compiler: the full expansion stops on the output token limit; 18502's peak back to 1746 MB (stacked on #610) - #619
Conversation
What changed: - tests/fuzz_support/mod.rs: seeds from every .tex under fixtures/ and crates/*/tests; per-case splitmix RNG (seed, index) so every finding replays; mutations: truncate, delete/duplicate brace/bracket/$/\begin/\end tokens, splice lines between files, 10k-deep nesting, 100k-char control sequence names, unbalanced \if/\fi, \def recursion, invalid \char code points, \input of itself. Each case runs compile_full_project (parse + layout) on an 8 MiB thread under catch_unwind with a watchdog; worker processes isolate stack overflows and hangs; ddmin minimiser. - examples/fuzz_compile.rs: supervisor, --replay, --check, --minimise, --diagnostics. - tests/fuzz_compile.rs: the same run as an #[ignore] test (debug build). What was run: - fuzz_compile --cases 300 --jobs 12: "fuzz: 300 cases, 4 unique findings" - FLASHTEX_FUZZ_CASES=40 cargo test --test fuzz_compile -- --ignored: "the fuzzer found 3 unique failures" (expected before the fixes) Next step: 50k-case run, minimise and fix each finding. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
… u8 overflow What changed: - The per-kind and total list depths were `count() as u8 + 1` in push_list_frame and open_list: 255 enclosing lists panicked with "attempt to add with overflow" (fuzz finding). Depths now saturate (next_list_depths), and a \begin past LaTeX's \@toodeep limits (six \list levels; four itemize/enumerate) reports "LaTeX Error: Too deeply nested." while the list is still typeset. - enumitem start=<i64::MIN> no longer overflows `n - 1`. - Regression test in tests/robustness.rs. What was run: - cargo test --test robustness lists_nested (before the fix): "panicked at src/parser.rs:6972:26: attempt to add with overflow" - after: "test result: ok. 1 passed; 0 failed" Next step: remaining fuzz findings. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
What changed:
- finish_math took an unclosed formula's end from the last content token.
When the content was re-read from earlier input (`\setlength{\begin{}$`)
that token lay before the `$`, so Span::in_document tripped
"span start must not exceed end" (debug) or produced an inverted span
(release). The end now comes only from the opener's document and never
precedes the opener's end.
- Regression test an_unclosed_math_span_never_inverts.
What was run:
- before: "panicked at src/lib.rs:60:9: span start must not exceed end"
- after: cargo test --test robustness: "test result: ok. 9 passed; 0 failed"
Next step: the cross-document alignment span.
Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
… in one document
What changed:
- multirow_environment merged every cell token's span into the row span;
with `\begin{align}a\include{sub}` the cells hold tokens of sub.tex and
main.tex, tripping "cannot merge spans from different documents". Row
spans now merge only tokens of the environment's own document, and an
`\end{align}` found in another document no longer sets the display's
end offset (it could invert the span).
- Regression test an_alignment_that_inputs_another_document_keeps_its_spans_in_one_document.
What was run:
- before: "panicked at src/lib.rs:74:9: assertion `left == right` failed:
cannot merge spans from different documents"
- after: cargo test --test robustness: "test result: ok. 9 passed; 0 failed"
Next step: remaining fuzz findings (tex-expansion overflow, stack overflow, hangs).
Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…flow What changed: - Fuzz finding: `\advance` added to a count holding i64::MAX (an over-long digit string parsed to i64::MAX) and panicked with "attempt to add with overflow" (expand.rs do_arith). - Constants follow tex.web §445: past 2^31-1 is "Number too big." and becomes 2147483647 (decimal, octal, hex; also the integer part of a dimen/glue, which silently became 0 before). - `<dimen>` past max_dimen is "Dimension too large." and becomes ±max_dimen (§448). - \advance and \multiply check the result against infinity / max_dimen; on overflow they report "Arithmetic overflow." and assign nothing (§1236-1240). - \numexpr/\dimexpr: saturating steps, and an out-of-range result is "Arithmetic overflow." with value 0 (e-TeX); rounded_div computes in i128. - `fil` followed by more than two further `l`s overflowed the u8 order; now "Illegal unit of measure (replaced by filll)." (§454). - Regression test numeric_ranges_follow_tex_instead_of_overflowing. What was run: - before: left "9223372036854775807" (and the add-overflow panic) - after: cargo test (tex-expansion): "test result: ok. 56 passed; 0 failed" (expand_tests), "ok. 3 passed" (incremental), "ok. 2 passed" (oracle) Next step: stack overflow and hang findings. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…e stack
What changed:
- Fuzz finding: a table cell, footnote, \colorbox/\rotatebox/\uline
argument or \include re-enters parse_stream on its own token stream
with no depth bound. 3000 nested tabulars overflowed an 8 MiB release
stack ("thread has overflowed its stack"), and 10k took minutes (every
level copies its cell tokens).
- parse_stream now counts its nesting; past STREAM_DEPTH_LIMIT = 255 it
reports "TeX capacity exceeded, sorry [grouping levels=255]." once per
compile and skips the content nested past the limit.
- fuzz_support: FLASHTEX_FUZZ_STACK_KB overrides the case stack size.
- Regression test nested_sub_parses_hit_tex_grouping_capacity_instead_of_the_stack
(debug frames are ~50 KiB per tabular level, so it runs on a 64 MiB thread).
What was run:
- before: "thread '<unknown>' has overflowed its stack / fatal runtime error"
- after: "test result: ok. 1 passed; 0 failed" (5.86s, debug)
- release --check at depth 255, 2 MiB stack: "signature: ok" (tabular, footnote)
Next step: hangs (\loop runaway, unknown-command suggestion cost).
Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…x.web §339)
What changed:
- Fuzz finding (hang): `\loop{\n}` with no \repeat. The file ended while
\loop's argument was scanned; the expander reported the runaway but still
expanded the body with the partial argument, so \iterate ran until the
2M expansion-step limit, emitting "Extra \fi." (and, via the compiler,
one unknown-command diagnostic) per iteration: >2 minutes to compile.
- TeX sets long_state := outer_call there, so the inserted \par aborts the
call. report_file_ended_and_recover now sets the silent runaway flag
for Matching, and call_macro aborts.
- Regression test a_macro_call_cut_off_by_the_end_of_file_is_aborted.
What was run:
- before: diagnostics ["Runaway argument?...", "Extra \fi.", "Extra \fi.", ...]
- after: cargo test (tex-expansion): "ok. 57 passed; 0 failed" (expand_tests),
"ok. 3 passed" (incremental), "ok. 2 passed" (oracle)
Next step: the unknown-command suggestion cost behind the remaining hangs.
Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
… cannot hang
What changed:
- Fuzz finding (hang): `\def\a{\n\a}\a` emits ~666k `\n` before the 2M
expansion-step limit. Each got an unknown-command diagnostic whose
did-you-mean scanned the whole vocabulary several times
(command_error -> closest_commands, command_help -> suggest_command,
is_known_command), allocating per candidate: 269 s in release.
- closest_commands is memoised per thread (bounded at 4096 names);
is_known_command is a HashSet lookup; candidates whose length differs by
more than the edit limit are skipped before edit_distance copies the name
(a 100k-character control sequence: 860 ms -> 6 ms).
- Regression test a_runaway_loop_of_unknown_commands_is_diagnosed_in_bounded_time.
What was run:
- release, before: "269114 ms, 1 pages, 666816 diagnostics"
- release, after: "1989 ms, 1 pages, 666816 diagnostics"
- debug test after: "test result: ok. 1 passed; 0 failed ... finished in 10.88s"
Next step: re-run the 50k fuzz on the fixed build.
Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…ty exceeded"
What changed:
- Fuzz finding (hang, 9 of 26 hangs in a 2000-case run):
`\def\a#1{\a{#1#1}}\a x` doubles its argument on every call, so the
token lists exhaust memory and time after ~30 calls while the 2M
expansion-step limit is nowhere near.
- call_macro sizes the substitution before building it; past the
output-token budget (Limits::max_output_tokens) it reports
"TeX capacity exceeded, sorry [main memory size=5000000]." and stops
expansion, as TeX does when main memory runs out.
- Regression test an_argument_that_doubles_every_call_exceeds_capacity.
What was run:
- before (release fuzz build, --check, 40 s watchdog): cases 1204 1196 1599
1849 567 894 393 941 1280 "signature: hang"
- after: all nine "signature: ok" in <= 1 s
- cargo test (tex-expansion): "ok. 58 passed; 0 failed" (expand_tests),
"ok. 3 passed" (incremental), "ok. 2 passed" (oracle)
Next step: the slow deep-nesting cases.
Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
What changed:
- Reported from another lane: parsing
crates/page-builder/oracle/fixtures/vspace-03.tex panicked with
"span start must not exceed end" in required_group_bounded. Minimised:
`\AtBeginDocument{\setlength\}}` + `\begin{document`. The replayed
argument's closing offset lies before its `{`.
- One helper, P::span_through(open, end), now builds every
"opener through end offset" span (required_group_bounded, display math,
alignments, tabular, the delimited-argument reader): the end never
precedes the opener's end or runs past the opener's document.
- Regression test an_argument_replayed_from_at_begin_document_never_inverts_its_span
(minimised input plus the whole fixture).
What was run:
- before: "panicked at src/lib.rs:60:9: span start must not exceed end"
- after: "test result: ok. 1 passed; 0 failed"
Next step: \loop oracle fixtures parse time; slow deep-nesting cases.
Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…rash inputs What changed: - Seeds now include crates/*/oracle/**/*.tex (the page-builder oracle fixture vspace-03.tex panicked the parser and was not a seed). This changes which input each case index generates. - Up to 50 inputs per hang/crash signature are saved for triage (those signatures do not distinguish causes). What was run: - fuzz_compile --cases 2000: "fuzz: 2000 cases, 1 unique findings" (26 hangs, triaged) Next step: full 50k re-run on the fixed build. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…bug threads) What changed: - Measured after the previous commit: 255 nested tabulars still overflow a 512 KiB thread in release (128 already did; ~7 KiB a level) and a 2 MiB debug thread fails at 40 levels (~50 KiB a level), so the guard did not fire first on every profile. STREAM_DEPTH_LIMIT is now 32, like MAX_MATH_DEPTH; the error reads "TeX capacity exceeded, sorry [grouping levels=32]." - \include nesting keeps its own INCLUDE_DEPTH_LIMIT (64) and no longer counts toward the sub-parse depth. - The regression test runs on the default test thread again. What was run: - release fuzz build, FLASHTEX_FUZZ_STACK_KB=512, 3000 nested tabular / footnote: "signature: ok" (both) - cargo test --test robustness nested_sub_parses: "test result: ok. 1 passed" (0.28s) Next step: \left...\right nesting is quadratic in layout. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…not quadratic layout What changed: - Fuzz finding (slow compile, 15.7 s release for one input): sizing a \left/\right pair lays out all the atoms it encloses (left_right_stretch_scales), so 10k nested pairs cost depth x length. - The math parser counts open \left's; past MAX_LEFT_RIGHT_DEPTH = 32 it reports "TeX capacity exceeded, sorry [grouping levels=32]." once and drops the extra \left's and their \right's. Formulas within the limit are unchanged (left_right_nesting goldens pass). - Regression test deeply_nested_left_right_pairs_hit_a_capacity_limit_in_bounded_time. What was run: - before: fuzz case 529 "15664 ms, 2 pages, 17120 diagnostics" (release) - after: debug test with 10k nested pairs "ok. 1 passed ... finished in 0.13s" - cargo test --test left_right_nesting: "test result: ok. 3 passed; 0 failed" Next step: re-time the remaining slow fuzz cases. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…ot define
What changed:
- self_referential_macro_hits_explicit_recursion_limit used
`\newcommand{\loop}{\loop} \loop`. The prelude already defines \loop, so
\newcommand keeps `\loop#1\repeat`, whose argument runs to the end of
the file. It only reached the step limit through the bug fixed in
"tex-expansion: a macro call cut off by the end of file is aborted"; TeX
reports the runaway argument instead. The test now uses \recurse.
What was run:
- cargo test --manifest-path crates/compiler/Cargo.toml --no-fail-fast:
58 suites, 676 passed, 0 failed, 6 ignored
Next step: 50k fuzz re-run, PR.
Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
What changed: - rustfmt on the new fuzz files and tests/robustness.rs (which was rustfmt-clean on main). No behaviour change. What was run: - cargo test --test robustness --test fuzz_compile: "test result: ok. 13 passed" and "ok. 0 passed; 0 failed; 1 ignored" Next step: PR. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…city exceeded"
What changed:
- Fuzz finding (36 GB resident, killed): `\def\a{\csname a\endcsna` +
10k `[` ... `]` + `me}\a`. `\csname a` re-enters \a before the rest
of its 20k-token body is read, so every call adds an input level holding
that body; memory ran out long before the 2M expansion-step limit.
- Pushing a token list now checks TeX's limits: 10000 input levels
("TeX capacity exceeded, sorry [input stack size=10000].", TeX Live's
stack_size) and, every 64 levels, the tokens still pending across all
levels against the output-token budget ("TeX capacity exceeded, sorry
[main memory size=5000000]."). Either stops expansion.
- Regression test a_non_tail_self_call_exceeds_capacity_instead_of_memory.
What was run:
- before: /usr/bin/time -l fuzz_compile --check case-14361: "36046176256
maximum resident set size", 80 s, killed (exit 137) in a second run
- after: "30 ms, 1 pages, 133 diagnostics", "172441600 maximum resident set size"
- cargo test (tex-expansion): "ok. 59 passed; 0 failed" (expand_tests),
"ok. 3 passed" (incremental), "ok. 2 passed" (oracle)
Next step: remaining slow cases; full compiler suite; PR.
Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…ry block What changed: - Fuzz finding (25.7 GB resident): 30k nested itemize. Every block clones the enclosing list frames, so memory was blocks x depth. - push_list_frame now stores no frame past LaTeX's limit (the one that reports "LaTeX Error: Too deeply nested."); it counts dropped levels, and a list \end pops a dropped level first. Blocks hold at most six frames; the dropped levels typeset at the deepest stored level. - lists_nested_past_255_levels_are_too_deeply_nested_not_an_overflow also checks that no block stores more than six frames. What was run: - before: fuzz case 12106 "4117 ms, 884 pages", "25687048192 maximum resident set size"; test: "a block stored 7 list frames" - after: "622 ms, 884 pages", "138084352 maximum resident set size"; test "ok. 1 passed" - cargo test --test list_structure --test setlist_spacing --test acceptance --test pinned_fixtures --lib: all "test result: ok" Next step: remaining slow fuzz cases. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
What changed: - Fuzz finding (22 s): a mutated microtype oracle fixture whose \loop never ends. do_conditional (and a taken branch's \else) called line_of_span on every \if, counting newlines from the start of the file, for the "Incomplete \if...; all text was ignored after line N." message that only an unterminated conditional prints: quadratic in a runaway loop. - ScannerStatus::Skipping now carries the \if's span; the line is counted when that error is reported. The unused line count on a mismatched \end is gone. - Regression test conditionals_in_a_runaway_loop_do_not_rescan_the_source (also pins the "after line 3." wording). What was run: - before: test panicked "244.136504125s" (debug, 30 s budget) - after: "test result: ok. 1 passed ... finished in 0.53s"; tex-expansion cargo test: "ok. 60 passed" (expand_tests), "ok. 3 passed" (incremental), "ok. 2 passed" (oracle) Next step: re-time the remaining slow fuzz cases. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
What changed:
- The new tex-expansion capacity stops ("TeX capacity exceeded, sorry
[input stack size=10000]." / "[main memory size=5000000].") end
expansion at a point that depends on where the run started. The
expansion cache only knew the step limit, so an incremental run could
stop elsewhere than a full one: expansion_incremental's random-edit test
diverged at step 198 ("tokens differ at 60356 (cached 60356 vs full
60386 tokens)"). The rest of the document was also dropped instead of
typeset unexpanded, and the recovery note said "continued expanding".
- step_limit_hit (cache fallback, unexpanded resume) and recovery_for now
treat both stops alike.
- Regression test a_capacity_stop_is_recovered_like_the_step_limit.
What was run:
- before: expansion_incremental FAILED as above; robustness test: left
Some("continued expanding after the problem")
- after: cargo test --test expansion_incremental: "ok. 2 passed; 0 failed";
cargo test --test robustness a_capacity_stop: "ok. 1 passed"
Next step: full compiler suite, final fuzz run, PR.
Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
What changed: - tex-expansion: an identical diagnostic (severity, message, span) is recorded once between safe points; the set is emptied when next_content_token returns at a safe point, which is where incremental checkpoints are taken, so restored engines decide exactly as a full run. - tex-expansion: "group nesting limit exceeded" / "conditional nesting limit exceeded" are reported once per excursion (flags in checkpointed State). - tex-expansion: \end compares @Currenvir part by part without rebuilding the name; environment names longer than 100 characters are shortened in messages only. - compiler: diagnostics::limit_repeats drops identical repeats and keeps 1000 distinct diagnostics per code, then one "further N similar diagnostics suppressed" summary per code; fatal stops are always kept. Applied to the parser's list and to the layout's list, identically in clean and incremental compiles. - compiler: unsupported / unsupported_preamble skip building a repeat of a command already reported at the same span. - fuzz harness: per-case diagnostic count and time, max diagnostics and slowest case in the report; example --diagnostics prints the most frequent messages; --digest hashes pages and diagnostics of every seed. - tests: diagnostic_floods.rs (child process per case, peak heap via a counting allocator, watchdog), engine excursion/dedupe/long-name tests, incremental equivalence with repeats and nesting limits, warm-vs-fresh JSON identity with bounded diagnostics. What was run: - cargo test --manifest-path crates/tex-expansion/Cargo.toml: test result: ok. 63 passed; 0 failed (expand_tests), test result: ok. 4 passed; 0 failed (incremental_tests), test result: ok. 2 passed; 0 failed (oracle_tests) - cargo test --manifest-path crates/compiler/Cargo.toml --no-fail-fast: 59 suites, 684 passed, 0 failed, 6 ignored - fuzz_compile --cases 50000 --jobs 6 --seed 0xF1A57E40 (overflow checks, debug assertions): 0 panics, 0 overflows, 14 hangs (base 18); max diagnostics in one case 3109 (base 2974694) - fuzz_compile --digest over 419 seeds: pages identical for all 419; diagnostics differ in 49 (48 \loop fixtures ~14750 -> ~134, and 20-Vmatrix 3 -> 2, a duplicate glyph note) Next step: merge the updated compiler-fuzz branch and open the PR stacked on #577. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…no diagnostic What changed: - `\advance` on count, dimen and skip registers (value, stretch and shrink) adds with explicit i32 wrapping (tex.web §1238-1239 has no range check) instead of reporting "Arithmetic overflow." and leaving the register. `\multiply`, `\divide`, `\numexpr` and `\dimexpr` keep their bounds checks. - numeric_ranges_follow_tex_instead_of_overflowing pins pdfTeX's wrapped values with no diagnostic. What was run: - pdftex 3.141592653-2.6-1.40.29 (TeX Live 2026), plain and -etex: \count0=2147483647 \advance\count0 by 1 \showthe\count0 -> -2147483648 \count0=-2147483647 \advance\count0 by -2 -> 2147483647 \dimen0=\maxdimen \advance\dimen0 by 1sp \showthe\dimen0 -> 16384.0pt \dimen0=16383pt \advance\dimen0 by 16383pt -> 32766.0pt 3x\maxdimen -> -16384.00005pt; 4x\maxdimen+1sp -> -0.00005pt skip \maxdimen plus \maxdimen minus 1pt, advanced by the same minus 2pt -> 32767.99997pt plus 32767.99997pt minus 3.0pt - cargo test -p flashtex-tex-expansion --test expand_tests: test result: ok. 60 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out Next step: reword or raise the grouping-level capacity limit (#577 review). Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
What changed: merged origin/agent/daniel-parent/compiler-fuzz 0c1524c (no rebase), per the coordinator. What was run: see the next checkpoint. Next step: re-run suites on the merge. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…s where pdflatex does
What changed:
- STREAM_DEPTH_LIMIT stays 32 (255 nested tabulars, footnotes or rotateboxes
overflow a 512 KiB release and a 2 MiB debug thread), and its error now
says "FlashTeX nesting limit (32) exceeded" instead of claiming TeX's
"grouping levels" capacity.
- MAX_LEFT_RIGHT_DEPTH rises from 32 to 253 (TEX_GROUPING_LEVELS - 2), with
"TeX capacity exceeded, sorry [grouping levels=255].": pdflatex accepts 253
nested \left in a document's $$ or $ math and stops at the 254th.
The \left counter is not recursive, so the stack is not the bound.
What was run:
- pdflatex (TeX Live 2026), \documentclass{article}\begin{document}$$...$$:
252 ok, 253 ok, 254 and 255 "! TeX capacity exceeded, sorry [grouping levels=255]."
inline $...$: 253 ok, 254 capacity exceeded.
- Scratch probe (not committed) with both limits at 255:
release, 512 KiB thread: tabular/footnote/rotatebox 255 -> stack overflow;
colorbox/uline 255 ok; \left 255 ok (also on 256 KiB), 10000 in 120 ms
debug, 2 MiB thread: tabular/footnote/colorbox/rotatebox/uline 255 -> stack overflow;
\left 255 ok (also on 512 KiB), 10000 in 714 ms
- cargo test -p flashtex-compiler --test robustness:
test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 36.36s
Next step: full compiler and tex-expansion suites and a 5k fuzz smoke run.
Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…flood-cap What changed: merged origin/agent/daniel-parent/compiler-fuzz d5d308e (no rebase), per the coordinator. What was run: full compiler and tex-expansion suites on the merge (see the PR). Next step: open the PR stacked on #577. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
What changed: - The step limit and the output token limit count from the document start, so a state equivalent at a checkpoint does not decide whether a reused suffix ends the same way. Convergence now also requires the new totals to stay within both limits (or to reach the old run's stop at the same count), and the expander records how its last run ended. - Checkpoints carried past a converged edit get the step offset of the new run; they kept the old run's step count, so a later restart from one stopped at the wrong place (seed 3 edit 54 of the nesting-limit test). - Checkpoints keep the engine's last origin, where a step limit hit on the next step is reported; convergence compares it. - Un-skipped the stop edits in the nesting-limit test; new tests/incremental_stops.rs with minimised regressions and a property test over the fuzz seeds with small limits. What was run: - cargo test --test incremental_stops --test incremental_tests: test result: ok. 6 passed; 0 failed test result: ok. 4 passed; 0 failed - Against the base source: 5 of the 6 incremental_stops tests fail. Next step: full suites, latency bench, PR. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…ops apart from nesting limits What changed: - step_limit_right_after_a_checkpoint_reports_the_last_origin now edits after the stop with a checkpoint at every safe point, so some cut restarts one step before the limit. - The property test reports stops and nesting-limit excursions separately. What was run: - With the last-origin restore and comparison removed, the test fails: "diagnostics differ -- cut 5". With them: ok. - cargo test --manifest-path crates/tex-expansion/Cargo.toml: test result: ok. 63 passed; 0 failed (expand_tests) test result: ok. 6 passed; 0 failed (incremental_stops) test result: ok. 4 passed; 0 failed (incremental_tests) test result: ok. 2 passed; 0 failed (oracle_tests) - FLASHTEX_INC_STOP_EDITS=60, release: 419 seeds, 25140 edits: 17443 ended at a stop, 6524 went past a nesting limit, 2569 converged; ok. Next step: latency benches, compiler suite, PR. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
What changed: - IncrementalExpander::edit_with_limits applies an edit with new step and output-token limits (the compiler's limits grow with the document). A checkpoint is restarted from only if its step count, output count and peak main-memory size fit the new limits; Converge::same_end compares stops against the new limits (a step or output stop is reused when the new total there is the new limit plus one, a main-memory stop only under the same limit, a suffix that did not stop only if its peak fits). Changing the nesting limits re-expands the document. - Engine tracks peak_memory (largest main-memory size checked without stopping) and whether it stopped on main memory; checkpoints keep it. - IncrementalExpander::input_position: where the last run ended, as Engine::input_position gives it for a full run. - tests/incremental_stops.rs: input positions are compared; new tests edits_with_new_limits_cross_every_stop, a_smaller_memory_limit_invalidates_checkpoints_after_a_large_expansion, incremental_matches_full_on_fuzz_seeds_when_limits_change. What was run: - cargo test --release (tex-expansion): all test binaries ok; incremental_stops "test result: ok. 9 passed; 0 failed" - FLASHTEX_INC_STOP_EDITS=30 seeds 1, 2: "419 seeds, 12570 edits with changing limits: 8351 ended at a stop, 972 converged" / "8131 ended at a stop, 960 converged", ok - Each of the 7 new conditions removed in turn: every mutant fails a test. Next step: the compiler's cached expansion uses this and drops its full re-expansion after a stop. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
What changed: - expand_project_with_cache no longer re-runs expand_project after the incremental run hits the step limit or TeX capacity (and no longer starts every later revision of a runaway document with a full run). The expander equals a full run across stops (#601), so the cache now types the rest of the entry unexpanded from IncrementalExpander:: input_position, as expand_project does; those tokens are dropped before the next edit is spliced. - The cache expands under expand_project's limits (all project documents' bytes) on every revision via edit_with_limits; it used the entry's size at creation, which only the fallback had hidden. - Debug builds compare every stopped cached run with expand_project. - tests/expansion_incremental.rs: edits_around_a_runaway_loop_match_full_ expansion (typing before/inside/after a loop, same-length replacements that converge with the stopped run, deleting it) and a_runaway_entry_ follows_the_size_of_other_project_documents. What was run: - cargo test --release --test expansion_incremental: "test result: ok. 5 passed; 0 failed"; debug: "ok. 5 passed; 0 failed" (94.64s) - Mutants (no truncation of the recovery, entry-only limits, no recovery) each fail a test. Next step: fixture digests, compiler/tex-expansion suites, before/after timings. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
What changed:
- edit_latency_bench: `HW1 runaway` scenarios (HW1 with a \def\r{x\r}\r
loop; typing while it runs away, and deleting/restoring the loop's line)
and `--only SUBSTR`.
- fuzz_compile `--diagnostics FILE --digest` prints page and diagnostic
hashes, to diff two builds on one runaway input.
What was run:
- edit_latency_bench on 1e4bb85 (#601 head) and this branch: every
scenario's reply sha256 is identical (HW1, 500KB, HW1 runaway).
- fuzz_compile --diagnostics --digest on fuzz cases 10082 and 18502: same
pages and diagnostics digests on both builds.
Next step: open the PR with the before/after table.
Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…ocation for the cached stream What changed: - expand_project counts engine tokens and stops once the count goes past max_output_tokens, with the incremental expander's "output token limit exceeded" diagnostic, after converting the token that went past it. Before, only the incremental path had this limit, so a small (< 4 KB, full path) document with an output-heavy loop ran on to the step limit and typeset the rest unexpanded, while a larger one stopped earlier. - The debug-build parity check in expand_project_with_cache also covers output-limit stops. - build_cache reserves the converted stream once (engine tokens + bytes of the unexpanded rest) and finish trims it to at most 1/8 spare. Growing it by doubling left a freed chain of blocks resident for the rest of the compile: fuzz case 18502's peak footprint 2146 MB -> 1746 MB (#601: 1744 MB). - Tests: an_output_heavy_loop_stops_both_paths_at_the_output_token_limit and output_heavy_runaway_loops_match_full_expansion_under_random_edits. What was run: - cargo test --release --test expansion_incremental: test result: ok. 7 passed; 0 failed - Mutation (no output limit on the full path): both new tests fail (tokens differ at 763823, cached 763823 vs full 801109 tokens). - fuzz_compile --digest, 419 seeds: shasum 36b592ea57085d703ffea84d4b6979c628dbf236, identical to #610's head. Next step: suites, edit_latency_bench, PR stacked on #610. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
… rest was not typeset What changed: - The output token limit's diagnostic is now, in TeX's capacity wording, "TeX capacity exceeded, sorry [output token limit=N]; expansion stopped here and the rest of the document was not typeset." (was "output token limit exceeded"). One definition, tex::output_limit_message, used by IncrementalExpander, Engine::run and expand_project; tex::is_output_limit matches it. - Its compiler recovery note is "stopped expanding; the rest of the document was not typeset" (was "...typeset without macro expansion", which neither path does after this stop). - The message now starts with "TeX capacity exceeded, sorry [", so the unexpanded recovery (step_limit_hit) excludes it explicitly; is_stop_limit still matches it, so the diagnostic flood cap always keeps it. - The step limit's text is unchanged: it does typeset the rest unexpanded. - Tests assert the exact message and recovery, and that nothing after the loop is typeset. What was run: - tex-expansion cargo test --release: 0 + 63 + 9 + 4 + 2 passed, 0 failed - compiler cargo test --release: 59 test binaries, 688 passed, 0 failed, 6 ignored - expansion_incremental (debug): test result: ok. 7 passed; 0 failed (98.57s) - Mutation (output-limit stop resumes unexpanded): an_output_heavy_loop_stops_both_paths_at_the_output_token_limit fails. - fuzz_compile --digest, 419 seeds: 36b592ea57085d703ffea84d4b6979c628dbf236, unchanged. - bodyloop.tex: pages 163c9d2a21b1822b unchanged; diagnostics d82176b9e51d82eb -> 856cb57fcc2fede2 (message text only). Next step: reply on #619. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
|
daniel-parent subagent: pushed b6a7e6b (a new commit, no amend or force). It rewords the output token limit's diagnostic, as decided.
Results: Still misleading, not changed: 🤖 Generated with Claude Code |
…ontinued
What changed:
- "group nesting limit exceeded" and "conditional nesting limit exceeded" got
the note "stopped expanding; the rest of the document was typeset without
macro expansion" (recovery_for matched contains("limit exceeded")). The
engine does not stop there: maybe_handle_brace drops the `{` without
opening a group, and do_conditional returns before pushing the conditional,
so its test is read as text; expansion continues in both cases.
- New notes: "the extra group was ignored and expansion continued" and "the
extra conditional was ignored without evaluating its test, and expansion
continued". The step-limit branch now matches only is_stop_limit, whose
note (rest typeset unexpanded) is true.
- Messages are unchanged (not TeX capacity wording: that prefix means a stop
to is_stop_limit and the flood cap).
- Tests: expand_tests past_a_nesting_limit_the_extra_group_or_conditional_is_ignored_and_expansion_continues
pins the engine behaviour (tokens and follow-on diagnostics);
expansion_incremental expansion_limit_notes_describe_what_happens pins the
three notes.
What was run:
- tex-expansion cargo test --release: 0 + 64 + 9 + 4 + 2 passed, 0 failed
- compiler cargo test --release: 59 test binaries, 689 passed, 0 failed, 6 ignored
- fuzz_compile --digest, 419 seeds: 36b592ea57085d703ffea84d4b6979c628dbf236, unchanged.
- Diagnostics hashes change (pages unchanged) for 10082 9232f3ba710cfc05 -> f0c08aabd804800a,
conds.tex f0075201d8ee58ff -> 1d31e6378d071330, groups.tex 78f34e551c859d61 -> cd117cd5038b2f6c.
Next step: reply on #619.
Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
|
daniel-parent subagent: pushed c8ffe67 (a new commit, no amend or force). The nesting-limit recovery notes now say what actually happens. What the engine does (checked in the code, then pinned by a new test):
Notes (compiler
Results: Diagnostics text changes only (pages unchanged):
Also noticed, not changed: 🤖 Generated with Claude Code |
|
daniel-parent independent review (subagent)
Minor, non-blocking observation: |
Conflicts in crates/compiler/src/expansion.rs: this branch removes the Option-returning runaway fallback from update_cache/finish_diagnostics (a stopped cached expansion is not expanded a second time) while main added \refstepcounter current-label tracking. Kept the branch's control flow and signatures, carried main's current_label_log/ current_label_by_marker propagation into the cached path and the current_label_by_marker field into finish_diagnostics' Expansion. Regenerated supported-latex artifacts; bundled copy in sync. Implementation-Agent: fable-rebase-d Commit-Executor: fable-rebase-d (direct; Cursor usage limit)
…ed) into agent/daniel-parent/expansion-token-limit-parity Clean merge of the parent branch's main-merge. Implementation-Agent: fable-rebase-d Commit-Executor: fable-rebase-d (direct; Cursor usage limit)
…on-token-limit-parity Conflicts were this branch's output-token-limit parity refinements vs the #610 squash and #587's font-units work arriving from main. Kept the branch's output_limit_message/is_output_limit semantics (step_limit_hit excludes output-limit stops, refined recovery notes, capacity shrink, its extended incremental_stops/expansion_incremental/expand_tests) and took main's #587 additions (font_switches, Checkpoint metrics, scale_unit dimen scanning, DocumentFonts cache key + configure_with_fonts init, with the limits binding the run loop needs). Regenerated supported-latex artifacts; bundled copy in sync. Implementation-Agent: fable-rebase-d Commit-Executor: fable-rebase-d (direct; Cursor usage limit)
Stacked on #610 (
agent/daniel-parent/stop-single-run), which is stacked on #601, #592 and #577. Review after them. Until they merge, the diff against main includes the whole stack; this PR's own commits are the ones after adc2c67.What
Two changes to
crates/compiler/src/expansion.rs, both follow-ups from #610's "Not done" list.1. The full expansion stops on the output token limit
expand_projectnow enforcesmax_output_tokens, the same way the incremental expander does:\input/\includeonlyargument reads take.output token limit exceededdiagnostic (synthetic span). This happens after it converts the token that went past the limit, asIncrementalExpander's run loop does.output token limit exceeded, with the note "...the rest of the document was typeset without macro expansion", which neither path does. It is nowTeX capacity exceeded, sorry [output token limit=N]; expansion stopped here and the rest of the document was not typeset., with the note "stopped expanding; the rest of the document was not typeset".tex::output_limit_message/tex::is_output_limit, used byIncrementalExpander,Engine::runandexpand_project.group nesting limit exceededandconditional nesting limit exceededalso had the "typeset without macro expansion" note, but the engine keeps expanding after both.maybe_handle_bracedrops the extra{without opening a group.do_conditionalreturns before pushing it, so its test is read as text.Before this change, only the incremental path had this limit. So the same output-heavy loop behaved differently depending on the document's size:
With #610's repro (
\def\o{xyzw xyzw xyzw\o}\oinserted into the 20 KB test document), both paths now give 764,343 tokens. Before, the full path gave 799,804.The debug-build parity check in
expand_project_with_cachenow also compares output-limit stops againstexpand_project, not only step-limit and capacity stops.2. Compile-wide memory on fuzz case 18502
#610's regression was not retained or duplicated data:
The extra footprint came from freed memory the allocator kept resident. The converted stream (4.34M tokens × 96 B) grew by doubling to an 8.39M-token capacity. The chain of freed blocks from that growth stayed resident through parse and layout:
proc_pid_rusage) was 1231 MB, against 1242 MB of live heap.The fix:
build_cachereserves the stream once, for the engine token count plus the bytes of the unexpanded rest.finishtrims a stream with more than 1/4 spare capacity down to 1/8 spare. This applies once after a cold build: on 500 KB, 184K tokens in a 475K-token reservation. Later edits never trimmed in the bench: no trim or stream clone during500KBediting (probed).Why
Before → after (release, same machine, interleaved runs, load average 7–20 on 18 cores)
"#601" is 1e4bb85, "#610" is adc2c67 (this PR's base), "after" is this branch.
Fuzz case 18502,
fuzz_compile --diagnostics(3 interleaved runs each):With
--digest, which also formats all 827 pages, the peak footprint goes from 2146–2150 MB (#610) to 1801 / 1802 / 1938 MB.Heap probe on 18502, stage by stage (counting allocator; footprint from
proc_pid_rusage):edit_latency_bench, normal edits, session p50 in ms:The 500KB delete/restore timing is within the noise of this machine. Instructions retired for that scenario alone (
--only "500KB: delete", whole process, 2 runs each) are the same or slightly lower:Per edit, the code path is unchanged apart from one capacity comparison.
Identity
fuzz_compile --digest, all 419 seeds: identical to compiler, tex-expansion: a runaway document is expanded once per compile (stacked on #601) #610's head (shasum36b592ea57085d703ffea84d4b6979c628dbf236 for both, the same value compiler, tex-expansion: a runaway document is expanded once per compile (stacked on #601) #610 reported). No seed reaches the output token limit on the full path, so item 1 changes none of them.edit_latency_benchreply sha256: identical in every run, both builds, all 12 scenarios. For example257e6ff491b4534b,c300dfedc507d109, and the HW1 runaway scenarios2826b4f5b8aeaa3aand446dc408ee055be5.--diagnostics --digest, page and diagnostic hashes): identical on compiler, tex-expansion: a runaway document is expanded once per compile (stacked on #601) #610 and after for 10082, 18502,bodyloop.tex(an output-limit stop through the cache),endloop.tex,endloop1k.tex,endshort.tex,envloop.tex,conds.tex,groups.tex,missing.tex,n.tex,distinct.texandsection.tex.bodyloop.tex: pages163c9d2a21b1822bstay the same, diagnostics go fromd82176b9e51d82ebto856cb57fcc2fede2. No fuzz seed hits this limit; the 419-seed digest is still 36b592ea57085d703ffea84d4b6979c628dbf236 after b6a7e6b.9232f3ba710cfc05tof0c08aabd804800a),conds.tex(f0075201d8ee58ffto1d31e6378d071330) andgroups.tex(78f34e551c859d61tocd117cd5038b2f6c). The 419-seed digest is unchanged after c8ffe67.\def\o{+ab×100 +\o}\oin the body.expansion step limit exceeded, rest typeset unexpanded).unterminated environment 'document').unterminated environment 'document'on both builds. Pagesd8ced1b6721700a2are the same; diagnostics werec7d1e682dcbd809ebefore the rewording.Overlap check
gh pr list --state open --search "output token limit"and--search expansion: only this stack (compiler: mutation fuzzer + 13 robustness fixes (panics, stack overflows, hangs, OOM) #577, compiler, tex-expansion: bound diagnostic floods from runaway loops (stacked on #577) #592, tex-expansion: incremental re-expansion equals a full run across step/output-limit stops (stacked on #592) #601, compiler, tex-expansion: a runaway document is expanded once per compile (stacked on #601) #610) touchesexpansion.rs's cache or the expander's limits. compiler: \ifthenelse (ifthen package) is unsupported (#521) #550, compiler: \fnsymbol{footnote} inside \footnote errors (#564) #572, compiler: punctuation after a style group survives macro expansion (GH-MACRO-BODY-COMMA) #576, compiler: em/ex follow the active font, \hspace keeps its trailing space, \global length prefixes #587, tex-expansion: registers::scale_decimal loses precision vs. pdftex (GH-SCALE-DECIMAL-EXACT) #600 and compiler: \newcounter{c}[within] must recognize kernel counters (GH-NEWCOUNTER-WITHIN, #594) #605 change individual commands.coord.py claims: GH-EXPANSION-TOKEN-LIMIT-PARITY is held by daniel-parent. No other claim covers the expansion cache.vendor/is untouched.Test results
Not done
tokens/originsvectors intex-expansionstill grow by doubling (5.0M tokens on 18502, about 450 MB of capacity). Their final size is not known ahead of the run, so they are not reserved. The footprint is back at tex-expansion: incremental re-expansion equals a full run across step/output-limit stops (stacked on #592) #601's level without this.🤖 Generated with Claude Code
https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz