# 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 `Value` tree 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 by `depth::MAX_DATA_DEPTH` (512) or made iterative: - **reader** (`read_form`): reading past the cap (e.g. 100000 nested `[`) now returns `ReadError::TooDeep` instead of recursing to overflow. - **printer** (`pr-str`/`str`/`prn`/`println`/`print`): before, printing a deep value overflowed the stack. Now `print_into` is depth-bounded — a subtree past the cap prints the literal marker `#` — and cycles are detected: a self-referencing atom `(reset! a a)` prints `#atom[#]` instead of looping forever. The infallible `print_str` keeps the marker (used by `Display`/error paths); the user-facing print prims call a new fallible `print_str_checked`, so a script printing over-deep data gets a `:eval/limit` throw (`{:limit :nesting}`) rather than a silently truncated string. - **hash** (`hash_val`): before, `(hash x)` on a deep value overflowed via `hash32`→`hash_sequential`→`hash32`. Now `hash32` threads a depth and, past the cap, folds one fixed sentinel byte for the whole subtree (so equal- shaped deep values still hash alike; `eq` remains the source of truth). - **eq** (`eq_val`): before, `(= x x)` on a deep value overflowed via `eq_val`→`eq_sequential`→`eq_val`. Now `eq_val` is iterative (an explicit `Vec` worklist 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 returns `false`. - **compare** (`compare`/`default_cmp`): before, `(compare x x)` on nested vectors overflowed via `default_cmp` recursion. Now `default_cmp` threads a depth (`cmp_at`) and, past the cap, throws `"compare: nesting too deep"` instead of recursing. Ordinary data compares exactly as before. - **Tail calls no longer grow the stack.** A call to a fn in tail position now returns an internal `TailCall` signal that the caller's apply loop runs, as upstream mino does (`MINO_TAIL_CALL`). Before, only `recur` did this, so `dotimes`, `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 a `try` (the body is fully run inside the frame, so `catch` still 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 per `Gc` edge; see `vendor/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-p` and `file-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`. Durable `mino.store` ops (`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. - **Print capture**: in a sandboxed interpreter `print`/`println`/`prn` append 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 to `None` (unlimited). The budget resets at each top-level eval. - `steps`: one per `eval`, 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`: every `eval` and fn application is one level (a `recur` loop 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}`. The `Limits` doc 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 returns `Err`, 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" | , :mino/data {:limit :steps|:heap|:depth|:hook, :value N}}` (kind and code match upstream mino). It is **uncatchable**: once one trips, `try` re-raises it without running `catch` or `finally`, and every later eval step re-raises it until the top-level eval returns. ### Changed - `repeat` is now a native prim. *Before:* core.clj's `repeat` recursed once per element through the eager `lazy-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 with `steps 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.