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.
This commit is contained in:
2026-08-04 12:59:09 +02:00
parent e2284aba2b
commit 87eab6fd75
+63 -29
View File
@@ -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 {