diff --git a/docs/lyng_cli.md b/docs/lyng_cli.md index 8632ce6..ba87054 100644 --- a/docs/lyng_cli.md +++ b/docs/lyng_cli.md @@ -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. diff --git a/lyng/build.gradle.kts b/lyng/build.gradle.kts index 410b40a..a2b59db 100644 --- a/lyng/build.gradle.kts +++ b/lyng/build.gradle.kts @@ -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("linuxX64Test") { + dependsOn(tasks.named("linkDebugExecutableLinuxX64")) + environment( + "LYNG_CLI_NATIVE_BIN", + layout.buildDirectory.file("bin/linuxX64/debugExecutable/lyng.kexe").get().asFile.absolutePath + ) +} diff --git a/lyng/src/commonMain/kotlin/Common.kt b/lyng/src/commonMain/kotlin/Common.kt index 7b89c4d..a54f679 100644 --- a/lyng/src/commonMain/kotlin/Common.kt +++ b/lyng/src/commonMain/kotlin/Common.kt @@ -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() + + 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("", cliBuiltinsDeclarations)) +} + +private fun Scope.installCliBuiltins(runtime: CliExecutionRuntime? = null) { addFn("exit") { - exit(requireOnlyArg().toInt()) + val code = requireOnlyArg().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): Scope = newStdScope().apply { + installCliDeclarations() installCliBuiltins() addConst("ARGV", ObjList(argv.map { ObjString(it) }.toMutableList())) } @@ -408,12 +527,22 @@ fun executeFileWithArgs(fileName: String, args: List) { 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 = diff --git a/lyng/src/jvmMain/kotlin/Common.jvm.kt b/lyng/src/jvmMain/kotlin/Common.jvm.kt index acf3001..87b446d 100644 --- a/lyng/src/jvmMain/kotlin/Common.jvm.kt +++ b/lyng/src/jvmMain/kotlin/Common.jvm.kt @@ -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) -} \ No newline at end of file +} diff --git a/lyng/src/jvmTest/kotlin/net/sergeych/lyng_cli/CliAtExitJvmTest.kt b/lyng/src/jvmTest/kotlin/net/sergeych/lyng_cli/CliAtExitJvmTest.kt new file mode 100644 index 0000000..2c1897a --- /dev/null +++ b/lyng/src/jvmTest/kotlin/net/sergeych/lyng_cli/CliAtExitJvmTest.kt @@ -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" }) + } +} diff --git a/lyng/src/linuxTest/kotlin/net/sergeych/lyng_cli/CliAtExitLinuxNativeTest.kt b/lyng/src/linuxTest/kotlin/net/sergeych/lyng_cli/CliAtExitLinuxNativeTest.kt new file mode 100644 index 0000000..024d2a5 --- /dev/null +++ b/lyng/src/linuxTest/kotlin/net/sergeych/lyng_cli/CliAtExitLinuxNativeTest.kt @@ -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() + 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>(3) + argv[0] = executable.cstr.ptr + argv[1] = scriptPath.toString().cstr.ptr + argv[2] = null + execvp(executable, argv) + _exit(127) + } + pid + } +} diff --git a/lyng/src/nativeMain/kotlin/Common.native.kt b/lyng/src/nativeMain/kotlin/Common.native.kt index 985aa65..6aa8fd9 100644 --- a/lyng/src/nativeMain/kotlin/Common.native.kt +++ b/lyng/src/nativeMain/kotlin/Common.native.kt @@ -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) -} \ No newline at end of file +}