Conversation
…line spawn_pipeline_processes() spawns each pipeline stage in a loop, awaiting execute_in_pipeline() before moving on to the next stage. For external processes and for builtins run in an owned (non-last) shell, that await resolves as soon as the work is spawned, since execute_via_builtin_in_owned_shell() already wraps builtins in tokio::task::spawn_blocking. execute_via_function() had no such wrapping: it ran the function body inline, so the loop genuinely blocked until the function fully returned, and the *next* stage (the one that would actually drain this stage's stdout pipe) was never even spawned yet. A function that writes more to stdout than the OS pipe buffer holds (~64KiB on Linux) before returning then deadlocks on that write forever. Split execute_via_function the same way execute_via_builtin already is: an owned-shell path that spawns the function body as a background task (spawn_blocking + block_on, mirroring the builtin path exactly) so the pipeline loop can proceed immediately, and an unchanged parent-shell path (only used for a pipeline's own last stage, which never needs to unblock a downstream reader) that still awaits inline. Added a regression case reproducing the deadlock under the compat suite's own 15s timeout. Full brush-compat-tests suite passes identically before and after this change (1795 succeeded / 0 failed / 379 known-to-fail / 28 skipped, plus the one new case), confirmed via a clean `cargo clean` rebuild on both sides of the fix.
|
Hi @vivo75 — I have an open PR, #1242, that fixes the same class of deadlock at a different dispatch site, so I checked whether they collide. They don't: they're orthogonal. I cherry-picked your commit onto current Both repros,
Your change is in One thing worth fixing, though — the new case lands in the middle of the previous one. On - name: "printf broken pipe returns 141 in PIPESTATUS"
stdin: |
printf '%s\n' {0..10000} | x=1
echo "Last: $?, PIPESTATUS: ${PIPESTATUS[*]}"and the diff inserts the new case before that last - name: "printf broken pipe returns 141 in PIPESTATUS"
stdin: |
printf '%s\n' {0..10000} | x=1
- name: "Function stage writing more than a pipe buffer before the next stage is spawned"
...
big | wc -l
echo "Last: $?, PIPESTATUS: ${PIPESTATUS[*]}"The PIPESTATUS case no longer prints Heads-up that we both append to the tail of |
…t inline `spawn_pipeline_processes` awaits each stage's spawn before starting the next. For an external command that returns `StartedProcess`, or a builtin in an owned shell that `spawn_blocking`s and returns `StartedTask`, that await resolves immediately. `execute_via_function` awaited `invoke_shell_function` *inline*, so the loop blocked until the function body fully returned and the next stage -- the reader of this stage's stdout pipe -- was never spawned. A function that writes more than one pipe buffer (~64 KiB on Linux) before returning then deadlocks on `write()` forever. Split `execute_via_function` the way `execute_via_builtin` already is: an owned-shell path that `spawn_blocking`s the body and returns `StartedTask` immediately, and an unchanged parent-shell path (only ever a pipeline's own last stage) that still awaits inline. Re-does the never-merged reubeno#1276 against current `main`.
…t inline `spawn_pipeline_processes` awaits each stage's spawn before starting the next. For an external command that returns `StartedProcess`, or a builtin in an owned shell that `spawn_blocking`s and returns `StartedTask`, that await resolves immediately. `execute_via_function` awaited `invoke_shell_function` *inline*, so the loop blocked until the function body fully returned and the next stage -- the reader of this stage's stdout pipe -- was never spawned. A function that writes more than one pipe buffer (~64 KiB on Linux) before returning then deadlocks on `write()` forever. Split `execute_via_function` the way `execute_via_builtin` already is: an owned-shell path that `spawn_blocking`s the body and returns `StartedTask` immediately, and an unchanged parent-shell path (only ever a pipeline's own last stage) that still awaits inline. Re-does the never-merged reubeno#1276 against current `main`.
…t inline `spawn_pipeline_processes` awaits each stage's spawn before starting the next. For an external command that returns `StartedProcess`, or a builtin in an owned shell that `spawn_blocking`s and returns `StartedTask`, that await resolves immediately. `execute_via_function` awaited `invoke_shell_function` *inline*, so the loop blocked until the function body fully returned and the next stage -- the reader of this stage's stdout pipe -- was never spawned. A function that writes more than one pipe buffer (~64 KiB on Linux) before returning then deadlocks on `write()` forever. Split `execute_via_function` the way `execute_via_builtin` already is: an owned-shell path that `spawn_blocking`s the body and returns `StartedTask` immediately, and an unchanged parent-shell path (only ever a pipeline's own last stage) that still awaits inline. Re-does the never-merged reubeno#1276 against current `main`.
…t inline `spawn_pipeline_processes` awaits each stage's spawn before starting the next. For an external command that returns `StartedProcess`, or a builtin in an owned shell that `spawn_blocking`s and returns `StartedTask`, that await resolves immediately. `execute_via_function` awaited `invoke_shell_function` *inline*, so the loop blocked until the function body fully returned and the next stage -- the reader of this stage's stdout pipe -- was never spawned. A function that writes more than one pipe buffer (~64 KiB on Linux) before returning then deadlocks on `write()` forever. Split `execute_via_function` the way `execute_via_builtin` already is: an owned-shell path that `spawn_blocking`s the body and returns `StartedTask` immediately, and an unchanged parent-shell path (only ever a pipeline's own last stage) that still awaits inline. Re-does the never-merged reubeno#1276 against current `main`.
Portuale's embedded brush backend now builds a compiled ebuild exactly like
bash: the #38 G3 smoke's empty-image `src_compile` no-op is fixed, a broken
saved environment fails the phase loudly, and the pin moves to the rebased
thin fork.
Brush side (`vivo75/brush`, force-pushed as authorised; each fix is one
commit on upstream `main` `25bffd54`, all five in `main` `b9524ad5`):
* 01 `bc99e6c1` tokenizer: a `${…}` / `$(…)` on a here-tag line stole the
pending here-document's tokens (corrupting the enclosing word and
`<<${VAR}`'s tag).
* 02 `df830c59` parser AST `Display`: `declare -f` here-document
serialization -- deferred bodies at column 0, verbatim spans, process
substitution, `|`/`>&` spacing -- plus the B1 repair: the deferred
terminator is the quote-removed delimiter and the command-line tag is
re-quoted the way a shell prints it (`<<"EOF"` / `<<\EOF` / `<<E"O"F`
all print as `<<'EOF'`). This is what unblocked the saved environment.
* 03 `962051c9` core: a function used as a non-last pipeline stage runs as
a background task, not inline (re-do of reubeno/brush#1276).
* 04 `dfbca97c` core: a parse error in a *sourced* file returns 2 instead
of exiting the calling shell, so real `bin/ebuild.sh:580`'s
`source "${T}"/environment || die` actually fires.
* 05 `2073877d` core: brace expansion produces its fields independently of
IFS. `__filter_readonly_variables` builds bash's special-variable list
with `printf '${!%s*} ' {A..Z} {a..z} _` after `local IFS`, so under
brush the list came back malformed, nothing was filtered, and
`BASHOPTS`/`EUID`/`PPID`/`SHELLOPTS`/`UID` leaked into
`${T}/environment` (`declare: cannot mutate readonly variable` on every
later `source`).
Portuale side:
* `run_one_phase_brush` fails the phase when the setup script or
`bin/ebuild.sh` sourcing returns non-zero -- a defence that stays valid
after fix 04 (real `ebuild.sh`'s own die fires, and this catches it).
* the embedded shell is given a real `$BASH` (first `bash` on `PATH`,
resolved once) so the hygienic-specials probe can run at all.
* new `dev-libs/heredocpkg` fixture (inherit-free `<<-'EOF'` `src_compile`)
with a Bash/Brush `image/`-set equality pytest, plus Rust regressions for
the corrupt-saved-environment die and the filtered `${T}/environment`.
* re-pin: `rust/portuale/Cargo.toml`, `rust/Cargo.lock`,
`3rdparty/repos.toml` -> `b9524ad51de5c8231eb5dcaf79ca982841117385`;
the temporary path `[patch]` used for local iteration is gone.
Docs: `brush-pin.md` (new Current pin, resolved G3 bullet, five-branch
list), `brush-pr/` write-ups 04/05 + regenerated payload-free patches +
`gh pr create` commands for B6 (user-owned), backlog #5/#6,
`scope-backlog.md` G, `agent-context.md`, `what-this-proves.md` Track-B
slice note, `TEST/findings/l2.md` "#38 S2" resolution.
Verified: `cargo fmt --check` and `cargo clippy --release --all-targets`
clean; `cargo test --release` green (475 portuale tests); full pytest
1568 passed / 2 skipped / 8 xfailed; brush's own `brush-compat-tests`
2504 ran, 0 unexpected failures (and 0 failed on each of the five branches
alone); ad-hoc eclass sweep 211/211 eclasses, 2054 generic functions + 5
synthetic quoted-tag functions, 0 round-trip failures (pristine
`25bffd54`: 20 failures, 41 eclasses unparsed); the #38 G3 smoke
(`emerge --shell brush --buildpkgonly porttest/splitdebug`) exits 0 with a
12 KiB image carrying `pt-splitdebug` + `libptsd.so*` + splitdebug trees.
`--shell` still defaults to `bash`: flipping it back stays a separate owner
decision after the upstream PRs land (B6/B7 of
`docs/backlog_tier_1_sliced.opus.md`). Also carries the working-tree doc
updates from the fetch/mirror session that were already uncommitted on this
branch (backlog #14, `scope-backlog.md`'s fetch paragraph).
Co-Authored-By: deepseek-v4.1-flash
Summary
Fixes a real deadlock: a shell function used as a non-last stage of a pipeline blocks forever once it writes more to its stdout than the OS pipe buffer holds (~64KiB on Linux), because the pipeline-spawning loop doesn't move on to the next stage (the one that would drain that pipe) until the function has fully returned.
Minimal repro, no external commands involved:
Real bash and every other shell run every pipeline stage concurrently, so this case never arises there.
Root cause
spawn_pipeline_processes()(brush-core/src/interp.rs) spawns each pipeline stage in a loop,.awaitingexecute_in_pipeline()before moving on to the next stage:For external processes, and for builtins run in an owned (non-last-stage) shell, that
.awaitresolves as soon as the work is spawned, not when it finishes —execute_via_builtin_in_owned_shell()already wraps the builtin intokio::task::spawn_blocking, returningExecutionSpawnResult::StartedTaskimmediately.execute_via_function()had no such wrapping: it ran the function body inline (invoke_shell_function(...).await), so the loop genuinely blocked on the entire function body completing before the next pipeline stage was even spawned. If that function writes enough to stdout to fill the kernel pipe buffer before returning, itswrite()blocks — and since nothing is reading the other end yet (the next stage hasn't been spawned), it blocks forever.This isn't just a synthetic case: any script piping the output of a function that produces more than ~64KiB (e.g.
some_function | grep foo,some_function | wc -l, or shells that dump their own state through a function-based filter) hits this.Fix
Split
execute_via_functionthe same wayexecute_via_builtinalready is:tokio::task::spawn_blocking+rt.block_on(...), mirroringexecute_via_builtin_in_owned_shellexactly, and returnsExecutionSpawnResult::StartedTaskimmediately so the pipeline loop can proceed to spawn the next stage right away.post_executeis intentionally not invoked in the new owned-shell path, matchingexecute_via_builtin_in_owned_shell's existing behavior — the owned shell is a throwaway clone discarded after the pipeline stage completes, so runningpost_executeagainst it has no observable effect on the parent shell.Testing
New compat case (
brush-shell/tests/cases/compat/pipeline.yaml, "Function stage writing more than a pipe buffer before the next stage is spawned") reproduces the original hang under the compat suite's own 15s per-test timeout, so a regression here fails the test rather than hanging CI.Full
brush-compat-testssuite passes identically before and after this change, confirmed via a clean (cargo clean) rebuild on both sides of the fix rather than trusting a cached binary:(+1/+1 is exactly the new test case; no other test's outcome changed.)
How this was found
Found while embedding
brush_core::Shellas the bash-execution backend for a Gentoo Portage ebuild-phase-execution prototype. Realbin/phase-functions.sh's post-phase step pipes__save_ebuild_env | __filter_readonly_variables— both sides are shell functions, and__save_ebuild_envdumps every function and variable currently in scope viadeclare -f/declare -p. Small scripts stayed under the pipe-buffer threshold and worked fine; anything that had sourced a nontrivial number of shell functions into scope (as few as a couple hundred short functions) reliably deadlocked. Bisected down toexecute_via_function's lack of concurrent spawning with the standalone repro above, independent of any of the ebuild-specific code.