Add CLI atExit shutdown handlers
This commit is contained in:
parent
0e73d80707
commit
88ce04102a
@ -6,6 +6,7 @@ The Lyng CLI is the reference command-line tool for the Lyng language. It lets y
|
|||||||
- Use standard argument passing (`ARGV`) to your scripts.
|
- Use standard argument passing (`ARGV`) to your scripts.
|
||||||
- Resolve local file imports from the executed script's directory tree.
|
- Resolve local file imports from the executed script's directory tree.
|
||||||
- Format Lyng source files via the built-in `fmt` subcommand.
|
- Format Lyng source files via the built-in `fmt` subcommand.
|
||||||
|
- Register synchronous process-exit handlers with `atExit(...)`.
|
||||||
|
|
||||||
|
|
||||||
## Building on Linux
|
## Building on Linux
|
||||||
@ -87,6 +88,43 @@ lyng --version
|
|||||||
lyng --help
|
lyng --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Exit handlers: `atExit(...)`
|
||||||
|
|
||||||
|
The CLI exposes a CLI-only builtin:
|
||||||
|
|
||||||
|
```lyng
|
||||||
|
extern fun atExit(append: Bool=true, handler: ()->Void)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use it to register synchronous cleanup handlers that should run when the CLI process is leaving.
|
||||||
|
|
||||||
|
Semantics:
|
||||||
|
- `append=true` appends the handler to the end of the queue.
|
||||||
|
- `append=false` inserts the handler at the front of the queue.
|
||||||
|
- Handlers run one by one.
|
||||||
|
- Exceptions thrown by a handler are ignored, and the next handler still runs.
|
||||||
|
- Handlers are best-effort and run on:
|
||||||
|
- normal script completion
|
||||||
|
- script failure
|
||||||
|
- script `exit(code)`
|
||||||
|
- process shutdown such as `SIGTERM`
|
||||||
|
|
||||||
|
Non-goals:
|
||||||
|
- `SIGKILL`, hard crashes, and power loss cannot be intercepted.
|
||||||
|
- `atExit` is currently a CLI feature only; it is not part of the general embedding/runtime surface.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```lyng
|
||||||
|
atExit {
|
||||||
|
println("closing resources")
|
||||||
|
}
|
||||||
|
|
||||||
|
atExit(false) {
|
||||||
|
println("runs first")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Local imports for file execution
|
### Local imports for file execution
|
||||||
|
|
||||||
When you execute a script file, the CLI builds a temporary local import manager rooted at the directory that contains the entry script.
|
When you execute a script file, the CLI builds a temporary local import manager rooted at the directory that contains the entry script.
|
||||||
|
|||||||
@ -19,6 +19,8 @@ plugins {
|
|||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest
|
||||||
|
|
||||||
group = "net.sergeych"
|
group = "net.sergeych"
|
||||||
version = "unspecified"
|
version = "unspecified"
|
||||||
|
|
||||||
@ -83,6 +85,9 @@ kotlin {
|
|||||||
implementation(libs.okio.fakefilesystem)
|
implementation(libs.okio.fakefilesystem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val linuxTest by creating {
|
||||||
|
dependsOn(commonTest)
|
||||||
|
}
|
||||||
val nativeMain by creating {
|
val nativeMain by creating {
|
||||||
dependsOn(commonMain)
|
dependsOn(commonMain)
|
||||||
}
|
}
|
||||||
@ -100,5 +105,16 @@ kotlin {
|
|||||||
val linuxX64Main by getting {
|
val linuxX64Main by getting {
|
||||||
dependsOn(nativeMain)
|
dependsOn(nativeMain)
|
||||||
}
|
}
|
||||||
|
val linuxX64Test by getting {
|
||||||
|
dependsOn(linuxTest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.named<KotlinNativeTest>("linuxX64Test") {
|
||||||
|
dependsOn(tasks.named("linkDebugExecutableLinuxX64"))
|
||||||
|
environment(
|
||||||
|
"LYNG_CLI_NATIVE_BIN",
|
||||||
|
layout.buildDirectory.file("bin/linuxX64/debugExecutable/lyng.kexe").get().asFile.absolutePath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -28,6 +28,8 @@ import com.github.ajalt.clikt.parameters.options.flag
|
|||||||
import com.github.ajalt.clikt.parameters.options.option
|
import com.github.ajalt.clikt.parameters.options.option
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import net.sergeych.lyng.EvalSession
|
import net.sergeych.lyng.EvalSession
|
||||||
import net.sergeych.lyng.LyngVersion
|
import net.sergeych.lyng.LyngVersion
|
||||||
@ -36,6 +38,7 @@ import net.sergeych.lyng.Scope
|
|||||||
import net.sergeych.lyng.Script
|
import net.sergeych.lyng.Script
|
||||||
import net.sergeych.lyng.ScriptError
|
import net.sergeych.lyng.ScriptError
|
||||||
import net.sergeych.lyng.Source
|
import net.sergeych.lyng.Source
|
||||||
|
import net.sergeych.lyng.asFacade
|
||||||
import net.sergeych.lyng.io.console.createConsoleModule
|
import net.sergeych.lyng.io.console.createConsoleModule
|
||||||
import net.sergeych.lyng.io.fs.createFs
|
import net.sergeych.lyng.io.fs.createFs
|
||||||
import net.sergeych.lyng.io.http.createHttpModule
|
import net.sergeych.lyng.io.http.createHttpModule
|
||||||
@ -57,6 +60,14 @@ import okio.Path.Companion.toPath
|
|||||||
|
|
||||||
expect fun exit(code: Int)
|
expect fun exit(code: Int)
|
||||||
|
|
||||||
|
internal expect class CliPlatformShutdownHooks {
|
||||||
|
fun uninstall()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun install(runtime: CliExecutionRuntime): CliPlatformShutdownHooks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
expect class ShellCommandExecutor {
|
expect class ShellCommandExecutor {
|
||||||
fun executeCommand(command: String): CommandResult
|
fun executeCommand(command: String): CommandResult
|
||||||
|
|
||||||
@ -71,6 +82,51 @@ data class CommandResult(
|
|||||||
val error: String
|
val error: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private const val cliBuiltinsDeclarations = """
|
||||||
|
extern fun atExit(append: Bool=true, handler: ()->Void)
|
||||||
|
"""
|
||||||
|
|
||||||
|
private class CliExitRequested(val code: Int) : RuntimeException("CLI exit requested: $code")
|
||||||
|
|
||||||
|
internal class CliExecutionRuntime(
|
||||||
|
private val session: EvalSession,
|
||||||
|
private val rootScope: Scope
|
||||||
|
) {
|
||||||
|
private val shutdownMutex = Mutex()
|
||||||
|
private var shutdownStarted = false
|
||||||
|
private val exitHandlers = mutableListOf<Obj>()
|
||||||
|
|
||||||
|
fun registerAtExit(handler: Obj, append: Boolean) {
|
||||||
|
if (append) {
|
||||||
|
exitHandlers += handler
|
||||||
|
} else {
|
||||||
|
exitHandlers.add(0, handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun shutdown() {
|
||||||
|
shutdownMutex.withLock {
|
||||||
|
if (shutdownStarted) return
|
||||||
|
shutdownStarted = true
|
||||||
|
}
|
||||||
|
val handlers = exitHandlers.toList()
|
||||||
|
val facade = rootScope.asFacade()
|
||||||
|
for (handler in handlers) {
|
||||||
|
runCatching {
|
||||||
|
facade.call(handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.cancelAndJoin()
|
||||||
|
shutdownSystemNetEngine()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shutdownBlocking() {
|
||||||
|
runBlocking {
|
||||||
|
shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val baseCliImportManagerDefer = globalDefer {
|
private val baseCliImportManagerDefer = globalDefer {
|
||||||
val manager = Script.defaultImportManager.copy().apply {
|
val manager = Script.defaultImportManager.copy().apply {
|
||||||
installCliModules(this)
|
installCliModules(this)
|
||||||
@ -91,14 +147,76 @@ val baseScopeDefer = globalDefer {
|
|||||||
baseCliImportManagerDefer.await().copy().apply {
|
baseCliImportManagerDefer.await().copy().apply {
|
||||||
invalidateCliModuleCaches()
|
invalidateCliModuleCaches()
|
||||||
}.newStdScope().apply {
|
}.newStdScope().apply {
|
||||||
|
installCliDeclarations()
|
||||||
installCliBuiltins()
|
installCliBuiltins()
|
||||||
addConst("ARGV", ObjList(mutableListOf()))
|
addConst("ARGV", ObjList(mutableListOf()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Scope.installCliBuiltins() {
|
private suspend fun Scope.installCliDeclarations() {
|
||||||
|
eval(Source("<cli-builtins>", cliBuiltinsDeclarations))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Scope.installCliBuiltins(runtime: CliExecutionRuntime? = null) {
|
||||||
addFn("exit") {
|
addFn("exit") {
|
||||||
exit(requireOnlyArg<ObjInt>().toInt())
|
val code = requireOnlyArg<ObjInt>().toInt()
|
||||||
|
if (runtime == null) {
|
||||||
|
exit(code)
|
||||||
|
}
|
||||||
|
throw CliExitRequested(code)
|
||||||
|
}
|
||||||
|
addFn("atExit") {
|
||||||
|
if (runtime == null) {
|
||||||
|
raiseIllegalState("atExit is only available while running a CLI script")
|
||||||
|
}
|
||||||
|
if (args.list.size > 2) {
|
||||||
|
raiseError("Expected at most 2 positional arguments, got ${args.list.size}")
|
||||||
|
}
|
||||||
|
var append = true
|
||||||
|
var appendSet = false
|
||||||
|
var handler: Obj? = null
|
||||||
|
|
||||||
|
when (args.list.size) {
|
||||||
|
1 -> {
|
||||||
|
val only = args.list[0]
|
||||||
|
if (only.isInstanceOf("Callable")) {
|
||||||
|
handler = only
|
||||||
|
} else {
|
||||||
|
append = only.toBool()
|
||||||
|
appendSet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 -> {
|
||||||
|
append = args.list[0].toBool()
|
||||||
|
appendSet = true
|
||||||
|
handler = args.list[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((name, value) in args.named) {
|
||||||
|
when (name) {
|
||||||
|
"append" -> {
|
||||||
|
if (appendSet) {
|
||||||
|
raiseIllegalArgument("argument 'append' is already set")
|
||||||
|
}
|
||||||
|
append = value.toBool()
|
||||||
|
appendSet = true
|
||||||
|
}
|
||||||
|
"handler" -> {
|
||||||
|
if (handler != null) {
|
||||||
|
raiseIllegalArgument("argument 'handler' is already set")
|
||||||
|
}
|
||||||
|
handler = value
|
||||||
|
}
|
||||||
|
else -> raiseIllegalArgument("unknown argument '$name'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val handlerValue = handler ?: raiseError("argument 'handler' is required")
|
||||||
|
if (!handlerValue.isInstanceOf("Callable")) {
|
||||||
|
raiseClassCastError("Expected handler to be callable")
|
||||||
|
}
|
||||||
|
runtime.registerAtExit(handlerValue, append)
|
||||||
ObjVoid
|
ObjVoid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -237,6 +355,7 @@ private fun registerLocalCliModules(manager: ImportManager, modules: List<LocalC
|
|||||||
|
|
||||||
private suspend fun ImportManager.newCliScope(argv: List<String>): Scope =
|
private suspend fun ImportManager.newCliScope(argv: List<String>): Scope =
|
||||||
newStdScope().apply {
|
newStdScope().apply {
|
||||||
|
installCliDeclarations()
|
||||||
installCliBuiltins()
|
installCliBuiltins()
|
||||||
addConst("ARGV", ObjList(argv.map { ObjString(it) }.toMutableList()))
|
addConst("ARGV", ObjList(argv.map { ObjString(it) }.toMutableList()))
|
||||||
}
|
}
|
||||||
@ -408,12 +527,22 @@ fun executeFileWithArgs(fileName: String, args: List<String>) {
|
|||||||
|
|
||||||
suspend fun executeSource(source: Source, initialScope: Scope? = null) {
|
suspend fun executeSource(source: Source, initialScope: Scope? = null) {
|
||||||
val session = EvalSession(initialScope ?: baseScopeDefer.await())
|
val session = EvalSession(initialScope ?: baseScopeDefer.await())
|
||||||
|
val rootScope = session.getScope()
|
||||||
|
val runtime = CliExecutionRuntime(session, rootScope)
|
||||||
|
rootScope.installCliBuiltins(runtime)
|
||||||
|
val shutdownHooks = CliPlatformShutdownHooks.install(runtime)
|
||||||
|
var requestedExitCode: Int? = null
|
||||||
try {
|
try {
|
||||||
evalOnCliDispatcher(session, source)
|
try {
|
||||||
|
evalOnCliDispatcher(session, source)
|
||||||
|
} catch (e: CliExitRequested) {
|
||||||
|
requestedExitCode = e.code
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
session.cancelAndJoin()
|
shutdownHooks.uninstall()
|
||||||
shutdownSystemNetEngine()
|
runtime.shutdown()
|
||||||
}
|
}
|
||||||
|
requestedExitCode?.let { exit(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal suspend fun evalOnCliDispatcher(session: EvalSession, source: Source): Obj =
|
internal suspend fun evalOnCliDispatcher(session: EvalSession, source: Source): Obj =
|
||||||
|
|||||||
@ -24,6 +24,34 @@ import kotlin.system.exitProcess
|
|||||||
@PublishedApi
|
@PublishedApi
|
||||||
internal var jvmExitImpl: (Int) -> Nothing = { code -> exitProcess(code) }
|
internal var jvmExitImpl: (Int) -> Nothing = { code -> exitProcess(code) }
|
||||||
|
|
||||||
|
internal actual class CliPlatformShutdownHooks private constructor(
|
||||||
|
private val shutdownHook: Thread?
|
||||||
|
) {
|
||||||
|
actual fun uninstall() {
|
||||||
|
val hook = shutdownHook ?: return
|
||||||
|
runCatching {
|
||||||
|
Runtime.getRuntime().removeShutdownHook(hook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual companion object {
|
||||||
|
actual fun install(runtime: CliExecutionRuntime): CliPlatformShutdownHooks {
|
||||||
|
val hook = Thread(
|
||||||
|
{
|
||||||
|
runtime.shutdownBlocking()
|
||||||
|
},
|
||||||
|
"lyng-cli-shutdown"
|
||||||
|
)
|
||||||
|
return runCatching {
|
||||||
|
Runtime.getRuntime().addShutdownHook(hook)
|
||||||
|
CliPlatformShutdownHooks(hook)
|
||||||
|
}.getOrElse {
|
||||||
|
CliPlatformShutdownHooks(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
actual fun exit(code: Int) {
|
actual fun exit(code: Int) {
|
||||||
jvmExitImpl(code)
|
jvmExitImpl(code)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,119 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
package net.sergeych.lyng_cli
|
||||||
|
|
||||||
|
import net.sergeych.jvmExitImpl
|
||||||
|
import net.sergeych.runMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.PrintStream
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
class CliAtExitJvmTest {
|
||||||
|
private val originalOut: PrintStream = System.out
|
||||||
|
private val originalErr: PrintStream = System.err
|
||||||
|
|
||||||
|
private class TestExit(val code: Int) : RuntimeException()
|
||||||
|
|
||||||
|
private data class CliResult(val out: String, val err: String, val exitCode: Int?)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
jvmExitImpl = { code -> throw TestExit(code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
System.setOut(originalOut)
|
||||||
|
System.setErr(originalErr)
|
||||||
|
jvmExitImpl = { code -> kotlin.system.exitProcess(code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runCli(vararg args: String): CliResult {
|
||||||
|
val outBuf = ByteArrayOutputStream()
|
||||||
|
val errBuf = ByteArrayOutputStream()
|
||||||
|
System.setOut(PrintStream(outBuf, true, Charsets.UTF_8))
|
||||||
|
System.setErr(PrintStream(errBuf, true, Charsets.UTF_8))
|
||||||
|
|
||||||
|
var exitCode: Int? = null
|
||||||
|
try {
|
||||||
|
runMain(arrayOf(*args))
|
||||||
|
} catch (e: TestExit) {
|
||||||
|
exitCode = e.code
|
||||||
|
} finally {
|
||||||
|
System.out.flush()
|
||||||
|
System.err.flush()
|
||||||
|
}
|
||||||
|
return CliResult(outBuf.toString("UTF-8"), errBuf.toString("UTF-8"), exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runScript(scriptText: String): CliResult {
|
||||||
|
val tmp: Path = Files.createTempFile("lyng_atexit_", ".lyng")
|
||||||
|
try {
|
||||||
|
Files.writeString(tmp, scriptText)
|
||||||
|
return runCli(tmp.toString())
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(tmp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun atExitRunsInRequestedOrderAndIgnoresHandlerExceptions() {
|
||||||
|
val result = runScript(
|
||||||
|
"""
|
||||||
|
atExit {
|
||||||
|
println("tail")
|
||||||
|
}
|
||||||
|
atExit(false) {
|
||||||
|
println("head")
|
||||||
|
throw Exception("ignored")
|
||||||
|
}
|
||||||
|
println("body")
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(result.err.takeIf { it.isNotBlank() })
|
||||||
|
assertNull(result.exitCode)
|
||||||
|
val lines = result.out
|
||||||
|
.lineSequence()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.toList()
|
||||||
|
assertEquals(listOf("body", "head", "tail"), lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun atExitRunsBeforeScriptExitTerminatesProcess() {
|
||||||
|
val result = runScript(
|
||||||
|
"""
|
||||||
|
atExit {
|
||||||
|
println("cleanup")
|
||||||
|
}
|
||||||
|
exit(7)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(7, result.exitCode)
|
||||||
|
assertTrue(result.out.lineSequence().any { it.trim() == "cleanup" })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,129 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package net.sergeych.lyng_cli
|
||||||
|
|
||||||
|
import kotlinx.cinterop.*
|
||||||
|
import okio.FileSystem
|
||||||
|
import okio.Path
|
||||||
|
import okio.Path.Companion.toPath
|
||||||
|
import platform.posix.O_CREAT
|
||||||
|
import platform.posix.O_TRUNC
|
||||||
|
import platform.posix.O_WRONLY
|
||||||
|
import platform.posix.SIGTERM
|
||||||
|
import platform.posix._exit
|
||||||
|
import platform.posix.close
|
||||||
|
import platform.posix.dup2
|
||||||
|
import platform.posix.execvp
|
||||||
|
import platform.posix.fork
|
||||||
|
import platform.posix.getenv
|
||||||
|
import platform.posix.getpid
|
||||||
|
import platform.posix.kill
|
||||||
|
import platform.posix.open
|
||||||
|
import platform.posix.usleep
|
||||||
|
import platform.posix.waitpid
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
class CliAtExitLinuxNativeTest {
|
||||||
|
@Test
|
||||||
|
fun atExitRunsOnSigtermForNativeCli() {
|
||||||
|
val executable = getenv("LYNG_CLI_NATIVE_BIN")?.toKString()
|
||||||
|
?: error("LYNG_CLI_NATIVE_BIN is not set")
|
||||||
|
val fs = FileSystem.SYSTEM
|
||||||
|
val tempDir = "/tmp/lyng_cli_native_${getpid()}_${kotlin.random.Random.nextInt()}".toPath()
|
||||||
|
val scriptPath = tempDir / "sigterm.lyng"
|
||||||
|
val stdoutPath = tempDir / "stdout.txt"
|
||||||
|
val stderrPath = tempDir / "stderr.txt"
|
||||||
|
|
||||||
|
fs.createDirectories(tempDir)
|
||||||
|
try {
|
||||||
|
fs.write(scriptPath) {
|
||||||
|
writeUtf8(
|
||||||
|
"""
|
||||||
|
atExit {
|
||||||
|
println("cleanup-native")
|
||||||
|
}
|
||||||
|
while(true) {
|
||||||
|
yield()
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pid = launchCli(executable, scriptPath, stdoutPath, stderrPath)
|
||||||
|
usleep(300_000u)
|
||||||
|
assertEquals(0, kill(pid, SIGTERM), "failed to send SIGTERM")
|
||||||
|
|
||||||
|
val status = waitForPid(pid)
|
||||||
|
val exitCode = if ((status and 0x7f) == 0) (status shr 8) and 0xff else -1
|
||||||
|
val stdout = readUtf8IfExists(fs, stdoutPath)
|
||||||
|
val stderr = readUtf8IfExists(fs, stderrPath)
|
||||||
|
|
||||||
|
assertEquals(143, exitCode, "unexpected native CLI exit status; stderr=$stderr")
|
||||||
|
assertTrue(stdout.contains("cleanup-native"), "stdout did not contain cleanup marker. stdout=$stdout stderr=$stderr")
|
||||||
|
} finally {
|
||||||
|
fs.deleteRecursively(tempDir, mustExist = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readUtf8IfExists(fs: FileSystem, path: Path): String {
|
||||||
|
return if (fs.exists(path)) {
|
||||||
|
fs.read(path) { readUtf8() }
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForPid(pid: Int): Int = memScoped {
|
||||||
|
val status = alloc<IntVar>()
|
||||||
|
val waited = waitpid(pid, status.ptr, 0)
|
||||||
|
check(waited == pid) { "waitpid failed for $pid" }
|
||||||
|
status.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchCli(
|
||||||
|
executable: String,
|
||||||
|
scriptPath: Path,
|
||||||
|
stdoutPath: Path,
|
||||||
|
stderrPath: Path
|
||||||
|
): Int = memScoped {
|
||||||
|
val pid = fork()
|
||||||
|
check(pid >= 0) { "fork failed" }
|
||||||
|
if (pid == 0) {
|
||||||
|
val stdoutFd = open(stdoutPath.toString(), O_WRONLY or O_CREAT or O_TRUNC, 0x1A4)
|
||||||
|
val stderrFd = open(stderrPath.toString(), O_WRONLY or O_CREAT or O_TRUNC, 0x1A4)
|
||||||
|
if (stdoutFd < 0 || stderrFd < 0) {
|
||||||
|
_exit(2)
|
||||||
|
}
|
||||||
|
dup2(stdoutFd, 1)
|
||||||
|
dup2(stderrFd, 2)
|
||||||
|
close(stdoutFd)
|
||||||
|
close(stderrFd)
|
||||||
|
|
||||||
|
val argv = allocArray<CPointerVar<ByteVar>>(3)
|
||||||
|
argv[0] = executable.cstr.ptr
|
||||||
|
argv[1] = scriptPath.toString().cstr.ptr
|
||||||
|
argv[2] = null
|
||||||
|
execvp(executable, argv)
|
||||||
|
_exit(127)
|
||||||
|
}
|
||||||
|
pid
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,11 +22,40 @@
|
|||||||
package net.sergeych
|
package net.sergeych
|
||||||
|
|
||||||
import kotlinx.cinterop.*
|
import kotlinx.cinterop.*
|
||||||
|
import kotlin.native.concurrent.ThreadLocal
|
||||||
import platform.posix.fgets
|
import platform.posix.fgets
|
||||||
import platform.posix.pclose
|
import platform.posix.pclose
|
||||||
import platform.posix.popen
|
import platform.posix.popen
|
||||||
|
import platform.posix.signal
|
||||||
|
import platform.posix.atexit
|
||||||
|
import platform.posix.SIGINT
|
||||||
|
import platform.posix.SIGHUP
|
||||||
|
import platform.posix.SIGTERM
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
@ThreadLocal
|
||||||
|
private var activeCliRuntime: CliExecutionRuntime? = null
|
||||||
|
|
||||||
|
@ThreadLocal
|
||||||
|
private var nativeCliHooksInstalled: Boolean = false
|
||||||
|
|
||||||
|
private fun installNativeCliHooksOnce() {
|
||||||
|
if (nativeCliHooksInstalled) return
|
||||||
|
nativeCliHooksInstalled = true
|
||||||
|
atexit(staticCFunction(::nativeCliAtExit))
|
||||||
|
signal(SIGTERM, staticCFunction(::nativeCliSignalHandler))
|
||||||
|
signal(SIGINT, staticCFunction(::nativeCliSignalHandler))
|
||||||
|
signal(SIGHUP, staticCFunction(::nativeCliSignalHandler))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nativeCliAtExit() {
|
||||||
|
activeCliRuntime?.shutdownBlocking()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nativeCliSignalHandler(signal: Int) {
|
||||||
|
exitProcess(128 + signal)
|
||||||
|
}
|
||||||
|
|
||||||
actual class ShellCommandExecutor() {
|
actual class ShellCommandExecutor() {
|
||||||
actual fun executeCommand(command: String): CommandResult {
|
actual fun executeCommand(command: String): CommandResult {
|
||||||
val outputBuilder = StringBuilder()
|
val outputBuilder = StringBuilder()
|
||||||
@ -62,6 +91,24 @@ actual class ShellCommandExecutor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal actual class CliPlatformShutdownHooks private constructor(
|
||||||
|
private val runtime: CliExecutionRuntime
|
||||||
|
) {
|
||||||
|
actual fun uninstall() {
|
||||||
|
if (activeCliRuntime === runtime) {
|
||||||
|
activeCliRuntime = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual companion object {
|
||||||
|
actual fun install(runtime: CliExecutionRuntime): CliPlatformShutdownHooks {
|
||||||
|
installNativeCliHooksOnce()
|
||||||
|
activeCliRuntime = runtime
|
||||||
|
return CliPlatformShutdownHooks(runtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
actual fun exit(code: Int) {
|
actual fun exit(code: Int) {
|
||||||
exitProcess(code)
|
exitProcess(code)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user