Stream depth must not be bounded by the stack
A lazy stream is a chain of closures — block → captured env → scope →
binding → block — so any walk that crosses the captured-env link recursively
spends stack once per line. Exactly two walks cross it: the serial encoder and
drop glue. Both now cross it flat. intern_scope only reserves an id and
queues the scope; InternCtx::finish — the table’s sole accessor — drains the
queue. Teardown is cut by a thread-local drop trampoline on Closure: glue
still does all traversal, but a closure dying inside another closure’s drop
hands its bindings to that dismantler’s queue instead of letting glue recurse,
keeping the stack between any two links constant. No wire change, no new
dependency, no stream redesign.
Context
from-lines builds one `more [head, tail] node per line, each tail a
Block capturing a one-binding Env holding the next node. Every other
Value walk — Display, PartialEq, shallow_size, value_carries_handle,
scrub — deliberately stops at the closure boundary, so their depth per node
is a small constant. The two walks that cross the boundary were recursive, and
both crashed in the field: a pipeline helper stage aborted encoding a stream of
a few hundred lines (ls /tmp | from-lines began failing when /tmp grew), and
a sixty-thousand-line stream aborted the process in drop glue at teardown.
Decision
-
Interning reserves;
finishencodes. The oldintern_scopeencoded a scope’s bindings inline, and a binding holding a closure re-enteredfrom_runtime, which re-enteredintern_scope— one full cycle of frames per stream link. Nowintern_scopeis infallible: it assigns the id, reserves the row, and queues(id, Arc).InternCtx::finish(self)drains the queue — encoding a scope may queue more scopes, never recurse into them — and is the only way to obtain the table, so no envelope can ship half-encoded. The queuedArcs double as liveness pins: an interned pointer cannot be reused by a fresh allocation mid-encode. Encoder stack depth is now bounded by data nesting inside one scope, not by stream length. The decoder needed nothing:WireDecoder::for_shellalways resolved row dependencies rather than trusting id order, and rows were already flat. -
A drop trampoline on
Closure, not a hand-rolled walk.Valueis destructured by move throughout the evaluator, soDrop for Valueis not writable (E0509). Every chain link passes through aBindingand through aClosure; the cut sat onBindingfirst (260806) and moved toClosure(260826) once the persistent map made binding drops loop-resident — everybindinto a shared environment drops a copied node, oneBindingper neighbour, and a list in scope was paying the trampoline on every iteration. Closures are rare where bindings are common.Closure::dropcallsEnv::dismantle, which keeps a thread-local queue of binding maps: when no dismantler is active it becomes one — dropping the map by plain glue, then draining the queue until quiet — and when one is already active above it on this thread’s stack, it pushes the map and returns. The machine’s two closure destructurings go throughClosure::into_parts(ManuallyDrop+ptr::read), sincemem::takewould allocate anEnv::new()per step. Glue therefore still performs all traversal — a shared spine stays one refcount decrement, nothing is ever cloned in order to be destroyed,Arc’s own atomic elects the single deallocator under concurrent drops — but the stack between any two links is a constant band of glue frames. An empty binding map skips the queue entirely; a closure dying during thread-local teardown falls back to plain glue, the only depth-honest option left there.
Alternatives considered
- An iterative
Drop for Envwalking an ownership worklist. Implemented first, and rejected by measurement: it runs on every env drop and iterates the whole scope chain with copy-on-write pops, so a deep-recursion test (scope_escapes, one scope per frame, an env clone dropped per call) went from 14 s to spinning for 11+ minutes — O(depth) per drop on exactly the hot pathimblwas chosen to make O(1). - Ownership traversal in
Binding::drop(dismantle the dying value by consuming it). Rejected: consuming a persistent container that shares chunks with a survivor clones elements — deepStringcopies included — in order to destroy the clones; an accumulator loop’s rebinds would pay O(n) each, O(n²) overall. Only the trampoline lets glue keep the O(1) shared-spine drop while still cutting the stack. - Segmented stacks (
stacker::maybe_grow). Handles arbitrary depth in three lines, but treats the symptom in one walk, adds a dependency, and leaves drop glue — whichstackercannot reach — recursive. - Parent-side folding of pipe streams (the parked pipe plan): removes this wire crossing for one shape, but any deep value crossing any wire would still overflow, and teardown would still abort.
- A depth limit with a clean error. A 386-entry /tmp is not pathological; refusing it is not a fix.
Consequences
- Pure-data depth (a deeply nested list/variant with no env links) still recurses in every walk, this module’s included — such values cannot be built without tripping other recursion limits first.
WireDecoder::for_shellbuilds one row per pass over an n-link chain — O(n²) passes. Correct, and cheap at the sizes this unblocks; a Kahn-style topological build is available if streams ever cross the wire at 10⁵ links.- Every non-scalar binding death costs one thread-local access and branch;
the leader additionally allocates one small queue.
scope_escapesmeasures at or below its pre-change time. - Stack use in
seq 1 n | from-linesis now O(1) in n on both sides of the wire and at teardown: 50k-link encode and 100k-link drop verified on 256 KiB thread stacks.
See also
transport (the serial layer this reshapes),
codecs (from-lines and the lossy line-stream contract),
pure-pipe-equation (why a stream is an
ordinary variant rather than a runtime-recognised type).