Skip to content

fix(builtins): avoid deadlock when a builtin reads a process substitution - #5

Merged
cataggar merged 1 commit into
kfrom
fix/procsub-builtin-deadlock
Aug 19, 2026
Merged

cataggar merged 1 commit into
kfrom
fix/procsub-builtin-deadlock

Conversation

@cataggar

Copy link
Copy Markdown
Owner

Problem

mapfile -t lines < <(cmd) never returns when it appears inside a command substitution, while the identical redirection at top level completes immediately:

x=$(mapfile -t a < <(printf "one\ntwo\n"); echo "${a[*]}")   # hangs forever
mapfile -t a < <(printf "one\ntwo\n"); echo "${a[*]}"        # fine

while read ... done < <(cmd) inside a command substitution hangs the same way. There is no error, no output, and no timeout — the shell simply never comes back.

Cause

Scheduling, not descriptor lifetime.

setup_process_substitution starts the producer with tokio::spawn. A spawn issued from a runtime worker lands in that worker's LIFO slot, which other workers are not able to steal. mapfile and read read their descriptor synchronously and run inline on the task that invoked them — so inside a command substitution they block the very worker that is holding the producer task. Nothing writes the data, nothing drops the write end, and the read waits on an EOF that cannot arrive.

The two cases that do work confirm the mechanism rather than contradicting it:

case producer spawned from result
mapfile < <(...) at top level main, i.e. the block_on thread passes — task goes to the global queue, any worker can take it
mapfile < <(...) inside $( ) a tokio-rt-worker hangs — LIFO slot, and that worker then blocks
cat < <(...) inside $( ) a tokio-rt-worker passes — external, so the worker awaits the child and yields

Only an in-process builtin that blocks can wedge this way. Confirmed by instrumenting the write end with a Weak handle: its Arc strong count goes 1 → 0 correctly in every passing case, and in the hanging case the producer task body never executes at all.

Fix

Add openfiles::without_parking_worker, which runs a potentially blocking read inside tokio::task::block_in_place so the worker hands its queues — LIFO slot included — to another worker before blocking, keeping the producer runnable.

Applied to the two builtins that read a descriptor synchronously, which are the only two in the default builtin set:

  • mapfile / readarray
  • read (which polls only when -t is given; without it the read blocks)

It is a no-op outside a multi-threaded runtime, where block_in_place is both unavailable and unnecessary.

The OpenFile reference-counting contract is deliberately left alone. Sharing handles by Arc is what keeps deeply nested execution from exhausting the process-wide descriptor table, and it was never the problem here.

Tests

Nine compat cases covering the builtin form, the while read ... done < <(...) loop form, read directly, and nesting two command substitutions deep — plus a cat control that was never affected and must keep passing.

Validated against a build identical in every byte except that the body of without_parking_worker was replaced with a plain call, so the difference is attributable to the fix alone:

passed failed
with fix 1794 4
fix neutered 1786 12

The 8-case delta is exactly the new tests: all eight fail by timing out (SIGKILL) without the fix. The remaining 4 failures are pre-existing parser cases (Error: newline before ...) present in both runs and untouched by this change.

cargo fmt --check and cargo clippy are clean.

Overhead

block_in_place is not free in principle, so it was measured. It is free here in practice, because the existing byte-at-a-time read loop dominates it:

workload neutered with fix
while read over 20k lines 7515 ms 7417 ms
mapfile 20k lines 85 ms 81 ms
200 × mapfile from process substitution 174 ms 173 ms

Notes

  • The experimental-bundled-coreutils builtins (cat, head, …) also read real descriptors and are not covered here. They are off by default; worth a look if that feature is ever promoted.
  • setup_process_substitution still discards the producer's result and keeps its TODO(execute): Don't execute synchronously!. Not part of this deadlock, but it is why a failing producer fails silently. Left for a separate change.

This is the third defect of this class found by running a large real-world bash script under brush, after #2 (here-document pipe buffer) and #3 (here-document line continuations). The affected shape — a function calling helpers that read a process substitution — occurs 128 times in the ~95k-line script that surfaced it, so it is ordinary bash rather than an exotic corner.

…tion

`mapfile -t lines < <(cmd)` never returns when it appears inside a command
substitution, while the same redirection at top level completes immediately:

    x=$(mapfile -t a < <(printf "one\ntwo\n"); echo "${a[*]}")   # hangs
    mapfile -t a < <(printf "one\ntwo\n"); echo "${a[*]}"        # fine

The cause is scheduling, not descriptor lifetime. `setup_process_substitution`
spawns the producer with `tokio::spawn`. A spawn issued from a runtime worker
lands in that worker's LIFO slot, which other workers are not able to steal.
`mapfile` and `read` then read their descriptor synchronously, inline on the
task that invoked them -- so inside a command substitution they block the very
worker holding the producer. Nothing writes the data, nothing drops the write
end, and the read waits for an EOF that cannot arrive.

At top level the producer is spawned from the thread running `block_on`, which
is outside the worker pool, so the task goes to the global queue and any worker
can pick it up. An external consumer such as `cat` is likewise unaffected: it
awaits a child process and yields, leaving the worker free to poll its own LIFO
slot. Only an in-process builtin that blocks can wedge this way.

Add `openfiles::without_parking_worker`, which runs a potentially blocking read
inside `tokio::task::block_in_place` so the worker hands its queues -- LIFO slot
included -- to another worker before blocking, and apply it to the two builtins
that read a descriptor synchronously: `mapfile`/`readarray`, and `read` (which
polls only when `-t` is supplied). It is a no-op outside a multi-threaded
runtime, where `block_in_place` is both unavailable and unnecessary.

