Summary
FileOperationService.runCompression and runExtraction read the child's
stderr after waitUntilExit(). If the child writes more than the pipe
buffer (~64 KB) to stderr, the child blocks in write(2) and the parent blocks
in waitUntilExit() — the app hangs forever. There is also no timeout, so a
child stuck on an unresponsive mount never returns either.
Found while porting mqdirCore into another project. A commit hook in that
repo flags Process.waitUntilExit without a watchdog (from an earlier incident
of ours), which is what surfaced it.
Where
Sources/mqdirCore/FileOperationService.swift, as of e5d4bfb:
let stderr = Pipe()
process.standardError = stderr
process.standardOutput = Pipe()
try process.run()
process.waitUntilExit() // <- blocks here
guard process.terminationStatus == 0 else {
let stderrData = (try? stderr.fileHandleForReading.readToEnd()) ?? nil ?? Data()
Same shape in runExtraction (ditto / tar).
Reproduction
Deterministic, no archive needed — the shape of the bug is enough:
import Foundation
let p = Process()
p.executableURL = URL(fileURLWithPath: "/bin/sh")
p.arguments = ["-c", "yes 'warning: bad entry' | head -c 2000000 1>&2"]
let err = Pipe()
p.standardError = err
p.standardOutput = Pipe()
try p.run()
print("waiting…")
p.waitUntilExit() // never returns
print("done \(p.terminationStatus)")
macOS 26.0 / Swift 6.2 / arm64: prints waiting… and hangs indefinitely.
In the app this is reachable whenever a child is chatty on stderr — extracting
a damaged or partially-downloaded zip makes ditto emit a warning per entry,
and a large enough archive crosses 64 KB.
Suggested fix
Drain the pipe on another queue before waiting, and add a timeout. Roughly:
private static func waitDraining(
_ process: Process, stderr: Pipe, timeout: TimeInterval = 600
) -> Data {
final class DataBox: @unchecked Sendable { var data = Data() }
let box = DataBox()
let group = DispatchGroup()
group.enter()
DispatchQueue.global().async {
box.data = stderr.fileHandleForReading.readDataToEndOfFile()
group.leave()
}
let watchdog = DispatchQueue(label: "archive-watchdog")
watchdog.asyncAfter(deadline: .now() + timeout) {
guard process.isRunning else { return }
process.terminate()
watchdog.asyncAfter(deadline: .now() + 5) {
if process.isRunning { kill(process.processIdentifier, SIGKILL) }
}
}
process.waitUntilExit()
group.wait()
return box.data
}
Callers then use the returned data instead of reading the pipe afterwards. A
timeout-killed child exits non-zero, so the existing error path still fires.
10 minutes is deliberately generous — compressing a multi-GB tree legitimately
takes minutes.
Happy to send a PR if you'd like.
Unrelated, and thanks: HangulNFCFilename.swift is the clearest write-up of the
appendingPathComponent → NFD problem I have seen. I verified all of it here
(reading raw bytes via readdir(3), since Foundation hands back NFD either way
and Swift's String == is normalisation-aware) and it holds exactly as
documented — including moveItem NFD→NFC being a silent no-op on APFS.
Summary
FileOperationService.runCompressionandrunExtractionread the child'sstderrafterwaitUntilExit(). If the child writes more than the pipebuffer (~64 KB) to stderr, the child blocks in
write(2)and the parent blocksin
waitUntilExit()— the app hangs forever. There is also no timeout, so achild stuck on an unresponsive mount never returns either.
Found while porting
mqdirCoreinto another project. A commit hook in thatrepo flags
Process.waitUntilExitwithout a watchdog (from an earlier incidentof ours), which is what surfaced it.
Where
Sources/mqdirCore/FileOperationService.swift, as of e5d4bfb:Same shape in
runExtraction(ditto/tar).Reproduction
Deterministic, no archive needed — the shape of the bug is enough:
macOS 26.0 / Swift 6.2 / arm64: prints
waiting…and hangs indefinitely.In the app this is reachable whenever a child is chatty on stderr — extracting
a damaged or partially-downloaded zip makes
dittoemit a warning per entry,and a large enough archive crosses 64 KB.
Suggested fix
Drain the pipe on another queue before waiting, and add a timeout. Roughly:
Callers then use the returned data instead of reading the pipe afterwards. A
timeout-killed child exits non-zero, so the existing error path still fires.
10 minutes is deliberately generous — compressing a multi-GB tree legitimately
takes minutes.
Happy to send a PR if you'd like.
Unrelated, and thanks:
HangulNFCFilename.swiftis the clearest write-up of theappendingPathComponent→ NFD problem I have seen. I verified all of it here(reading raw bytes via
readdir(3), since Foundation hands back NFD either wayand Swift's
String ==is normalisation-aware) and it holds exactly asdocumented — including
moveItemNFD→NFC being a silent no-op on APFS.