Skip to content

Archive compress/extract can deadlock: stderr pipe read after waitUntilExit(), no timeout #11

Description

@dalsoop

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions