shell: remove unsafe from the interpreter, state nodes, IOWriter/IOReader and Builtin

JA
2mo ago
+3744-4075

What

Same programme as #40055 … #40221. Stacked on #40204 (base branch claude/subprocess-zero-unsafe; retarget to main once that lands). Every target in src/runtime/shell/ goes to 0: interpreter.rs 53 → 0, states/{Cmd,Expansion,CondExpr,Async,Base,Pipeline,Script,Stmt,Subshell}.rs → 0, IOWriter.rs 7 → 0, IOReader.rs 7 → 0, Builtin.rs 10 → 0, IO.rs 4 → 0, dispatch_tasks.rs 5 → 0; builtins touched on the way: mv.rs 8 → 0, touch.rs 6 → 0, export.rs 1 → 0, mkdir.rs 6 → 1, ls.rs 11 → 2, cp.rs 17 → 3 (rm.rs's DirTask tree is its own raw-pointer design and is left for a follow-up).

  • State tree: nodes live in an append-only chunked slab on the Interpreter addressed by id; parent/child links are ids, child_done re-enters through the interpreter, and no Ref/RefMut on a node, env, or captured buffer is held across a re-entrant call (builtins never hold their node borrowed across IOWriter::enqueue). ShellExecEnv has Drop + clear(); Bufio::Borrowed → Rc<RefCell<Vec>>.
  • Parsed script: bun_shell_parser::ParsedScript owns arena + root; root(&self) -> &Script<'_> is covariant/safe and the erased 'static form is only available as root_backref() -> BackRef<Script<'static>> (one lifetime-only transmute with SAFETY in the parser crate).
  • IOWriter / IOReader: intrusively refcounted (IOWriterRef/IOReaderRef RAII newtypes over RefPtr); impl_buffered_writer_parent! gets a borrow = this entry whose ref_/deref hooks are the parent's intrusive count; pending-writer entries are typed; sync completions are deferred through Yield::OnIoWriterChunk.
  • Thread-pool / loop bounces: bun_event_loop::EventLoopTask::arm_boxed<T, R>(Box<T>, node) (JS arm = manual-deinit ConcurrentTask with T::TAG, mini arm = trampoline to R::run_from_loop_thread(Box<T>)), AnyTaskWithExtraContext::{arm_boxed, load} (also fixes a pre-existing free-under-&mut in the mini loop — it now copies the node out before running), boxed_taskable!, crate::shell_task!(Ty); ShellTask.interp is an InterpRef openable only on the owning thread. Bounces use the embedded nodes (no allocation per bounce; mini Async is 0 allocs vs 1 on main).
  • bun_ptr::SelfRoot/RefPtr::new_cyclic cherry-picked from #40210; owned_task!/intrusive_work_task!/intrusive_field! accept a nested field path; EventLoopHandle::with_env; Body::Value::with_request_or_response(value, |v| ..); Stdio::Capture is a unit variant.

Behaviour notes: a parser-internal error with no diagnostics surfaces as ParseFailure::Other (thrown/returned) instead of an empty message; a pipeline/subshell child's duped env closes when its slot is freed (after Cmd::deinit rather than just before). Windows: a synchronous uv submission failure still re-enters IOWriter::on_error from inside write() exactly as on main (io-layer follow-up).

Perf (release, perf stat -e instructions:u, taskset-pinned, interleaved, 7 runs, medians; base = #40204): bun run --shell=bun script of 1000× true −0.15 %; 500× ([ -f ] + mkdir -p + touch + glob) +0.15 %; JS loop 3000× $`true` +0.35 %. (Node state uses a bun_ptr::JsRefCell — RefCell API/panics in debug, JsCell cost in release; node chunks are built in place with the first chunk inline.) No allocation per thread-pool or Async bounce (embedded nodes; mini Async is 0 allocs vs 1 on main).

Pre-existing bug fixed in passing: IOWriter::fail_pending_writers called each child back once while every output chunk of a builtin shared one ChildPtr, so mkdir -v a b c | head -1 / ls dir1 dir2 … | head -1 could hang after EPIPE; each parked OutputTask now has its own sequence number and the four per-builtin output vtables are one generic OutputTask::{start, write, on_chunk} (tests added).

Testing

Debug+ASAN: bunshell, commands/*, exec, throw, yield, lazy, leak, lex/parse/brace/glob/escape/env, file-io, epipe, pipeline_stack, worker-terminate-leak, seq-condexpr, sentinel, cmdsub-crash, blocking-pipe, read/write-fault, leak-args, shelloutput, bunshell-file/instance/default, run-shell, spawn-shell-signal, shell regression tests — 945 pass / 3 fail (root-user cases, identical on base) / 33 skip; new test rm error with stderr redirected to a file. Hand-driven: pipelines mixing builtins and subprocesses, ${Buffer}/${Response} redirects, &&/||/subshell/cmdsubst, brace+glob, .text/.lines/.quiet/.nothrow/.env/.cwd, throwing interpolation, Worker terminated mid-pipeline, 2000× echo hi then GC (ShellInterpreter count back to baseline) — exit codes correct, no ASAN output. clippy clean; source lints pass; rust-check-all windows-msvc + apple-darwin pass.