Repository navigation
Description
Summary
Subprocess.run(..., input: .string(...)) traps with a fatalError in its own FileDescriptor deinit when the child process closes its stdin early (reads what it needs, then exits and closes the read end before the parent finishes writing).
Subprocess/Configuration.swift:1115: Fatal error: FileDescriptor 8 was not closed
The deinit at Configuration.swift:1110-1115:
deinit {
guard self.closeWhenDone else {
return
}
fatalError("FileDescriptor \(self._descriptor) was not closed")
}Early child exit leaves the input FileDescriptor with closeWhenDone == true and no path for the deinit to reconcile it, so the process traps (SIGTRAP, exit 133) instead of surfacing a recoverable error.
Reproducer
A minimal attached package (subprocess-fd-repro.zip) reproduces it deterministically with head -n 1 fed a large input:
signal(SIGPIPE, SIG_IGN) // see note below
let input = (0..<50_000).map { "line-\($0)" }.joined(separator: "\n") + "\n"
let result = try await Subprocess.run(
.name("head"),
arguments: ["-n", "1"],
input: .string(input),
output: .string(limit: 64 * 1024),
error: .string(limit: 64 * 1024)
)$ swift run repro
Subprocess/Configuration.swift:1115: Fatal error: FileDescriptor 8 was not closed
$ echo $?
133SIGPIPE masks the bug
With the default SIGPIPE disposition, the closed-pipe write terminates the parent (exit 141) before any deinit runs — so the fatalError is never reached and the defect is invisible. This is why the bug went unnoticed: most subprocess-feeding apps don't ignore SIGPIPE, so they die at the SIGPIPE step rather than the deinit step.
Ignoring SIGPIPE (standard for any CLI that pipes to children — git, curl, ssh, Python all do) unmasks it: the write error surfaces, and the FileDescriptor deinit then traps.
SIGPIPE disposition |
Exit | Failure point |
|---|---|---|
| default (terminate) | 141 (SIGPIPE) |
closed-pipe write — masks the deinit defect |
ignored (SIG_IGN) |
133 (SIGTRAP) |
FileDescriptor deinit fatalError |
Real-world trigger
git check-ignore --stdin fed a large path list: git reads what it needs, closes stdin, and exits early — same shape as head -n 1. This surfaced in a real tool (wks, which ignores SIGPIPE): the silent exit-141 death looked like a hang/segfault with no stderr and no crash report, and only after installing SIG_IGN did the underlying fatalError become visible.
Expected behavior
A child closing its stdin early is a normal occurrence (any command that short-circuits on its input). The parent should surface a recoverable error (e.g. EPIPE / SubprocessError) rather than trap the process via deinit. The closeWhenDone invariant should be reconciled on early child exit so the deinit doesn't fire.
Environment
- swift-subprocess 1.0.0
- Swift 6.4 (Xcode 8, toolchain
XcodeDefault.xctoolchain) - macOS 27.0 (Darwin 27), arm64
Activity
AdamShare commentedon Aug 24, 2026
Cloneable reproducer: https://github.com/AdamShare/swift-subprocess-fd-repro
git clone https://github.com/AdamShare/swift-subprocess-fd-repro cd swift-subprocess-fd-repro swift run reproWith
signal(SIGPIPE, SIG_IGN)inmain.swift: exit 133 (SIGTRAP) — theFileDescriptordeinitfatalError.Without it: exit 141 (SIGPIPE) — the masking case.
Issue swiftlang#367 workaround
jakepetroules commentedon Sep 9, 2026
Related to #265?
fix(ci): start the S3 fixture on a free port
test: make the integration tests safe to run in parallel
AdamShare commentedon Oct 14, 2026
@jakepetroules Related, yes. #265 asks what a failed write into a child's stdin should mean: an error, or an ordinary early exit. Here the failure never reaches that decision, because the process dies first, of SIGPIPE or of this fatalError. Fixing this is what gives #265's choice somewhere to go.
Two more data points from swift-subprocess 1.0.1, and the workaround we ship meanwhile.
The fatalError also fires with the default SIGPIPE disposition, under load. 300 parallel runs (Swift Testing exit tests, one process each) of
with SIGPIPE left at its default: most died of SIGPIPE, about a dozen stopped on
Configuration.swift:1115: Fatal error: FileDescriptor FileDescriptor(rawValue: 6) was not closed, and a few survived. SoSIG_IGNisn't needed to reach the deinit. It looks like a race between the write reaching the closed pipe and the termination monitor cancelling the input task, though I haven't confirmed that.Threshold. 16 KiB and 64 KiB payloads survive every time, since they fit the pipe buffer before the child exits; 128 KiB and 256 KiB don't.
Workaround, 300 of 300 survived. Own the pipe, keep its read end open in the parent until the child has exited, and pass it as
.fileDescriptor(readEnd, closeAfterSpawningProcess: false), writing from a DispatchIO channel that's stopped oncerunreturns. With a reader always present the write can't fail with EPIPE, and there's no write-endIODescriptorfor the deinit to trip on. It's a workaround, not a fix: the input types' own path can't be changed from outside (createPipeandStandardInputWriter.initare internal).A possible fix, both in the input path:
fcntl(writeEnd, F_SETNOSIGPIPE, 1)on the parent's end of the stdin pipe when it's created, so a write after the child exits fails with EPIPE instead of signaling. Linux has no per-descriptor flag for pipes; blocking SIGPIPE on the writing thread (pthread_sigmask, then consuming a pending one withsigtimedwait) is the usual approach, or documenting that hosts should ignore it..processTerminatedtask, socloseWhenDoneis reconciled and the deinit doesn't fire. Then report EPIPE as an ordinary early exit (the child read what it wanted) rather than an error, or make that thepipefail-style choice Control over stream writing errors #265 asks for.