Changelog — mino-rs
Unreleased
Fixed
Deeply nested data no longer overflows the Rust stack when read, printed, hashed, compared for equality, or ordered. Every walk over a
Valuetree used Rust stack proportional to the nesting, so a value nested a few thousand deep (e.g. built by(loop [x nil i 0] (if (< i N) (recur [x] (inc i)) x))) aborted the process on a 2 MB debug stack. Untrusted callers (mentat_eval) could crash the host with such data. Now all five paths are bounded bydepth::MAX_DATA_DEPTH(512) or made iterative:- reader (
read_form): reading past the cap (e.g. 100000 nested[) now returnsReadError::TooDeepinstead of recursing to overflow. - printer (
pr-str/str/prn/println/print): before, printing a deep value overflowed the stack. Nowprint_intois depth-bounded — a subtree past the cap prints the literal marker#<nesting too deep>— and cycles are detected: a self-referencing atom(reset! a a)prints#atom[#<cycle>]instead of looping forever. The infallibleprint_strkeeps the marker (used byDisplay/error paths); the user-facing print prims call a new fallibleprint_str_checked, so a script printing over-deep data gets a:eval/limitthrow ({:limit :nesting}) rather than a silently truncated string. - hash (
hash_val): before,(hash x)on a deep value overflowed viahash32→hash_sequential→hash32. Nowhash32threads a depth and, past the cap, folds one fixed sentinel byte for the whole subtree (so equal- shaped deep values still hash alike;eqremains the source of truth). - eq (
eq_val): before,(= x x)on a deep value overflowed viaeq_val→eq_sequential→eq_val. Noweq_valis iterative (an explicitVecworklist of value pairs) with no depth cap and no wrong answers: sequentials and map values push their children, sets compare via the (now-iterative)contains, any mismatch returnsfalse. - compare (
compare/default_cmp): before,(compare x x)on nested vectors overflowed viadefault_cmprecursion. Nowdefault_cmpthreads a depth (cmp_at) and, past the cap, throws"compare: nesting too deep"instead of recursing. Ordinary data compares exactly as before.
- reader (
Tail calls no longer grow the stack. A call to a fn in tail position now returns an internal
TailCallsignal that the caller’s apply loop runs, as upstream mino does (MINO_TAIL_CALL). Before, onlyrecurdid this, sodotimes,while(both expand to a named fn calling itself last) and any self- or mutually-recursive fn used one Rust stack frame and one depth level per iteration:(dotimes [i 60] i)already hit a depth limit of 300, and without limits large counts overflowed the stack and aborted the process. Now(dotimes [i 1000000] ...),(while ...)over 100,000 iterations and a 1,000,000-deep self tail call run at constant depth on a 1 MB stack. Tail calls never cross atry(the body is fully run inside the frame, socatchstill sees the throw), and non-tail recursion such as(inc (f (dec n)))is still bounded by the depth limit.Garbage collection no longer overflows the stack on long or deeply nested data. mino now uses a vendored copy of rust-gc v0.5.1 (
vendor/, MPL-2.0) whose marking uses a worklist instead of recursing once perGcedge; seevendor/CHANGES.md. Before, collecting while a list of ~6,000 elements was alive aborted a debug build on a 2 MB stack (so(count (range 10000))crashed), and live data nested ~200,000 deep aborted release builds. Now both run a million elements / 300,000 levels on a 2 MB stack.
Added
Interpreter::sandboxed(): an interpreter with no host access.- Before: every interpreter bound
slurp,spit,rm-rf,mkdir-pandfile-exists?, so any script could read, write or delete files as the host process. - Now: those five prims are unbound in a sandboxed interpreter; calling
one gives
unbound symbol. Durablemino.storeops (store-open*with a path,store-read-snapshot*,store-read-wal*, and commit/checkpoint/close on a pathed store) throw:eval/contract“… disabled in a sandboxed interpreter”. In-memory(mino.store/open)still works.Interpreter::new()keeps full host access.
- Before: every interpreter bound
- Print capture: in a sandboxed interpreter
print/println/prnappend to a buffer (charged to the heap budget) instead of writing stdout.take_output()returns the buffer and clears it.- Before: script output went straight to the host’s stdout (in pg_mentat that is the postmaster log).
Limits { steps, heap_bytes, depth }+set_limits. All default toNone(unlimited). The budget resets at each top-level eval.steps: one pereval, plus one per element produced by a bulk prim. Before:(loop [] (recur))hung the host forever. Now: it fails with:eval/limit {:limit :steps}.heap_bytes: bulk producers charge their output size before allocating. Before:(range 100000000000)tried to allocate ~3 TB and OOM-killed the process. Now: it fails in microseconds with:eval/limit {:limit :heap}. Charged:range,repeat,vec,set,into,concat,map,mapv,filterv,apply(the spread seq),reverse,sort,sort-by,str,clojure.string/join,clojure.string/replace/replace-first(string and regex), print capture.depth: everyevaland fn application is one level (arecurloop stays at one level). Before:(defn f [n] (if (zero? n) 0 (inc (f (dec n))))) (f 1000000)overflowed the Rust stack and aborted the whole process (SIGABRT, no unwinding). Now: it fails with:eval/limit {:limit :depth}. TheLimitsdoc comment has measured stack cost per level and recommended values.
set_check_hook: a host callback run every 4096 eval steps and each time depth crosses a multiple of 64. If it returnsErr, the eval aborts with that message (for cancellation or wall-clock timeouts).- A limit error is the diagnostic map
{:mino/kind :eval/limit, :mino/code "MLM001", :mino/message "step limit exceeded" | "heap limit exceeded" | "depth limit exceeded" | <hook message>, :mino/data {:limit :steps|:heap|:depth|:hook, :value N}}(kind and code match upstream mino). It is uncatchable: once one trips,tryre-raises it without runningcatchorfinally, and every later eval step re-raises it until the top-level eval returns.
Changed
repeatis now a native prim. Before: core.clj’srepeatrecursed once per element through the eagerlazy-seq, so(repeat 20000 "a")overflowed the stack. Now: it uses O(1) stack and is charged up front. Infinite(repeat x)still needs lazy seqs and throws.- The recursive eval path was reshaped to use less stack. Special forms, macro expansion, collection literals and non-fn callables now run out of line. A debug-build mino call level dropped from ~25 KB to ~7.8 KB of stack, so 500 depth levels fit in a 1 MB debug thread stack.
- The mentat scripting layer (
src/script.rs) now runs sandboxed withsteps 10_000_000,heap 64 MiB,depth 1000.
Known issues
- Deeply nested data still recurses without a depth check in the reader,
printer,
=/hash and GC drop, so a 100k-deep nested vector can still overflow the stack. This is a separate task. - The heap budget counts cumulative bytes charged by the bulk prims listed
above. It is not a live-heap measurement. Small allocations (one
conj, one closure) count only as steps.