mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 23:51:33 +00:00
* refactor(macos): pin subprocess groups through cleanup * refactor(macos): keep bounded process probes async * test(macos): cover async process probe limits * fix(macos): preserve local gateway version fallback * test(macos): cover gateway version probe fallback * fix(macos): handle subprocesses that exit before monitoring * test(macos): stress instant bounded process exits * fix(macos): close bounded process exit registration race * fix(macos): preserve bounded probe output ordering * fix(macos): propagate bounded process cancellation * fix(macos): recover missed subprocess exit events * chore(macos): refresh native i18n inventory * fix(macos): reject invalid process argument bytes
208 lines
7.1 KiB
Swift
208 lines
7.1 KiB
Swift
import Darwin
|
|
import Dispatch
|
|
import Foundation
|
|
import Subprocess
|
|
|
|
enum BoundedProcessError: Error {
|
|
case timedOut
|
|
}
|
|
|
|
struct BoundedProcessResult: Sendable {
|
|
var output: Data
|
|
var terminationStatus: Int32
|
|
}
|
|
|
|
enum BoundedProcess {
|
|
private static let outputLimit = 64 * 1024
|
|
|
|
private enum DeadlineOutcome: Sendable {
|
|
case exited
|
|
case timedOut
|
|
}
|
|
|
|
private final class ProcessExitSignal: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private let processIdentifier: pid_t
|
|
private let source: DispatchSourceProcess?
|
|
private var continuation: CheckedContinuation<Void, Never>?
|
|
private var finished = false
|
|
|
|
init(processIdentifier: pid_t) {
|
|
self.processIdentifier = processIdentifier
|
|
if Self.hasExited(processIdentifier) {
|
|
self.source = nil
|
|
self.finished = true
|
|
return
|
|
}
|
|
let source = DispatchSource.makeProcessSource(
|
|
identifier: processIdentifier,
|
|
eventMask: .exit,
|
|
queue: .global(qos: .utility))
|
|
self.source = source
|
|
source.setEventHandler { [weak self] in
|
|
self?.finish()
|
|
}
|
|
source.resume()
|
|
// The child can exit between the initial waitid probe and kqueue
|
|
// registration. Recheck after resume so that race cannot consume
|
|
// the full timeout when no NOTE_EXIT event is delivered.
|
|
if Self.hasExited(processIdentifier) {
|
|
self.finish()
|
|
}
|
|
}
|
|
|
|
func wait() async {
|
|
await withTaskCancellationHandler {
|
|
await withCheckedContinuation { continuation in
|
|
self.lock.lock()
|
|
guard !self.finished else {
|
|
self.lock.unlock()
|
|
continuation.resume()
|
|
return
|
|
}
|
|
self.continuation = continuation
|
|
self.lock.unlock()
|
|
}
|
|
} onCancel: {
|
|
self.finish()
|
|
}
|
|
}
|
|
|
|
func pollUntilExit() async {
|
|
while !Task.isCancelled {
|
|
do {
|
|
try await Task.sleep(for: .milliseconds(50))
|
|
} catch {
|
|
return
|
|
}
|
|
if Self.hasExited(self.processIdentifier) {
|
|
self.finish()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func hasExited() -> Bool {
|
|
Self.hasExited(self.processIdentifier)
|
|
}
|
|
|
|
private func finish() {
|
|
self.lock.lock()
|
|
guard !self.finished else {
|
|
self.lock.unlock()
|
|
return
|
|
}
|
|
self.finished = true
|
|
let continuation = self.continuation
|
|
self.continuation = nil
|
|
self.lock.unlock()
|
|
self.source?.cancel()
|
|
continuation?.resume()
|
|
}
|
|
|
|
private static func hasExited(_ processIdentifier: pid_t) -> Bool {
|
|
while true {
|
|
var info = siginfo_t()
|
|
if waitid(P_PID, id_t(processIdentifier), &info, WEXITED | WNOHANG | WNOWAIT) == 0 {
|
|
return info.si_pid != 0 || info.si_signo != 0
|
|
}
|
|
guard errno == EINTR else { return false }
|
|
}
|
|
}
|
|
}
|
|
|
|
static func run(
|
|
path: String,
|
|
arguments: [String],
|
|
environment: [String: String]? = nil,
|
|
workingDirectory: String? = nil,
|
|
timeout: TimeInterval) async throws -> BoundedProcessResult
|
|
{
|
|
precondition(timeout > 0)
|
|
var platformOptions = PlatformOptions()
|
|
platformOptions.qualityOfService = .utility
|
|
platformOptions.createSession = true
|
|
platformOptions.teardownSequence = [
|
|
.send(
|
|
signal: .kill,
|
|
toProcessGroup: true,
|
|
allowedDurationToNextStep: .zero),
|
|
]
|
|
let configuration = Configuration(
|
|
.path(.init(path)),
|
|
arguments: Arguments(arguments),
|
|
environment: environment.map(self.environment(from:)) ?? .inherit,
|
|
workingDirectory: workingDirectory.map { .init($0) },
|
|
platformOptions: platformOptions)
|
|
let executionResult = try await Subprocess.run(
|
|
configuration,
|
|
input: .none,
|
|
output: .bytes(limit: self.outputLimit),
|
|
error: .combinedWithOutput)
|
|
{ execution in
|
|
let exitSignal = ProcessExitSignal(
|
|
processIdentifier: pid_t(execution.processIdentifier.value))
|
|
let deadline = await withTaskGroup(of: DeadlineOutcome.self) { group in
|
|
group.addTask {
|
|
await exitSignal.wait()
|
|
return .exited
|
|
}
|
|
// Keep normal exits event-driven, but recover promptly if
|
|
// kqueue misses NOTE_EXIT under heavy concurrent spawning.
|
|
group.addTask {
|
|
await exitSignal.pollUntilExit()
|
|
return .exited
|
|
}
|
|
group.addTask {
|
|
do {
|
|
try await Task.sleep(for: .seconds(timeout))
|
|
return .timedOut
|
|
} catch {
|
|
return .exited
|
|
}
|
|
}
|
|
defer { group.cancelAll() }
|
|
return await group.next() ?? .exited
|
|
}
|
|
try Task.checkCancellation()
|
|
|
|
switch deadline {
|
|
case .exited:
|
|
// The body runs before swift-subprocess reaps the group leader.
|
|
// Kill inherited descendants while the pid cannot be recycled.
|
|
try? execution.send(signal: .kill, toProcessGroup: true)
|
|
return false
|
|
case .timedOut:
|
|
if exitSignal.hasExited() {
|
|
try? execution.send(signal: .kill, toProcessGroup: true)
|
|
return false
|
|
}
|
|
try? execution.send(signal: .terminate, toProcessGroup: true)
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
try? execution.send(signal: .kill, toProcessGroup: true)
|
|
return true
|
|
}
|
|
}
|
|
|
|
if executionResult.closureOutput {
|
|
throw BoundedProcessError.timedOut
|
|
}
|
|
let data = Data(executionResult.standardOutput)
|
|
let terminationStatus = switch executionResult.terminationStatus {
|
|
case let .exited(code), let .signaled(code):
|
|
Int32(code)
|
|
}
|
|
return BoundedProcessResult(output: data, terminationStatus: terminationStatus)
|
|
}
|
|
|
|
private static func environment(from values: [String: String]) -> Environment {
|
|
var converted: [Environment.Key: String] = [:]
|
|
converted.reserveCapacity(values.count)
|
|
for (key, value) in values {
|
|
guard let environmentKey = Environment.Key(rawValue: key) else { continue }
|
|
converted[environmentKey] = value
|
|
}
|
|
return .custom(converted)
|
|
}
|
|
}
|