DEV Community

Jiwon Yoon
Jiwon Yoon

Posted on

Clear the Lineup — Chasing a Stack Overflow in Typst's `#eval` Down to One Wrong Word

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\"")
Enter fullscreen mode Exit fullscreen mode

Then:

$ ./target/debug/typst compile overflow.typ overflow.pdf
thread 'main' (750833) has overflowed its stack
fatal runtime error: stack overflow, aborting
Enter fullscreen mode Exit fullscreen mode

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 with route: Route::extend(route).with_id(id), and import_file bails with a cyclic-import error whenever route.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 (fresh Scope, EmptyIntrospector, Context::none()), and for its route it just did route: 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:

  1. Evaluating overflow.typ builds route R0, tagged with id overflow.typ.
  2. The #eval("import \"overflow.typ\"") call invokes eval_string, which throws away R0 and builds R_fresh = Route::default().
  3. Inside that fresh, historyless engine, it evaluates import "overflow.typ". import_file checks R_fresh.contains(overflow.typ) — false, because R_fresh remembers nothing.
  4. 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,
Enter fullscreen mode Exit fullscreen mode
-        route: Route::default(),
+        route: Route::extend(route),
Enter fullscreen mode Exit fullscreen mode

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),
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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\"")
Enter fullscreen mode Exit fullscreen mode

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)