From 7c8d47a86fa4fb02573e3441852021a762fe8543 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 23:48:53 -0700 Subject: [PATCH] test: synchronize process timeout fixture --- CHANGELOG.md | 1 + .../AttachmentResolverProcessTests.swift | 49 ---------------- Tests/IMsgCoreTests/ProcessTimeoutTests.swift | 57 +++++++++++++++++++ 3 files changed, 58 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 333622a..7282b3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Reliability - fix: bound osascript send, reaction, and helper-process waits with process-tree cleanup so stalled subprocesses cannot hang CLI or RPC work (#197, thanks @SebTardif). +- test: synchronize process-tree timeout regression proof with deterministic child readiness so scheduler delays cannot race its deadline. ### Native Polls - fix: render complete native poll selection snapshots in human-readable history and watch output while preserving the existing poll-vote prefix (#198, thanks @clawSean). diff --git a/Tests/IMsgCoreTests/AttachmentResolverProcessTests.swift b/Tests/IMsgCoreTests/AttachmentResolverProcessTests.swift index 92c71c9..5285a18 100644 --- a/Tests/IMsgCoreTests/AttachmentResolverProcessTests.swift +++ b/Tests/IMsgCoreTests/AttachmentResolverProcessTests.swift @@ -32,52 +32,3 @@ func attachmentResolverConversionTimesOutOnHungConverter() throws { #expect(elapsed < .seconds(5)) #expect(elapsed >= .milliseconds(300)) } - -@Test -func attachmentResolverConversionKillsDescendantsAfterLeaderExits() throws { - let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: dir) } - - let pidFile = dir.appendingPathComponent("pids") - let hung = dir.appendingPathComponent("ffmpeg") - try """ - #!/bin/sh - trap 'exit 0' TERM - sh -c 'trap "" TERM; exec sleep 30' & - child=$! - printf '%s %s\n' "$$" "$child" > "\(pidFile.path)" - wait "$child" - """.write(to: hung, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: hung.path) - - let exitStatus = try AttachmentResolver.runConversionProcess( - executableURL: hung, - arguments: [], - timeout: 0.5 - ) - let processIDs = try String(contentsOf: pidFile, encoding: .utf8) - .split(separator: " ") - .compactMap { pid_t($0.trimmingCharacters(in: .whitespacesAndNewlines)) } - - #expect(exitStatus == 128 + SIGTERM) - #expect(processIDs.count == 2) - for processID in processIDs { - #expect(waitForProcessExit(processID)) - } -} - -private func waitForProcessExit(_ processID: pid_t) -> Bool { - let clock = ContinuousClock() - let deadline = clock.now + .seconds(2) - while clock.now < deadline { - errno = 0 - let result = kill(processID, 0) - let probeError = errno - if result == -1, probeError == ESRCH { - return true - } - Thread.sleep(forTimeInterval: 0.02) - } - return false -} diff --git a/Tests/IMsgCoreTests/ProcessTimeoutTests.swift b/Tests/IMsgCoreTests/ProcessTimeoutTests.swift index 05fea37..acd269a 100644 --- a/Tests/IMsgCoreTests/ProcessTimeoutTests.swift +++ b/Tests/IMsgCoreTests/ProcessTimeoutTests.swift @@ -51,6 +51,48 @@ func processTimeoutAllowsQuickExit() throws { #expect(process.terminationStatus == 0) } +@Test +func processTimeoutKillsDescendantsAfterLeaderExits() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let pidFile = dir.appendingPathComponent("pids") + let hung = dir.appendingPathComponent("process-tree") + try """ + #!/bin/sh + trap 'exit 0' TERM + sh -c 'trap "" TERM; exec sleep 30' >/dev/null 2>&1 & + child=$! + printf '%s %s\n' "$$" "$child" > "\(pidFile.path)" + printf R + wait "$child" + """.write(to: hung, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: hung.path) + + let readyPipe = Pipe() + let process = Process() + process.executableURL = hung + process.standardOutput = readyPipe + process.standardError = FileHandle(forWritingAtPath: "/dev/null") + try process.run() + readyPipe.fileHandleForWriting.closeFile() + + let ready = readyPipe.fileHandleForReading.readData(ofLength: 1) + try #require(ready == Data("R".utf8)) + let processIDs = try String(contentsOf: pidFile, encoding: .utf8) + .split(separator: " ") + .compactMap { pid_t($0.trimmingCharacters(in: .whitespacesAndNewlines)) } + try #require(processIDs.count == 2) + + let timedOut = ProcessTimeout.waitUntilExit(process, timeout: 0.5) + + #expect(timedOut) + for processID in processIDs { + #expect(waitForProcessExit(processID)) + } +} + @Test func processTimeoutReapsHungOsascript() throws { // Same launch shape as MessageSender.runOsascript / ReactCommand.runAppleScript: @@ -93,3 +135,18 @@ func processTimeoutAllowsCsrutilStatus() throws { #expect(!timedOut) #expect(!task.isRunning) } + +private func waitForProcessExit(_ processID: pid_t) -> Bool { + let clock = ContinuousClock() + let deadline = clock.now + .seconds(2) + while clock.now < deadline { + errno = 0 + let result = kill(processID, 0) + let probeError = errno + if result == -1, probeError == ESRCH { + return true + } + Thread.sleep(forTimeInterval: 0.02) + } + return false +}