From a7bfa73acc69dca89d30b002c50513ef59e573e8 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Tue, 28 Jul 2026 14:04:32 +0100 Subject: [PATCH] Never schedule stdin feeds or child kill timers on the global queue DispatchQueue.global() is non-overcommit, so on a low-core machine the blocked callers themselves can starve it: the -C stdin feeder never runs, the command never sees EOF on stdin, and the caller waits on it forever. Three such tests wedged the whole suite on the 3-vCPU CI runner for 6 hours. The kill timers rode the same pool, so a timeout could silently never fire exactly when a child hung. Feed stdin from a dedicated thread and run the timers on a private queue. Fixes #3 Co-Authored-By: Claude Fable 5 --- Sources/Git.swift | 15 ++++++++++----- Sources/GitRuntime.swift | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Sources/Git.swift b/Sources/Git.swift index f65690e..94805c8 100644 --- a/Sources/Git.swift +++ b/Sources/Git.swift @@ -34,6 +34,10 @@ enum CommitOutcome: Equatable { } } +/// Kill timers for spawned children. Not DispatchQueue.global(): it is +/// non-overcommit, and the blocked callers these timers guard can starve it. +let spawnWatchdogQueue = DispatchQueue(label: "gitwatchd.spawn-watchdog") + /// Run a program and capture stdout+stderr, trimmed. `timeout` terminates a /// hung child. @discardableResult @@ -49,7 +53,7 @@ func runProcess(_ exe: String, _ args: [String], env: [String: String]? = nil, p.standardError = pipe do { try p.run() } catch { return (-1, "\(error)") } if let timeout { - DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { + spawnWatchdogQueue.asyncAfter(deadline: .now() + timeout) { if p.isRunning { p.terminate() } } } @@ -282,13 +286,14 @@ enum Git { } do { try p.run() } catch { return "" } - // Feed stdin from a background thread while we drain stdout here, so a - // command that never reads (or one like cat with a payload past the - // pipe buffer) can neither deadlock the repo queue nor, with SIGPIPE + // Feed stdin from a dedicated thread (a starvable queue would deadlock + // us here, see spawnWatchdogQueue) while we drain stdout, so a command + // that never reads (or one like cat with a payload past the pipe + // buffer) can neither deadlock the repo queue nor, with SIGPIPE // ignored, crash on a broken pipe. let fed = DispatchSemaphore(value: 0) if let feedHandle { - DispatchQueue.global().async { + Thread.detachNewThread { let fd = feedHandle.fileDescriptor feedData.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in guard let base = raw.baseAddress else { return } diff --git a/Sources/GitRuntime.swift b/Sources/GitRuntime.swift index 3dc4006..aaa78a9 100644 --- a/Sources/GitRuntime.swift +++ b/Sources/GitRuntime.swift @@ -56,7 +56,7 @@ enum GitRuntime { return (ProcessInfo.processInfo.environment, "couldn't launch login shell: \(shell)") } // Guard against a slow/hanging rc file: kill after 5s and fall back. - DispatchQueue.global().asyncAfter(deadline: .now() + 5) { if proc.isRunning { proc.terminate() } } + spawnWatchdogQueue.asyncAfter(deadline: .now() + 5) { if proc.isRunning { proc.terminate() } } let data = out.fileHandleForReading.readDataToEndOfFile() proc.waitUntilExit() let text = String(data: data, encoding: .utf8) ?? ""