The smell
// dispatch layer — already handed a ctx
async fn dispatch_command(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult>
// interpreter layer — no ctx; reads self.exec_ctx
async fn execute_pipeline(&self, pipeline: &Pipeline) -> Result<ExecResult>
async fn execute_command(&self, name: &str, args: &[Arg]) -> Result<ExecResult>
dispatch_command is handed a &mut ExecContext, then copies it into self.exec_ctx so the interpreter layer can see it, runs, and copies it back. That round-trip is the bug generator, and it exists for one reason: the interpreter functions never got a ctx parameter. The slot is a workaround for a missing argument.
What it has cost so far
Two shipped, silent, wrong-answer bugs — both a missing line in that round-trip, both fixed by adding one:
The class is currently contained, not cured: ExecContext has 28 fields, five are per-invocation I/O with move semantics, and all five are now pinned by tests. A sixth resource has to break a tripwire first. That is vigilance, not a design.
Scope
52 references, all in kernel.rs, across 24 methods. The split is clean:
- 7 are
pub — cwd, set_cwd, try_set_cwd, reset, init_terminal, set_trash_backend, classify_command. Every one is a session-state accessor. That is the slot's legitimate job (state persisting across execute() calls), and they keep working unchanged against a SessionState holding cwd/scope/aliases/limits.
- 17 are private interpreter/dispatch internals. These take a
ctx instead.
So the kernel keeps holding session state — that part is correct. What must stop is per-invocation I/O riding along with it.
This is not mechanical, and that is the argument for it
The 17 form recursive cycles (execute_stmt_flow → execute_pipeline → dispatch_command → execute_command → arg expansion → eval_expr_async → execute_block_capturing → execute_stmt_flow), and three are hand-written boxed futures:
fn execute_stmt_flow<'a>(&'a self, stmt: &'a Stmt)
-> Pin<Box<dyn Future<Output = Result<ControlFlow>> + Send + 'a>>
Adding ctx: &'a mut ExecContext there is sharp borrow-checker work. But the places it fights are exactly the nesting points where both bugs lived. It will refuse to compile until we state what a nested dispatch gets: the same ctx, a fresh one, or a derived one. Today that is answered implicitly by whatever happens to be in the slot, and twice it was answered wrong, silently.
Rejected alternative: a Stdio newtype
Grouping the five I/O fields into one non-Clone struct so they move as a unit. It would have prevented both bugs (you cannot carry half of a moved struct) and is about half a day.
Not worth doing. It is mechanical papering: it makes the wrong answer harder to typo without making anyone state the right one, and it touches the same ~112 sites this change touches, so it would be thrown away. Considered and dropped.
Also considered and not adopted: abolishing the slot entirely and wrapping buffers in Arc<Mutex<Cursor>> (suggested by a gemini-pro deliberate). Session state on the Kernel is correct; only the per-invocation I/O is misplaced.
Sizing
~2–3 focused days, mostly borrow-checker negotiation in three recursive functions. The 21 rows in pipeline_nested_dispatch_tests.rs and 8 in pipeline_structured_data_tests.rs are the behavioral net.
Target: 0.16. Deliberately not 0.15 — pairing a context-shape change with the AST-breaking compound-into-pipe work would mean a regression could not be attributed to either.
The smell
dispatch_commandis handed a&mut ExecContext, then copies it intoself.exec_ctxso the interpreter layer can see it, runs, and copies it back. That round-trip is the bug generator, and it exists for one reason: the interpreter functions never got actxparameter. The slot is a workaround for a missing argument.What it has cost so far
Two shipped, silent, wrong-answer bugs — both a missing line in that round-trip, both fixed by adding one:
execute_pipelinecarriedpipe_stdinbut notpipe_stdout.echo $(echo sub) | catprinted nothing at exit 0. Shipped in 0.14.1.stdin_data_rx.seq 1 3 | jq -c $(echo .)fell back to reading the pipe as text.The class is currently contained, not cured:
ExecContexthas 28 fields, five are per-invocation I/O with move semantics, and all five are now pinned by tests. A sixth resource has to break a tripwire first. That is vigilance, not a design.Scope
52 references, all in
kernel.rs, across 24 methods. The split is clean:pub—cwd,set_cwd,try_set_cwd,reset,init_terminal,set_trash_backend,classify_command. Every one is a session-state accessor. That is the slot's legitimate job (state persisting acrossexecute()calls), and they keep working unchanged against aSessionStateholding cwd/scope/aliases/limits.ctxinstead.So the kernel keeps holding session state — that part is correct. What must stop is per-invocation I/O riding along with it.
This is not mechanical, and that is the argument for it
The 17 form recursive cycles (
execute_stmt_flow → execute_pipeline → dispatch_command → execute_command → arg expansion → eval_expr_async → execute_block_capturing → execute_stmt_flow), and three are hand-written boxed futures:Adding
ctx: &'a mut ExecContextthere is sharp borrow-checker work. But the places it fights are exactly the nesting points where both bugs lived. It will refuse to compile until we state what a nested dispatch gets: the same ctx, a fresh one, or a derived one. Today that is answered implicitly by whatever happens to be in the slot, and twice it was answered wrong, silently.Rejected alternative: a
StdionewtypeGrouping the five I/O fields into one non-
Clonestruct so they move as a unit. It would have prevented both bugs (you cannot carry half of a moved struct) and is about half a day.Not worth doing. It is mechanical papering: it makes the wrong answer harder to typo without making anyone state the right one, and it touches the same ~112 sites this change touches, so it would be thrown away. Considered and dropped.
Also considered and not adopted: abolishing the slot entirely and wrapping buffers in
Arc<Mutex<Cursor>>(suggested by a gemini-pro deliberate). Session state on theKernelis correct; only the per-invocation I/O is misplaced.Sizing
~2–3 focused days, mostly borrow-checker negotiation in three recursive functions. The 21 rows in
pipeline_nested_dispatch_tests.rsand 8 inpipeline_structured_data_tests.rsare the behavioral net.Target: 0.16. Deliberately not 0.15 — pairing a context-shape change with the AST-breaking compound-into-pipe work would mean a regression could not be attributed to either.