From 3105e1c55103f1eb399c9516ef1db6791b988d7f Mon Sep 17 00:00:00 2001 From: Ernie Chu Date: Sun, 1 Nov 2026 15:58:55 -0500 Subject: [PATCH] jetlink-server: a SIGTERM during start-up stops it cleanly on Linux The stop signals are held from the start so a SIGTERM that comes early waits for the handler, but on Linux dispatchMain leaves the main thread in sigsuspend with nothing blocked, while the sources register asynchronously. A held signal that came before they had taken over ended the process with no shutdown: every time at the serving line, and about one time in sixteen at the web page line, where sigtermStopsItCleanly sends it. The sources now register on their own queue, and the main thread goes to dispatch only once they have; the handler still runs on the main queue. A new Linux test sends SIGTERM at the serving line. It failed 5 of 5 before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HkbKCyD7z4w52noeFzme3w --- JetlinkKit/Sources/jetlink-server/Serve.swift | 17 +++++++-- .../ProcessTests.swift | 35 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/JetlinkKit/Sources/jetlink-server/Serve.swift b/JetlinkKit/Sources/jetlink-server/Serve.swift index a58890cb..b9799196 100644 --- a/JetlinkKit/Sources/jetlink-server/Serve.swift +++ b/JetlinkKit/Sources/jetlink-server/Serve.swift @@ -210,6 +210,12 @@ /// Runs `stop` on the main queue at SIGINT or SIGTERM, then exits 0 by /// `exitNow`. Never returns. func stopOnSignals(_ stop: @escaping @Sendable (String) -> Void) -> Never { + // The sources are registered before the main thread goes to dispatch: on + // Linux dispatchMain leaves it in sigsuspend with nothing blocked, so a + // held signal that comes before a source has taken over ends the process. + // Their own queue, since the main queue does not run until then. + let queue = DispatchQueue(label: "jetlink.signals") + let registered = DispatchGroup() let sources = stopSignals.map { number, name in #if os(macOS) // Handled by the source alone; a shell that starts this in the @@ -218,14 +224,19 @@ #endif // Not on Linux, where the signals are held: ignoring one drops it if // it is already pending, a SIGTERM that came during start-up. - let source = DispatchSource.makeSignalSource(signal: number, queue: .main) + let source = DispatchSource.makeSignalSource(signal: number, queue: queue) + registered.enter() + source.setRegistrationHandler { registered.leave() } source.setEventHandler { - stop(name) - exitNow(0) + DispatchQueue.main.async { + stop(name) + exitNow(0) + } } source.resume() return source } + registered.wait() withExtendedLifetime(sources) { dispatchMain() } } diff --git a/JetlinkKit/Tests/JetlinkServerCommandTests/ProcessTests.swift b/JetlinkKit/Tests/JetlinkServerCommandTests/ProcessTests.swift index aee0f5aa..480bb295 100644 --- a/JetlinkKit/Tests/JetlinkServerCommandTests/ProcessTests.swift +++ b/JetlinkKit/Tests/JetlinkServerCommandTests/ProcessTests.swift @@ -130,5 +130,40 @@ #expect(stopping.upperBound <= server.lowerBound) #expect(server.upperBound <= statusPage.lowerBound) } + + #if os(Linux) + /// A SIGTERM during start-up waits for the handler, rather than ending + /// the process: Linux holds it until the signal sources are registered. + @Test func sigtermDuringStartUpStillStopsCleanly() throws { + let tmp = try TemporaryDirectory() + let page = try TCPListener(host: "127.0.0.1", port: 0).port + let log = try tmp.file("server.log", "") + let process = Process() + process.executableURL = try #require(Binary.url) + process.arguments = [ + "--backend", "ort", "--device", "cpu", "--listen", "--host", "127.0.0.1", "--port", "0", "--cache", tmp.path, "--no-preload", + "--status-port", "\(page)", + ] + let handle = try FileHandle(forWritingTo: log) + process.standardOutput = handle + process.standardError = handle + try process.run() + defer { if process.isRunning { process.terminate() } } + + // At the serving line, before the web page and the handler are up. + let serving = eventually(timeout: 30) { + !process.isRunning || String(decoding: (try? Data(contentsOf: log)) ?? Data(), as: UTF8.self).contains("jetlink-server is serving") + } + guard serving, process.isRunning else { + Issue.record("never served: \(String(decoding: (try? Data(contentsOf: log)) ?? Data(), as: UTF8.self))") + return + } + kill(process.processIdentifier, SIGTERM) + process.waitUntilExit() + let text = String(decoding: try Data(contentsOf: log), as: UTF8.self) + #expect(process.terminationReason == .exit && process.terminationStatus == 0, "\(text)") + #expect(text.contains("stopping on SIGTERM") && text.contains("stopped the web page"), "\(text)") + } + #endif } #endif