The `OpenFile` reference-counting contract is deliberately left alone; sharing
handles by `Arc` is what keeps deeply nested execution from exhausting the
process-wide descriptor table, and it was never the problem here.

Adds compat cases covering the builtin, the `while read ... done < <(...)` loop
form, and nesting two command substitutions deep. All of them fail by timing out
without the fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@cataggar
cataggar force-pushed the fix/procsub-builtin-deadlock branch from 256c6e8 to 178a67c Compare August 19, 2026 13:36
@cataggar
cataggar merged commit 6c8190a into k Aug 19, 2026
32 of 38 checks passed
@cataggar
cataggar deleted the fix/procsub-builtin-deadlock branch August 19, 2026 16:45
@cataggar

Copy link
Copy Markdown
Owner Author

Correcting a claim in this PR's description, now that it has been tested rather than reasoned about.

The description says:

The experimental-bundled-coreutils builtins (cat, head, …) also read real descriptors and are not covered here. They are off by default; worth a look if that feature is ever promoted.

That overstates the risk. Built with --features experimental-bundled-coreutils, cat is genuinely a shell builtin (type catcat is a shell builtin), and none of them reproduce the deadlock in the shape that hung mapfile:

$ x=$(cat  < <(echo hello));        echo "[$x]"   # [hello]
$ x=$(head -1 < <(echo hello));     echo "[$x]"   # [hello]
$ x=$(sort < <(printf "b\na\n"));   echo "[$x]"   # [a b]
$ x=$(wc -l < <(printf "a\nb\n"));  echo "[$x]"   # [2]

All complete immediately. The reason is the split in execute_via_builtin: a builtin dispatched through execute_via_builtin_in_owned_shell runs under tokio::task::spawn_blocking, which is a blocking-pool thread rather than a runtime worker, so blocking there parks nothing that matters. Only the inline execute_via_builtin_in_parent_shell path can wedge a worker, which is the path mapfile and read took in the failing shape.

So the fix in this PR is not leaving a known hole behind the experimental feature flag. If a future builtin blocks on a descriptor via the parent-shell path, openfiles::without_parking_worker is the tool for it — but the bundled coreutils are not in that situation today.

@cataggar

Copy link
Copy Markdown
Owner Author

Correcting my earlier comment on this PR — the conclusion held up, the explanation did not.

I claimed the bundled coreutils are safe from the deadlock because they dispatch through execute_via_builtin_in_owned_shell, which uses tokio::task::spawn_blocking. That is wrong on two counts.

First, a single-command pipeline does not take the owned-shell path at all. interp.rs sets run_in_current_shell = pipeline_len == 1 || ..., so x=$(cat < <(echo hi)) goes through ShellForCommand::ParentShell and execute_via_builtin_in_parent_shell — the inline path, the same one mapfile took.

Second, and the actual reason: the bundled coreutils do not run in-process. As documented in brush-coreutils-builtins/src/lib.rs, the per-name shims "re-enter the binary as an external process, so shell redirections, pipes, and process-group state are honored by uutils transparently." Confirmed:

$ brush -c 'echo "shell pid: $$"; cat /proc/self/status | grep -E "^(Name|Pid):"'
shell pid: 402965
Name:   brush
Pid:    402983      # different process, re-executed brush binary

So cat is a builtin by name only; it forks and execs, the shell awaits a child, and the worker yields — the same reason /usr/bin/cat was never affected. Nothing to do with spawn_blocking.

The practical upshot is unchanged and slightly stronger: the fix here is correctly scoped, and the bundled coreutils are not a latent instance of this bug. But the invariant is narrower than my comment implied — any builtin that blocks on a descriptor in-process is at risk, and the inline path is the common case rather than the exceptional one. I've opened a follow-up to write that down where builtin authors will actually see it.

cataggar added a commit that referenced this pull request Aug 22, 2026
…tion (#5)

`mapfile -t lines < <(cmd)` never returns when it appears inside a command
substitution, while the same redirection at top level completes immediately:

    x=$(mapfile -t a < <(printf "one\ntwo\n"); echo "${a[*]}")   # hangs
    mapfile -t a < <(printf "one\ntwo\n"); echo "${a[*]}"        # fine

The cause is scheduling, not descriptor lifetime. `setup_process_substitution`
spawns the producer with `tokio::spawn`. A spawn issued from a runtime worker
lands in that worker's LIFO slot, which other workers are not able to steal.
`mapfile` and `read` then read their descriptor synchronously, inline on the
task that invoked them -- so inside a command substitution they block the very
worker holding the producer. Nothing writes the data, nothing drops the write
end, and the read waits for an EOF that cannot arrive.

At top level the producer is spawned from the thread running `block_on`, which
is outside the worker pool, so the task goes to the global queue and any worker
can pick it up. An external consumer such as `cat` is likewise unaffected: it
awaits a child process and yields, leaving the worker free to poll its own LIFO
slot. Only an in-process builtin that blocks can wedge this way.

Add `openfiles::without_parking_worker`, which runs a potentially blocking read
inside `tokio::task::block_in_place` so the worker hands its queues -- LIFO slot
included -- to another worker before blocking, and apply it to the two builtins
that read a descriptor synchronously: `mapfile`/`readarray`, and `read` (which
polls only when `-t` is supplied). It is a no-op outside a multi-threaded
runtime, where `block_in_place` is both unavailable and unnecessary.

The `OpenFile` reference-counting contract is deliberately left alone; sharing
handles by `Arc` is what keeps deeply nested execution from exhausting the
process-wide descriptor table, and it was never the problem here.

Adds compat cases covering the builtin, the `while read ... done < <(...)` loop
form, and nesting two command substitutions deep. All of them fail by timing out
without the fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant