From acac09ffd26003258d0e7713aa8abefc4d9ee2b3 Mon Sep 17 00:00:00 2001 From: Pool Camacho Date: Wed, 15 Apr 2026 03:21:34 -0600 Subject: [PATCH] fix(run): start pipe drain before waitForProcess to avoid 64KB deadlock Closes #4. Reported by @just-doit. Reproducible on any repo whose 'git log' output exceeds the macOS pipe buffer (~64 KB). Regression introduced when GitService.run was decomposed into helpers: the read tasks were moved inside drainAndClose, which the run method called only AFTER waitForProcess returned. While the process was alive nothing was reading the pipes, so once the kernel buffer filled git blocked on write, never exited, and the timeout fired. Split the helper into startDraining (synchronous, returns the task handles) and awaitDrain (async, awaits values and closes the read ends). The run method now starts the drain immediately after process.run() and awaits it after waitForProcess, restoring the behaviour that was lost in the decomposition. --- Maple/Services/GitService.swift | 41 ++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/Maple/Services/GitService.swift b/Maple/Services/GitService.swift index 934e173..ffb6b26 100644 --- a/Maple/Services/GitService.swift +++ b/Maple/Services/GitService.swift @@ -53,11 +53,13 @@ actor GitService { throw GitError.processLaunchFailed(underlying: "git \(cmd): \(detail)") } + // Start draining BEFORE waiting on the process. macOS pipe buffers cap at + // ~64KB; if git produces more output than that and nothing is reading, + // git blocks on write, the process never exits, and our timeout fires. + // The read tasks must be live for the whole lifetime of the child. + let drainTasks = startDraining(stdout: stdout, stderr: stderr) let completed = await waitForProcess(process, timeout: timeout) - - // Always drain the pipes, even on timeout, so their file descriptors - // aren't held open by the background read tasks. - let (outData, errData) = await drainAndClose(stdout: stdout, stderr: stderr) + let (outData, errData) = await awaitDrain(drainTasks, stdout: stdout, stderr: stderr) guard completed else { throw GitError.timedOut(command: arguments.joined(separator: " "), seconds: Int(timeout)) @@ -123,23 +125,26 @@ actor GitService { } } - private func drainAndClose(stdout: Pipe, stderr: Pipe) async -> (Data, Data) { - // Read pipes on background threads to avoid deadlocks - // (pipes can fill their buffer and block the process) - async let outRead = Task.detached { - stdout.fileHandleForReading.readDataToEndOfFile() - }.value - async let errRead = Task.detached { - stderr.fileHandleForReading.readDataToEndOfFile() - }.value - - let outData = await outRead - let errData = await errRead + /// Spawns detached tasks that drain stdout and stderr to EOF. Called before + /// the process wait so the kernel pipe buffer never fills up and blocks git. + private func startDraining(stdout: Pipe, stderr: Pipe) -> (out: Task, err: Task) { + let outTask = Task.detached { stdout.fileHandleForReading.readDataToEndOfFile() } + let errTask = Task.detached { stderr.fileHandleForReading.readDataToEndOfFile() } + return (outTask, errTask) + } - // Close the read ends explicitly so deinit ordering never delays cleanup. + /// Awaits the drain tasks (now that the process has exited or been terminated) + /// and explicitly closes the pipe read ends so deinit ordering never delays + /// the file descriptor release. + private func awaitDrain( + _ tasks: (out: Task, err: Task), + stdout: Pipe, + stderr: Pipe + ) async -> (Data, Data) { + let outData = await tasks.out.value + let errData = await tasks.err.value try? stdout.fileHandleForReading.close() try? stderr.fileHandleForReading.close() - return (outData, errData) }