Skip to content

fix(interpreter): run pipeline stages as subshells - #2538

Merged
chaliy merged 1 commit into
mainfrom
claude/fidelity-pipelines
Oct 7, 2026
Merged

chaliy merged 1 commit into
mainfrom
claude/fidelity-pipelines

Conversation

@chaliy

@chaliy chaliy commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Requested by Михайло · project thread

What changed

  • Every stage of a multi-command pipeline runs in a subshell, like bash: variables, cwd, options and fds changed inside a stage are rolled back, and exit/return end only that stage.
  • shopt -s lastpipe keeps the last stage in the current shell (bash does this whenever job control is off).
  • Streaming callers (exec_streaming, the CLI) no longer receive a non-last stage's output. for ...; done | tac streamed the loop's lines in input order.
  • New L-PIPE-001 records what is still missing: stages run one after another (no concurrency, no SIGPIPE/141). That is the next PR.

Why

This is step 2 of the bash-fidelity roadmap. echo x | read v setting v and cd dir | cat changing the cwd are divergences that scripts written against bash trip over. The streaming leak printed wrong output in the CLI.

Before / After

$ bashkit -c 'for i in 1 2 3; do echo $i; done | tac; echo x | read v; echo "v=${v-unset}"; x=1; x=2 | cat; echo "x=$x"'

Before (main): 1 2 3, v=x, x=2
After: 3 2 1, v=unset, x=1. This is identical to GNU bash 5.

Existing spec cases that were marked bash_diff: pipes don't create subshells in bashkit now use shopt -s lastpipe and match bash.

Risk

  • Medium. Scripts that relied on the old non-bash behavior (cmd | while read; do n=$((n+1)); done; echo $n) now get bash's answer (0). shopt -s lastpipe restores the old behavior.
  • Each stage takes a subshell snapshot, which costs about 2 µs per stage (3000-iteration echo | cat loop: 54 ms → 67 ms).

Checklist

  • Tests added or updated (pipeline_tests, pipes-redirects.test.sh, L-PIPE-001 evidence)
  • Backward compatibility considered (lastpipe opt-in)

https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA


Generated by Claude Code

@chaliy chaliy self-assigned this Oct 7, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
bashkit 9a406b9 Commit Preview URL

Branch Preview URL
Oct 07 2026, 05:52 AM

Every stage of a multi-command pipeline now rolls back its state changes
like bash; shopt -s lastpipe keeps the last stage in the shell. Streaming
callers no longer see a non-last stage's output. Records L-PIPE-001
(stages still run sequentially).

Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA
@chaliy
chaliy force-pushed the claude/fidelity-pipelines branch from a5ef256 to 9a406b9 Compare October 7, 2026 05:51
@chaliy
chaliy merged commit c535155 into main Oct 7, 2026
47 checks passed
@chaliy
chaliy deleted the claude/fidelity-pipelines branch October 7, 2026 06:20
chaliy added a commit that referenced this pull request Oct 7, 2026
…2541)

<!-- ccr-projects-attribution: {"github_login":"chaliy"} -->
_Requested by **Михайло** · [project
thread](https://claude.ai/code/project/chan_019YEkBJuZMHJdXrjom86cGX?thread=cmsg_019YEkBJuZMHJdXrjom86cGX6hNmzgZPpfCgsgRMptbhdM)_

Stacked on #2538.

## What changed
- Pipeline stages now stream into each other concurrently, starting at
the first stage that runs shell code (a loop, group, function, `eval`).
Each such stage runs on a forked shell and writes into a 4 KiB pipe with
backpressure. The last stage stays in the caller's shell, so its output
still streams to `exec_streaming` callers.
- SIGPIPE: when a reader is done (`head` got its lines, a `while read`
loop broke, or the stage never reads stdin), the writer's next write
fails and it ends with 141. `while :; do echo x; done | head -n 2`
prints two lines and sets `PIPESTATUS` to `141 0`, as in bash. Before,
the loop ran until the command limit and the whole `exec` failed.
- `read` pulls one line and `head -n/-c` pulls only what it needs from a
streaming pipe. Commands that never read stdin (`echo`, `true`, `sleep`,
...) do not wait for the producer.
- Leading stages that are one builtin call (`echo x | grep x`) still run
in sequence without forking, so the common case costs nothing extra.
- `concurrent_jobs(false)` also turns this off.
- New TM-DOS-124 covers runaway producers and recursive pipelines (`f()
{ f | f; }`). A streaming group counts as a subshell level, and past 4
nested levels stages run in sequence, which keeps native stack near
plain function recursion.
- L-PIPE-001 is narrowed to the remaining gaps:
- Leading single builtins (`yes | head`) still produce their whole
output before the next stage reads.
  - `cat`/`grep` downstream of a streaming stage read all input first.
  - The pipe is 4 KiB, not 64 KiB.

## Why
This is step 2 of the bash-fidelity roadmap. `producer | head` and
`producer | while read ...; break` are everyday idioms. They either ran
away into limits or produced the wrong `PIPESTATUS`. The pipe design (a
shared buffer polled on the caller's task, with no threads) is also the
building block for wiring a reader-backed stdin into `Terminal`
(L-TERM-002).

## Before / After
```
$ bashkit -c 'while :; do echo x; done | head -n 2; echo "${PIPESTATUS[*]}"'
```
Before: `x` printed thousands of times, then `resource limit exceeded:
maximum command count exceeded (10000)`.
After: `x`, `x`, `141 0`. This is identical to GNU bash 5.2.

The new spec cases in `pipes-redirects.test.sh` (endless producer into
`head`, into two `read`s, through a middle `while read` stage (`141 141
0`), and into a loop that `break`s) all match real bash locally.

## Risk
- Medium. Pipelines whose producer is a loop or function now run
concurrently, so stage side effects interleave like in bash rather than
strictly in order. Output order of the final stage is unchanged.
- Stack: measured min stack for `f() { f | f; }` in debug builds: 1.99
MB sequential (main 1.90), 2.19 MB with streaming. The 4-level cap keeps
it bounded. The existing nested `$(...)` canary is unchanged (1994531
bytes).
- A finite loop writing between 4 and 64 KiB into a reader that quits
early exits 141 where bash usually exits 0 (documented in L-PIPE-001).

## Checklist
- [x] Tests added or updated (`pipe` unit tests, `pipeline_tests`, 4
bash-compared spec cases, TM-DOS-124 regressions)
- [x] Backward compatibility considered (`concurrent_jobs(false)` keeps
sequential stages)

https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA

---
_Generated by [Claude
Code](https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA)_
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