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:
@@ -76,7 +76,7 @@ class GitSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info("Cloning repository from \(self.settings.syncRepoURL)")
|
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
|
syncStatus.isCloned = true
|
||||||
|
|
||||||
// Import immediately so this machine's DB is never left empty after a clone —
|
// Import immediately so this machine's DB is never left empty after a clone —
|
||||||
@@ -1069,13 +1069,25 @@ class GitSyncService {
|
|||||||
return url // SSH or other protocol
|
return url // SSH or other protocol
|
||||||
}
|
}
|
||||||
|
|
||||||
private func runGit(_ args: [String], cwd: String? = nil) async throws -> String {
|
/// 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
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
|
||||||
process.arguments = args
|
process.arguments = args
|
||||||
|
if let workingDirectoryURL {
|
||||||
if let cwd = cwd {
|
process.currentDirectoryURL = workingDirectoryURL
|
||||||
process.currentDirectoryURL = URL(fileURLWithPath: expandPath(cwd))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputPipe = Pipe()
|
let outputPipe = Pipe()
|
||||||
@@ -1083,8 +1095,24 @@ class GitSyncService {
|
|||||||
process.standardOutput = outputPipe
|
process.standardOutput = outputPipe
|
||||||
process.standardError = errorPipe
|
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()
|
try process.run()
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
|
} catch {
|
||||||
|
timeoutItem.cancel()
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timeoutItem.cancel()
|
||||||
|
|
||||||
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
@@ -1093,12 +1121,18 @@ class GitSyncService {
|
|||||||
let error = String(data: errorData, encoding: .utf8) ?? ""
|
let error = String(data: errorData, encoding: .utf8) ?? ""
|
||||||
|
|
||||||
guard process.terminationStatus == 0 else {
|
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("Git command failed: \(args.joined(separator: " "))")
|
||||||
log.error("Error: \(error)")
|
log.error("Error: \(message)")
|
||||||
throw SyncError.gitFailed(error.isEmpty ? "Unknown error" : error)
|
continuation.resume(throwing: SyncError.gitFailed(message))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return output
|
continuation.resume(returning: output)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func expandPath(_ path: String) -> String {
|
private func expandPath(_ path: String) -> String {
|
||||||
|
|||||||
Reference in New Issue
Block a user