mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-25 00:14:40 +00:00
The menubar wedged on "Loading Today…" for hours after an idle period. Root cause: DataClient.runCLI called the blocking process.waitUntilExit() from an async function on Swift's cooperative thread pool. On a 16-core machine, 16 concurrent slow `codeburn` subprocesses pinned all 16 cooperative threads inside waitUntilExit; the 45s timeout — itself a Task on that same pool — could then never be scheduled to kill them, so the deadlock was permanent. Confirmed via sample: 16/16 cooperative threads parked in waitUntilExit. PR #412 (AppStore inFlightKeys bookkeeping) was a layer above the OS-thread deadlock and could not fix it. Move both blocking points off the cooperative pool: bridge waitUntilExit through a global (overcommit) queue via a continuation, and drive the timeout from a DispatchSource on a global queue so it fires even when the pool is saturated. Extract runProcess for testability; add a concurrency + timeout smoke test and an output/exit-code test.
51 lines
2.4 KiB
Swift
51 lines
2.4 KiB
Swift
import XCTest
|
|
@testable import CodeBurnMenubar
|
|
|
|
final class DataClientProcessTests: XCTestCase {
|
|
/// Concurrency + timeout smoke test: launch more hung subprocesses than
|
|
/// there are cooperative threads, all at once, with a short timeout, and
|
|
/// assert every call returns once the timeout kills its sleep.
|
|
///
|
|
/// NOTE: this does NOT reproduce the production permanent deadlock (16/16
|
|
/// cooperative threads parked in waitUntilExit). In a short-lived unit-test
|
|
/// process libdispatch spins up replacement threads for blocked workers, so
|
|
/// even the old blocking-on-the-pool code completes here. The real deadlock
|
|
/// built up over ~2 days under the @MainActor refresh loop and is confirmed
|
|
/// by the live `sample`, not by this test. Kept as a guard that the
|
|
/// off-pool wait + timeout path stays correct under concurrency.
|
|
func testConcurrentTimedOutProcessesAllComplete() {
|
|
let count = ProcessInfo.processInfo.activeProcessorCount * 2 + 4
|
|
let done = DispatchSemaphore(value: 0)
|
|
|
|
Task {
|
|
await withTaskGroup(of: Void.self) { group in
|
|
for _ in 0..<count {
|
|
group.addTask {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/bin/sleep")
|
|
process.arguments = ["30"]
|
|
_ = try? await DataClient.runProcess(process, timeoutSeconds: 1, label: "sleep 30")
|
|
}
|
|
}
|
|
}
|
|
done.signal()
|
|
}
|
|
|
|
// Wait on the XCTest thread (a real thread, not the cooperative pool) so
|
|
// the deadlock is detectable even when the pool is fully starved.
|
|
let outcome = done.wait(timeout: .now() + 15)
|
|
XCTAssertEqual(outcome, .success,
|
|
"runProcess deadlocked: \(count) concurrent CLIs starved the cooperative pool")
|
|
}
|
|
|
|
/// A normally-exiting process returns its real output and exit code through
|
|
/// the off-pool wait path.
|
|
func testProcessReturnsOutputAndExitCode() async throws {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/bin/echo")
|
|
process.arguments = ["hello"]
|
|
let result = try await DataClient.runProcess(process, timeoutSeconds: 5, label: "echo hello")
|
|
XCTAssertEqual(result.exitCode, 0)
|
|
XCTAssertEqual(String(data: result.stdout, encoding: .utf8), "hello\n")
|
|
}
|
|
}
|