Add CLI atExit shutdown handlers

This commit is contained in:
Sergey Chernov 2026-04-09 22:17:45 +03:00
parent 0e73d80707
commit 88ce04102a
7 changed files with 513 additions and 7 deletions

View File

@ -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.
- Resolve local file imports from the executed script's directory tree.
- Format Lyng source files via the built-in `fmt` subcommand.
- Register synchronous process-exit handlers with `atExit(...)`.
## Building on Linux
@ -87,6 +88,43 @@ lyng --version
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
When you execute a script file, the CLI builds a temporary local import manager rooted at the directory that contains the entry script.

View File

@ -19,6 +19,8 @@ plugins {
alias(libs.plugins.kotlinMultiplatform)
}
import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest
group = "net.sergeych"
version = "unspecified"
@ -83,6 +85,9 @@ kotlin {
implementation(libs.okio.fakefilesystem)
}
}
val linuxTest by creating {
dependsOn(commonTest)
}
val nativeMain by creating {
dependsOn(commonMain)
}
@ -100,5 +105,16 @@ kotlin {
val linuxX64Main by getting {
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
)
}

View File

@ -28,6 +28,8 @@ import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import net.sergeych.lyng.EvalSession
import net.sergeych.lyng.LyngVersion
@ -36,6 +38,7 @@ import net.sergeych.lyng.Scope
import net.sergeych.lyng.Script
import net.sergeych.lyng.ScriptError
import net.sergeych.lyng.Source
import net.sergeych.lyng.asFacade
import net.sergeych.lyng.io.console.createConsoleModule
import net.sergeych.lyng.io.fs.createFs
import net.sergeych.lyng.io.http.createHttpModule
@ -57,6 +60,14 @@ import okio.Path.Companion.toPath
expect fun exit(code: Int)
internal expect class CliPlatformShutdownHooks {
fun uninstall()
companion object {
fun install(runtime: CliExecutionRuntime): CliPlatformShutdownHooks
}
}
expect class ShellCommandExecutor {
fun executeCommand(command: String): CommandResult
@ -71,6 +82,51 @@ data class CommandResult(
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 {
val manager = Script.defaultImportManager.copy().apply {
installCliModules(this)
@ -91,14 +147,76 @@ val baseScopeDefer = globalDefer {
baseCliImportManagerDefer.await().copy().apply {
invalidateCliModuleCaches()
}.newStdScope().apply {
installCliDeclarations()
installCliBuiltins()
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") {
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
}
}
@ -237,6 +355,7 @@ private fun registerLocalCliModules(manager: ImportManager, modules: List<LocalC
private suspend fun ImportManager.newCliScope(argv: List<String>): Scope =
newStdScope().apply {
installCliDeclarations()
installCliBuiltins()
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) {
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 {
evalOnCliDispatcher(session, source)
try {
evalOnCliDispatcher(session, source)
} catch (e: CliExitRequested) {
requestedExitCode = e.code
}
} finally {
session.cancelAndJoin()
shutdownSystemNetEngine()
shutdownHooks.uninstall()
runtime.shutdown()
}
requestedExitCode?.let { exit(it) }
}
internal suspend fun evalOnCliDispatcher(session: EvalSession, source: Source): Obj =

View File

@ -24,6 +24,34 @@ import kotlin.system.exitProcess
@PublishedApi
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) {
jvmExitImpl(code)
}
}

View File

@ -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" })
}
}

View File

@ -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
}
}

View File

@ -22,11 +22,40 @@
package net.sergeych
import kotlinx.cinterop.*
import kotlin.native.concurrent.ThreadLocal
import platform.posix.fgets
import platform.posix.pclose
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
@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 fun executeCommand(command: String): CommandResult {
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) {
exitProcess(code)
}
}