Compare commits
No commits in common. "a1ea09440d5837f2ea0b27cc10dcffda4d8eb2a6" and "0e73d80707e6629f0729c43185d0054e7c82fdfd" have entirely different histories.
a1ea09440d
...
0e73d80707
@ -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.
|
||||||
|
|||||||
@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
@ -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" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user