Skip to content

Thread an ExecContext through the interpreter instead of the shared exec_ctx slot #369

Description

@tobert

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 pubcwd, 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions