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