Skip to content

fix(interp): run function pipeline stages as background tasks, not inline - #1276

Open
vivo75 wants to merge 1 commit into
reubeno:mainfrom
vivo75:fix/pipeline-function-stage-deadlock2
Open

vivo75 wants to merge 1 commit into
reubeno:mainfrom
vivo75:fix/pipeline-function-stage-deadlock2

Conversation

@vivo75

@vivo75 vivo75 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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:

f() {
  for i in $(seq 1 5000); do echo "line $i"; done
}
f | cat > out.txt   # hangs forever

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, .awaiting execute_in_pipeline() before moving on to the next stage:

let spawn_result = command
    .execute_in_pipeline(pipeline_context, cmd_params)
    .await?;

For external processes, and for builtins run in an owned (non-last-stage) shell, that .await resolves as soon as the work is spawned, not when it finishesexecute_via_builtin_in_owned_shell() already wraps the builtin in tokio::task::spawn_blocking, returning ExecutionSpawnResult::StartedTask immediately.

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, its write() 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_function the same way execute_via_builtin already is:

  • Owned-shell path (used for every pipeline stage except the last): spawns the function body as a background task via tokio::task::spawn_blocking + rt.block_on(...), mirroring execute_via_builtin_in_owned_shell exactly, and returns ExecutionSpawnResult::StartedTask immediately so the pipeline loop can proceed to spawn the next stage right away.
  • Parent-shell path (used only for a pipeline's own last stage, which by definition never needs to unblock a downstream reader): unchanged, still awaits inline.

post_execute is intentionally not invoked in the new owned-shell path, matching execute_via_builtin_in_owned_shell's existing behavior — the owned shell is a throwaway clone discarded after the pipeline stage completes, so running post_execute against 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-tests suite 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:

    before: 2174 test case(s) ran: 1795 succeeded, 0 failed, 379 known to fail, 28 skipped.
    after:  2175 test case(s) ran: 1796 succeeded, 0 failed, 379 known to fail, 28 skipped.
    

    (+1/+1 is exactly the new test case; no other test's outcome changed.)

How this was found

Found while embedding brush_core::Shell as the bash-execution backend for a Gentoo Portage ebuild-phase-execution prototype. Real bin/phase-functions.sh's post-phase step pipes __save_ebuild_env | __filter_readonly_variables — both sides are shell functions, and __save_ebuild_env dumps every function and variable currently in scope via declare -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 to execute_via_function's lack of concurrent spawning with the standalone repro above, independent of any of the ebuild-specific code.

…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.
@hartsock

Copy link
Copy Markdown
Contributor

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 main (30a3bce) locally to measure it, rather than guess — your branch untouched.

Both repros, timeout 15, exit 124 = hung:

big | wc -l (function stage) while …; done | wc -l (compound stage)
main 30a3bce hangs (124) hangs (124)
#1276 on main 5000, exit 0 hangs (124)
#1242 on main hangs (124) 4096, exit 0

Your change is in SimpleCommand::execute_via_function (commands.rs); mine is in the ast::Command::Compound arm of ExecuteInPipeline (interp.rs). Neither reaches the other's path, so both are needed. Your commit also cherry-picks onto current main cleanly.

One thing worth fixing, though — the new case lands in the middle of the previous one. On main, pipeline.yaml ends:

  - 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 echo, so on the branch it reads:

  - 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 PIPESTATUS, so it stops testing the thing it's named for — and it still passes, because oracle and test now both produce nothing. Would you mind putting that echo back on the printf case? Your own case reads fine either way.

Heads-up that we both append to the tail of pipeline.yaml, so whichever lands second takes a small conflict there — happy to be the one who rebases. I'm glad to review yours if a second pair of eyes is useful, and happy to fold both into one PR under your authorship if the maintainer would rather review a single change; your fix is the one that needs the more careful look, since it also has to collapse the nested ExecutionSpawnResult.

vivo75 added a commit to vivo75/brush that referenced this pull request Sep 5, 2026
…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`.
vivo75 added a commit to vivo75/brush that referenced this pull request Sep 14, 2026
…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`.
vivo75 added a commit to vivo75/brush that referenced this pull request Sep 14, 2026
…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`.
vivo75 added a commit to vivo75/brush that referenced this pull request Sep 14, 2026
…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`.
vivo75 added a commit to vivo75/portuale that referenced this pull request Sep 14, 2026
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
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.

2 participants