swiftlang / swiftlang/swift-subprocess
FileDescriptor deinit fatalErrors ("was not closed") when a child closes its stdin early
Nobody has claimed this yet.
- Dominant language
- Swift
- Stars
- 734
- Forks
- 68
- Avg merge
- 9d 2h
- Merged PRs (30d)
- 6
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 $?
133
SIGPIPE 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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in Subprocess/Configuration.swift around lines 1110-1115 and trace the input FileDescriptor lifecycle when the child closes stdin early. Reproduce with the attached package using head -n 1 and ignored SIGPIPE, then verify that early child exit surfaces a recoverable error without triggering the deinit fatalError.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- swift
- Domain
- operating-systems
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 56/100