Skip to content

FileDescriptor deinit fatalErrors ("was not closed") when a child closes its stdin early #367

Open

Description

@AdamShare

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

Activity

  1. AdamShare commented on Aug 24, 2026

    @AdamShare
    Author

    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 repro

    With signal(SIGPIPE, SIG_IGN) in main.swift: exit 133 (SIGTRAP) — the FileDescriptor deinit fatalError.
    Without it: exit 141 (SIGPIPE) — the masking case.

  2. added a commit that references this issue on Aug 28, 2026
  3. jakepetroules commented on Sep 9, 2026

    @jakepetroules
    Contributor

    Related to #265?

  4. AdamShare commented on Oct 14, 2026

    @AdamShare
    Author

    @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

    _ = try? await Subprocess.run(
        .path("/usr/bin/true"),
        input: .string(String(repeating: "x", count: 262_144)),
        output: .discarded
    )

    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. So SIG_IGN isn'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 once run returns. With a reader always present the write can't fail with EPIPE, and there's no write-end IODescriptor for the deinit to trip on. It's a workaround, not a fix: the input types' own path can't be changed from outside (createPipe and StandardInputWriter.init are internal).

    A possible fix, both in the input path:

    1. Darwin: 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 with sigtimedwait) is the usual approach, or documenting that hosts should ignore it.
    2. Close the input write end on the path where the input task fails or is cancelled by the .processTerminated task, so closeWhenDone is 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 the pipefail-style choice Control over stream writing errors #265 asks for.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions