From 87eab6fd75eee1fef68819d63ee74f3e33782158 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 4 Aug 2026 12:59:09 +0200 Subject: [PATCH] Run git subprocess off the main thread with a timeout runGit() previously called Process.waitUntilExit() synchronously on the MainActor with no timeout. If a git network operation (push/pull/ fetch) was in flight when the Mac went to sleep, the dead connection could hang indefinitely on wake with no OS-level timeout of its own, freezing the entire app UI with nothing logged since the command never actually finished. Now runs on a background queue with a 30s timeout (120s for clone), matching the pattern already used by bash_execute. --- oAI/Services/GitSyncService.swift | 92 +++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 29 deletions(-) diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift index a183c1d..073e647 100644 --- a/oAI/Services/GitSyncService.swift +++ b/oAI/Services/GitSyncService.swift @@ -76,7 +76,7 @@ class GitSyncService { } log.info("Cloning repository from \(self.settings.syncRepoURL)") - _ = try await runGit(["clone", url, localPath]) + _ = try await runGit(["clone", url, localPath], timeout: 120) syncStatus.isCloned = true // Import immediately so this machine's DB is never left empty after a clone — @@ -1069,36 +1069,70 @@ class GitSyncService { return url // SSH or other protocol } - private func runGit(_ args: [String], cwd: String? = nil) async throws -> String { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/git") - process.arguments = args + /// Runs off the main thread with a hard timeout, rather than blocking synchronously on + /// `waitUntilExit()` on the (MainActor-isolated, per this project's default actor isolation) + /// calling thread. A plain `git fetch`/`pull`/`push` over a connection that died silently + /// during system sleep can otherwise hang indefinitely with no OS-level timeout of its own — + /// since that used to block the main thread, it froze the entire app UI with nothing to show + /// for it in the logs (no error is ever produced by a command that never finishes). Every + /// MainActor-touching value (paths, the logger) is captured *before* dispatching to the + /// background queue — the queue's closure must not touch `self` or other MainActor state. + private func runGit(_ args: [String], cwd: String? = nil, timeout: TimeInterval = 30) async throws -> String { + let workingDirectoryURL = cwd.map { URL(fileURLWithPath: expandPath($0)) } + let log = self.log - if let cwd = cwd { - process.currentDirectoryURL = URL(fileURLWithPath: expandPath(cwd)) + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = args + if let workingDirectoryURL { + process.currentDirectoryURL = workingDirectoryURL + } + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + var timedOut = false + let timeoutItem = DispatchWorkItem { + if process.isRunning { + timedOut = true + process.terminate() + } + } + DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: timeoutItem) + + do { + try process.run() + process.waitUntilExit() + } catch { + timeoutItem.cancel() + continuation.resume(throwing: error) + return + } + timeoutItem.cancel() + + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + + let output = String(data: outputData, encoding: .utf8) ?? "" + let error = String(data: errorData, encoding: .utf8) ?? "" + + guard process.terminationStatus == 0 else { + let message = timedOut + ? "Timed out after \(Int(timeout))s — this can happen if your Mac just woke from sleep and the network hasn't reconnected yet. Try again in a moment." + : (error.isEmpty ? "Unknown error" : error) + log.error("Git command failed: \(args.joined(separator: " "))") + log.error("Error: \(message)") + continuation.resume(throwing: SyncError.gitFailed(message)) + return + } + + continuation.resume(returning: output) + } } - - let outputPipe = Pipe() - let errorPipe = Pipe() - process.standardOutput = outputPipe - process.standardError = errorPipe - - try process.run() - process.waitUntilExit() - - let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - - let output = String(data: outputData, encoding: .utf8) ?? "" - let error = String(data: errorData, encoding: .utf8) ?? "" - - guard process.terminationStatus == 0 else { - log.error("Git command failed: \(args.joined(separator: " "))") - log.error("Error: \(error)") - throw SyncError.gitFailed(error.isEmpty ? "Unknown error" : error) - } - - return output } private func expandPath(_ path: String) -> String {