This is a submission for DEV's Summer Bug Smash: Clear the Lineup.
Project Overview
Typst is the Rust-based markup typesetting system that's been quietly eating LaTeX's lunch for the last couple of years — you write plain-text markup, Typst compiles it to PDF (or HTML) with a real incremental compiler underneath instead of a multi-pass macro soup. Part of what makes it pleasant is that it exposes its own evaluator to itself: a document can call #eval("some typst code") and get back whatever that code produces, at runtime, inside the document being compiled. It's a small feature, but it opens up a surprisingly deep rabbit hole, which is exactly where this bug was hiding.
Bug Fix or Performance Improvement
The problem: typst/typst#8632 reports that Typst crashes instead of erroring when #eval is used to recursively re-import the file it's running in. The repro is one line. Save this as overflow.typ:
#eval("import \"overflow.typ\"")
Then:
$ ./target/debug/typst compile overflow.typ overflow.pdf
thread 'main' (750833) has overflowed its stack
fatal runtime error: stack overflow, aborting
No PDF, no diagnostic, no exit code you can act on — just a native stack overflow and a SIGABRT. Typst has had cyclic-import detection since forever; a normal top-level import "overflow.typ" inside itself gets caught cleanly with an error: cyclic import message. Something about routing the import through #eval specifically was bypassing that check.
The hunt
I'll be upfront about how this investigation actually went: I worked through it with Claude (Anthropic's coding agent) rather than solo. The triage pass it did before I sat down with the code had already narrowed the search to one function — eval_string in crates/typst-eval/src/lib.rs — and named the suspect line. That's a big head start, but a named suspect isn't a confirmed root cause, so the actual work was reading that function next to its sibling, the file-level eval() a few lines above it, and checking the claim against the source myself rather than taking the plan on faith.
And it held up. Side by side:
- File-level
eval()builds its engine withroute: Route::extend(route).with_id(id), andimport_filebails with a cyclic-import error wheneverroute.contains(id)is true for the file it's about to open. -
eval_string— the routine that actually backs the#eval()builtin — builds a deliberately "detached" engine (freshScope,EmptyIntrospector,Context::none()), and for its route it just didroute: Route::default().
That's the entire bug, in one word: default() instead of extend().
Root cause deep-dive
Route is a linked structure — each frame in a call chain points back at its caller, and Typst uses it for two independent guards: route.contains(id) for cyclic-import detection, and Route::check_call_depth (an ~80-deep recursion cap — the same guard that trips on deeply nested show rules or recursive calls elsewhere in Typst). Both guards work by walking that outer chain.
Route::default() has no outer chain and no id. So from the guards' point of view, every single #eval() call looks like the very first evaluation ever performed — depth zero, no ancestors — no matter how many stack frames of real recursion are sitting underneath it. Trace what actually happens with overflow.typ:
- Evaluating
overflow.typbuilds routeR0, tagged with idoverflow.typ. - The
#eval("import \"overflow.typ\"")call invokeseval_string, which throws awayR0and buildsR_fresh = Route::default(). - Inside that fresh, historyless engine, it evaluates
import "overflow.typ".import_filechecksR_fresh.contains(overflow.typ)— false, becauseR_freshremembers nothing. - That import re-enters
eval()→eval_string()→ another fresh, disconnected route → another import → ... forever.
Nothing ever trips, because nothing is ever checked against real history. The only thing left to stop it is Rust's stack guard page, and that failure mode is a process abort, not a SourceResult error — which is exactly what made this bug both easy to trigger and unpleasant to hit.
The tell, once you're looking for it: eval_closure, the sibling routine used for calling ordinary closures, already threads route: Tracked<Route> through and does Route::extend(route). The correct pattern already existed in the same file. eval_string was just the one routine that didn't follow it.
Code
PR: https://github.com/typst/typst/pull/8661
The fix threads the caller's real Route through eval_string instead of manufacturing a disconnected one. Because Typst splits its compiler across crates and links these "routines" via function-pointer tables rather than trait objects (to dodge circular crate dependencies), adding one parameter meant touching every signature and every call site — 7 files, 18 insertions, 3 deletions total.
The core fix, in crates/typst-eval/src/lib.rs:
introspector: Tracked<dyn Introspector + '_>,
+ route: Tracked<Route>,
context: Tracked<Context>,
string: &str,
spans: SpanMode,
- route: Route::default(),
+ route: Route::extend(route),
The trait signature in crates/typst-library/src/routines.rs gains the matching parameter, and the real call site — the #eval() builtin in crates/typst-library/src/foundations/mod.rs — now passes the engine's actual route instead of nothing:
EmptyIntrospector.track(),
+ engine.route.track(),
Context::none().track(),
&text,
SpanMode::Uniform(span),
Three other call sites had no engine/route in scope at all, because they're genuine top-level entry points: the CSL bibliography math renderer, and the typst eval / typst query CLI subcommands. Those got Route::default().track() passed explicitly, which preserves their exact prior behavior instead of silently changing it — keeping the diff minimal and honest about what actually needed to change versus what was just plumbing.
One deliberate detail: eval_string does not call .with_id(...) on its extended route, unlike file-level eval(). That's correct, not an oversight — eval_string evaluates a bare string, which has no FileId of its own to attach. Cycle detection still works fine because the calling file's id is already baked into the outer chain being threaded in.
My Improvements
Before/after, same binary, same repro:
# BEFORE (unmodified main)
$ ./target/debug/typst compile overflow.typ overflow.pdf
thread 'main' (750833) has overflowed its stack
fatal runtime error: stack overflow, aborting
# AFTER (fix applied)
$ ./target/debug/typst compile overflow.typ overflow.pdf
error: cyclic import
┌─ overflow.typ:1:6
│
1 │ #eval("import \"overflow.typ\"")
│ ^^^^^^^^^^^^^^^^^^^^^^^^^
EXIT CODE: 1
Non-cyclic #eval usage — #eval("1 + 1"), cross-file #eval("import \"other.typ\"; other.x"), typst eval "1+1", typst query — all still work unchanged.
A crash bug is annoying to test-first: a stack overflow SIGABRTs the whole test process, so you can't just run the unfixed code under cargo test and expect a failing assertion — you'd just crash the test runner. So verification happened at two layers. First, an actual CLI build of the vulnerable binary against a kill-guard to capture the real "before" crash text above. Then a snapshot test, added to tests/suite/foundations/eval.typ, mirroring the existing file-level import-cyclic test:
--- eval-string-cyclic-import eval ---
// Cyclic import of this very file through `eval`.
// Error: 7-30 cyclic import
#eval("import \"./eval.typ\"")
Test results: the new test passes on its own (1/1), and nothing nearby regressed — eval (24 passed), import (99 passed), recursion (9 passed), cite/bibliography (66 passed), and the full integration suite comes back clean at 3720 passed, 0 failed, 0 skipped. Unit tests across typst-eval, typst-library, and typst-cli add another 34 passed. cargo fmt --check and cargo clippy are clean on every touched file.
Two things I want to flag honestly. First, the AI-assisted part: the triage that named eval_string and Route::default() came out of an earlier pass with Claude, and I worked the actual fix and verification with it too rather than purely solo — I want to be upfront about that rather than presenting it as a from-scratch human discovery. What I didn't outsource was believing it; I read the sibling functions myself, traced the recursion by hand, and built both binaries to see the crash and the fix with my own eyes before trusting either.
Second, a real lesson that'll outlast this specific bug: this is a codebase where several near-identical "routine" functions exist side by side (eval_string, eval_closure, and others), and one of them was quietly missing a parameter that all its siblings had. That asymmetry is a strong, checkable signal — worth grepping for the next time a similar "only one code path forgets to do the bookkeeping" bug shows up, in this codebase or any other with a family of nearly-parallel functions. It's also a nice reminder that "detached" or "sandboxed" execution contexts (which eval_string is deliberately trying to be) need real care about which invariants they're allowed to detach from — isolating the introspector and style context was intentional and fine; silently disconnecting the recursion guard was not.
Top comments (0)