Compare commits

..

No commits in common. "a1ea09440d5837f2ea0b27cc10dcffda4d8eb2a6" and "0e73d80707e6629f0729c43185d0054e7c82fdfd" have entirely different histories.

7 changed files with 9 additions and 515 deletions

View File

@ -6,7 +6,6 @@ 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
@ -88,43 +87,6 @@ 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.

View File

@ -19,8 +19,6 @@ 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"
@ -56,8 +54,8 @@ kotlin {
executable() executable()
all { all {
if (buildType == org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType.RELEASE) { if (buildType == org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType.RELEASE) {
debuggable = false debuggable = true
optimized = true optimized = false
} }
} }
} }
@ -85,9 +83,6 @@ 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)
} }
@ -105,16 +100,5 @@ 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
)
}

View File

@ -28,8 +28,6 @@ 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
@ -38,7 +36,6 @@ 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
@ -60,14 +57,6 @@ 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
@ -82,51 +71,6 @@ 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)
@ -147,76 +91,14 @@ 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 suspend fun Scope.installCliDeclarations() { private fun Scope.installCliBuiltins() {
eval(Source("<cli-builtins>", cliBuiltinsDeclarations))
}
private fun Scope.installCliBuiltins(runtime: CliExecutionRuntime? = null) {
addFn("exit") { addFn("exit") {
val code = requireOnlyArg<ObjInt>().toInt() exit(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
} }
} }
@ -355,7 +237,6 @@ 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()))
} }
@ -527,22 +408,12 @@ 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 {
try { evalOnCliDispatcher(session, source)
evalOnCliDispatcher(session, source)
} catch (e: CliExitRequested) {
requestedExitCode = e.code
}
} finally { } finally {
shutdownHooks.uninstall() session.cancelAndJoin()
runtime.shutdown() shutdownSystemNetEngine()
} }
requestedExitCode?.let { exit(it) }
} }
internal suspend fun evalOnCliDispatcher(session: EvalSession, source: Source): Obj = internal suspend fun evalOnCliDispatcher(session: EvalSession, source: Source): Obj =

View File

@ -24,34 +24,6 @@ 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)
} }

View File

@ -1,119 +0,0 @@
/*
* 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

@ -1,129 +0,0 @@
/*
* 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,40 +22,11 @@
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()
@ -91,24 +62,6 @@ 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)
} }