Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
49 changes: 0 additions & 49 deletions Tests/IMsgCoreTests/AttachmentResolverProcessTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
57 changes: 57 additions & 0 deletions Tests/IMsgCoreTests/ProcessTimeoutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
}