From 6cb8ca677e4fce88c1208638cef282fb3ae78508 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 27 Jul 2026 11:32:02 -0400 Subject: [PATCH 1/5] fix: bound osascript wait with the shared process timeout Extract ProcessTimeout from the ffmpeg conversion wait and apply the same monotonic deadline to MessageSender and ReactCommand osascript paths so hung Messages automation cannot block indefinitely. Signed-off-by: Sebastien Tardif --- CHANGELOG.md | 3 + Sources/IMsgCore/AttachmentResolver.swift | 41 +------------- Sources/IMsgCore/MessageSender.swift | 9 ++- Sources/IMsgCore/ProcessTimeout.swift | 56 +++++++++++++++++++ Sources/imsg/Commands/ReactCommand.swift | 8 ++- Tests/IMsgCoreTests/ProcessTimeoutTests.swift | 52 +++++++++++++++++ 6 files changed, 129 insertions(+), 40 deletions(-) create mode 100644 Sources/IMsgCore/ProcessTimeout.swift create mode 100644 Tests/IMsgCoreTests/ProcessTimeoutTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 67ac9245..f48c2f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.13.5 - Unreleased +### Reliability +- fix: bound `osascript` waits in message send (NSAppleScript fallback) and react automation with the same monotonic process timeout used for ffmpeg conversion, so a hung Messages automation cannot block the CLI/RPC indefinitely. + ## 0.13.4 - 2026-07-27 ### Highlights diff --git a/Sources/IMsgCore/AttachmentResolver.swift b/Sources/IMsgCore/AttachmentResolver.swift index c033f886..5f5717da 100644 --- a/Sources/IMsgCore/AttachmentResolver.swift +++ b/Sources/IMsgCore/AttachmentResolver.swift @@ -126,7 +126,7 @@ enum AttachmentResolver { /// Default bound for external converters (ffmpeg). Hung converters must not /// block attachment metadata resolution indefinitely. - static let conversionProcessTimeout: TimeInterval = 60 + static let conversionProcessTimeout: TimeInterval = ProcessTimeout.defaultTimeout static func runConversionProcess( executableURL: URL, @@ -140,47 +140,12 @@ enum AttachmentResolver { process.standardError = FileHandle(forWritingAtPath: "/dev/null") try process.run() - - // Monotonic deadline so wall-clock jumps cannot stretch the bound. - let clock = ContinuousClock() - let bound = Duration.seconds(max(0.05, timeout)) - let deadline = clock.now + bound - while process.isRunning { - if clock.now >= deadline { - terminateConversionProcess(process) - process.waitUntilExit() - return 128 + SIGTERM - } - Thread.sleep(forTimeInterval: 0.05) + if ProcessTimeout.waitUntilExit(process, timeout: timeout) { + return 128 + SIGTERM } return process.terminationStatus } - /// SIGTERM the process, then SIGKILL process and process-group after grace. - private static func terminateConversionProcess(_ process: Process) { - let pid = process.processIdentifier - guard pid > 0 else { return } - let ownsProcessGroup = getpgid(pid) == pid - process.terminate() - if ownsProcessGroup { - kill(-pid, SIGTERM) - } - - let clock = ContinuousClock() - let killDeadline = clock.now + .milliseconds(500) - while process.isRunning, clock.now < killDeadline { - Thread.sleep(forTimeInterval: 0.02) - } - if process.isRunning { - kill(pid, SIGKILL) - } - // The leader may exit on SIGTERM while a descendant ignores it. Escalate - // the captured group independently of the leader's state. - if ownsProcessGroup { - kill(-pid, SIGKILL) - } - } - private static func conversionPlan( path: String, uti: String, diff --git a/Sources/IMsgCore/MessageSender.swift b/Sources/IMsgCore/MessageSender.swift index 29eb7037..c1435539 100644 --- a/Sources/IMsgCore/MessageSender.swift +++ b/Sources/IMsgCore/MessageSender.swift @@ -319,6 +319,10 @@ public struct MessageSender { #endif } + /// Bound for osascript fallback when NSAppleScript is unauthorized. + /// Hung Messages automation must not block send/RPC indefinitely. + static let osascriptTimeout: TimeInterval = ProcessTimeout.defaultTimeout + private static func runOsascript(source: String, arguments: [String]) throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") @@ -332,7 +336,10 @@ public struct MessageSender { stdinPipe.fileHandleForWriting.write(data) } stdinPipe.fileHandleForWriting.closeFile() - process.waitUntilExit() + if ProcessTimeout.waitUntilExit(process, timeout: osascriptTimeout) { + throw IMsgError.appleScriptFailure( + "osascript timed out after \(Int(osascriptTimeout))s") + } if process.terminationStatus != 0 { let data = stderrPipe.fileHandleForReading.readDataToEndOfFile() let message = String(data: data, encoding: .utf8) ?? "Unknown osascript error" diff --git a/Sources/IMsgCore/ProcessTimeout.swift b/Sources/IMsgCore/ProcessTimeout.swift new file mode 100644 index 00000000..b6b859ae --- /dev/null +++ b/Sources/IMsgCore/ProcessTimeout.swift @@ -0,0 +1,56 @@ +import Darwin +import Foundation + +/// Bounded waits for external processes (ffmpeg, osascript, …). +/// Hung children must not block CLI/RPC work indefinitely. +public enum ProcessTimeout { + /// Default bound for short external helpers (converters, osascript). + public static let defaultTimeout: TimeInterval = 60 + + /// Wait for `process` to exit, or terminate it when `timeout` elapses. + /// - Returns: `true` if the process was killed because the deadline was reached. + @discardableResult + public static func waitUntilExit( + _ process: Process, + timeout: TimeInterval = defaultTimeout + ) -> Bool { + // Monotonic deadline so wall-clock jumps cannot stretch the bound. + let clock = ContinuousClock() + let bound = Duration.seconds(max(0.05, timeout)) + let deadline = clock.now + bound + while process.isRunning { + if clock.now >= deadline { + terminate(process) + process.waitUntilExit() + return true + } + Thread.sleep(forTimeInterval: 0.05) + } + return false + } + + /// SIGTERM the process, then SIGKILL process and process-group after grace. + public static func terminate(_ process: Process) { + let pid = process.processIdentifier + guard pid > 0 else { return } + let ownsProcessGroup = getpgid(pid) == pid + process.terminate() + if ownsProcessGroup { + kill(-pid, SIGTERM) + } + + let clock = ContinuousClock() + let killDeadline = clock.now + .milliseconds(500) + while process.isRunning, clock.now < killDeadline { + Thread.sleep(forTimeInterval: 0.02) + } + if process.isRunning { + kill(pid, SIGKILL) + } + // The leader may exit on SIGTERM while a descendant ignores it. Escalate + // the captured group independently of the leader's state. + if ownsProcessGroup { + kill(-pid, SIGKILL) + } + } +} diff --git a/Sources/imsg/Commands/ReactCommand.swift b/Sources/imsg/Commands/ReactCommand.swift index 0765e43c..17eb29e9 100644 --- a/Sources/imsg/Commands/ReactCommand.swift +++ b/Sources/imsg/Commands/ReactCommand.swift @@ -170,6 +170,9 @@ enum ReactCommand { return scalar.properties.isEmoji || scalar.properties.isEmojiPresentation } + /// Bound for react UI automation. Hung osascript must not block the CLI. + static let osascriptTimeout: TimeInterval = ProcessTimeout.defaultTimeout + private static func runAppleScript(_ source: String, arguments: [String]) throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") @@ -185,7 +188,10 @@ enum ReactCommand { stdinPipe.fileHandleForWriting.write(data) } stdinPipe.fileHandleForWriting.closeFile() - process.waitUntilExit() + if ProcessTimeout.waitUntilExit(process, timeout: osascriptTimeout) { + throw IMsgError.appleScriptFailure( + "osascript timed out after \(Int(osascriptTimeout))s") + } if process.terminationStatus != 0 { let data = stderrPipe.fileHandleForReading.readDataToEndOfFile() diff --git a/Tests/IMsgCoreTests/ProcessTimeoutTests.swift b/Tests/IMsgCoreTests/ProcessTimeoutTests.swift new file mode 100644 index 00000000..5a04cb07 --- /dev/null +++ b/Tests/IMsgCoreTests/ProcessTimeoutTests.swift @@ -0,0 +1,52 @@ +import Darwin +import Foundation +import Testing + +@testable import IMsgCore + +@Test +func processTimeoutKillsHungProcess() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + // `exec` replaces the shell with sleep so the Process PID is the sleeper + // itself (no orphan child after we kill the converter PID). + let hung = dir.appendingPathComponent("sleeper") + try """ + #!/bin/sh + exec sleep 30 + """.write(to: hung, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: hung.path) + + let process = Process() + process.executableURL = hung + process.arguments = [] + process.standardOutput = FileHandle(forWritingAtPath: "/dev/null") + process.standardError = FileHandle(forWritingAtPath: "/dev/null") + + try process.run() + let clock = ContinuousClock() + let start = clock.now + let timedOut = ProcessTimeout.waitUntilExit(process, timeout: 0.4) + let elapsed = start.duration(to: clock.now) + + #expect(timedOut) + #expect(!process.isRunning) + #expect(elapsed < .seconds(5)) + #expect(elapsed >= .milliseconds(300)) +} + +@Test +func processTimeoutAllowsQuickExit() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/true") + process.arguments = [] + process.standardOutput = FileHandle(forWritingAtPath: "/dev/null") + process.standardError = FileHandle(forWritingAtPath: "/dev/null") + + try process.run() + let timedOut = ProcessTimeout.waitUntilExit(process, timeout: 5) + #expect(!timedOut) + #expect(process.terminationStatus == 0) +} From cfc229d0cf2bb0da7574baac8628edfc91b08bd1 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 27 Jul 2026 11:42:17 -0400 Subject: [PATCH 2/5] fix: make ProcessTimeout compile on Linux read-core CI Import Darwin or Glibc conditionally like AttachmentResolver so linux-read-core does not fail on unconditional import Darwin. Signed-off-by: Sebastien Tardif --- Sources/IMsgCore/ProcessTimeout.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/IMsgCore/ProcessTimeout.swift b/Sources/IMsgCore/ProcessTimeout.swift index b6b859ae..15c3ecc3 100644 --- a/Sources/IMsgCore/ProcessTimeout.swift +++ b/Sources/IMsgCore/ProcessTimeout.swift @@ -1,6 +1,11 @@ -import Darwin import Foundation +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#endif + /// Bounded waits for external processes (ffmpeg, osascript, …). /// Hung children must not block CLI/RPC work indefinitely. public enum ProcessTimeout { From c0243a6cacf4426f999b3f313f0aa52bee402b9f Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 27 Jul 2026 14:48:07 -0400 Subject: [PATCH 3/5] fix: drop release-owned changelog; prove osascript timeout path Remove Unreleased CHANGELOG entry (release-owned). Add a ProcessTimeout test that launches real /usr/bin/osascript with delay 30 and reaps it under a short bound (same launch shape as MessageSender/ReactCommand). Signed-off-by: Sebastien Tardif --- CHANGELOG.md | 3 -- Tests/IMsgCoreTests/ProcessTimeoutTests.swift | 29 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f48c2f45..67ac9245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,6 @@ ## 0.13.5 - Unreleased -### Reliability -- fix: bound `osascript` waits in message send (NSAppleScript fallback) and react automation with the same monotonic process timeout used for ffmpeg conversion, so a hung Messages automation cannot block the CLI/RPC indefinitely. - ## 0.13.4 - 2026-07-27 ### Highlights diff --git a/Tests/IMsgCoreTests/ProcessTimeoutTests.swift b/Tests/IMsgCoreTests/ProcessTimeoutTests.swift index 5a04cb07..1a9b757a 100644 --- a/Tests/IMsgCoreTests/ProcessTimeoutTests.swift +++ b/Tests/IMsgCoreTests/ProcessTimeoutTests.swift @@ -50,3 +50,32 @@ func processTimeoutAllowsQuickExit() throws { #expect(!timedOut) #expect(process.terminationStatus == 0) } + +@Test +func processTimeoutReapsHungOsascript() throws { + // Same launch shape as MessageSender.runOsascript / ReactCommand.runAppleScript: + // /usr/bin/osascript -l AppleScript - with source on stdin. + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-l", "AppleScript", "-"] + let stdinPipe = Pipe() + process.standardInput = stdinPipe + process.standardOutput = FileHandle(forWritingAtPath: "/dev/null") + process.standardError = FileHandle(forWritingAtPath: "/dev/null") + + try process.run() + if let data = "delay 30\n".data(using: .utf8) { + stdinPipe.fileHandleForWriting.write(data) + } + stdinPipe.fileHandleForWriting.closeFile() + + let clock = ContinuousClock() + let start = clock.now + let timedOut = ProcessTimeout.waitUntilExit(process, timeout: 0.6) + let elapsed = start.duration(to: clock.now) + + #expect(timedOut) + #expect(!process.isRunning) + #expect(elapsed < .seconds(5)) + #expect(elapsed >= .milliseconds(400)) +} From 9d9fc3a6f92d747f0d41078faa68a3c2f51705bd Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 27 Jul 2026 15:45:01 -0400 Subject: [PATCH 4/5] fix: bound MessagesLauncher killall and csrutil waits Use ProcessTimeout for short helper processes so hung killall/csrutil cannot stall launcher setup (same policy as osascript/ffmpeg). Signed-off-by: Sebastien Tardif --- Sources/IMsgCore/MessagesLauncher.swift | 10 ++++++++-- Tests/IMsgCoreTests/ProcessTimeoutTests.swift | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Sources/IMsgCore/MessagesLauncher.swift b/Sources/IMsgCore/MessagesLauncher.swift index beb11bba..4a5fc54c 100644 --- a/Sources/IMsgCore/MessagesLauncher.swift +++ b/Sources/IMsgCore/MessagesLauncher.swift @@ -161,6 +161,10 @@ import Foundation } } + /// Bound for short helper processes (`killall`, `csrutil`). Must not hang + /// launcher/RPC setup if the helper stalls. + static let helperProcessTimeout: TimeInterval = 15 + /// Kill Messages.app if running. public func killMessages() { let task = Process() @@ -169,7 +173,7 @@ import Foundation task.standardOutput = FileHandle.nullDevice task.standardError = FileHandle.nullDevice try? task.run() - task.waitUntilExit() + _ = ProcessTimeout.waitUntilExit(task, timeout: Self.helperProcessTimeout) } /// Send a command asynchronously. @@ -224,7 +228,9 @@ import Foundation } catch { return nil } - task.waitUntilExit() + if ProcessTimeout.waitUntilExit(task, timeout: helperProcessTimeout) { + return nil + } let data = output.fileHandleForReading.readDataToEndOfFile() guard let text = String(data: data, encoding: .utf8) else { return nil } return text.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Tests/IMsgCoreTests/ProcessTimeoutTests.swift b/Tests/IMsgCoreTests/ProcessTimeoutTests.swift index 1a9b757a..05fea37c 100644 --- a/Tests/IMsgCoreTests/ProcessTimeoutTests.swift +++ b/Tests/IMsgCoreTests/ProcessTimeoutTests.swift @@ -79,3 +79,17 @@ func processTimeoutReapsHungOsascript() throws { #expect(elapsed < .seconds(5)) #expect(elapsed >= .milliseconds(400)) } + +@Test +func processTimeoutAllowsCsrutilStatus() throws { + let task = Process() + let output = Pipe() + task.executableURL = URL(fileURLWithPath: "/usr/bin/csrutil") + task.arguments = ["status"] + task.standardOutput = output + task.standardError = output + try task.run() + let timedOut = ProcessTimeout.waitUntilExit(task, timeout: MessagesLauncher.helperProcessTimeout) + #expect(!timedOut) + #expect(!task.isRunning) +} From 86f6d19d77a5d2c6e28602b52309b93b1f856016 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sat, 1 Aug 2026 22:57:00 -0400 Subject: [PATCH 5/5] fix: use send-style 150s deadline for osascript waits Fallback send and reaction automation must match IMsgBridgeProtocol defaultSendResponseTimeout rather than the 60s helper default. Signed-off-by: Sebastien Tardif --- Sources/IMsgCore/MessageSender.swift | 5 +++-- Sources/imsg/Commands/ReactCommand.swift | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/IMsgCore/MessageSender.swift b/Sources/IMsgCore/MessageSender.swift index c1435539..6903e53f 100644 --- a/Sources/IMsgCore/MessageSender.swift +++ b/Sources/IMsgCore/MessageSender.swift @@ -320,8 +320,9 @@ public struct MessageSender { } /// Bound for osascript fallback when NSAppleScript is unauthorized. - /// Hung Messages automation must not block send/RPC indefinitely. - static let osascriptTimeout: TimeInterval = ProcessTimeout.defaultTimeout + /// Align with the send-style bridge deadline (150s): Messages can stall + /// longer than the short helper default (60s). + static let osascriptTimeout: TimeInterval = IMsgBridgeProtocol.defaultSendResponseTimeout private static func runOsascript(source: String, arguments: [String]) throws { let process = Process() diff --git a/Sources/imsg/Commands/ReactCommand.swift b/Sources/imsg/Commands/ReactCommand.swift index 17eb29e9..d192c830 100644 --- a/Sources/imsg/Commands/ReactCommand.swift +++ b/Sources/imsg/Commands/ReactCommand.swift @@ -170,8 +170,9 @@ enum ReactCommand { return scalar.properties.isEmoji || scalar.properties.isEmojiPresentation } - /// Bound for react UI automation. Hung osascript must not block the CLI. - static let osascriptTimeout: TimeInterval = ProcessTimeout.defaultTimeout + /// Bound for react UI automation. Align with the send-style deadline (150s) + /// used on main for reaction waits; hung osascript still cannot block forever. + static let osascriptTimeout: TimeInterval = IMsgBridgeProtocol.defaultSendResponseTimeout private static func runAppleScript(_ source: String, arguments: [String]) throws { let process = Process()