ADR-036: A Stack Trace Is Asked For, and the Site Is Free

Status: Superseded by ADR-280 — its D1-D4 are D1-D4 there (D3 together with ADR-159 D3) Closes: ADR-023 §3.2 Supersedes: ADR-023 D7’s {error:full} format spec Related: ADR-023 D6 (an error knows the site that raised it), ADR-018 (why the short form is the default)


1. Context

Part I 7.1 promised that every error carries file, line and a stack trace, “invisibly”. The word doing the damage is every: errors here are the expected kind, one per rejected line or failed parse, so a promise made per error is a promise made a million times in a program that validates a million rows.

ADR-023 §3.2 left the cost open and asked for a number.


2. What was measured

valgrind --tool=callgrind, 20 000 errors raised eight frames deep and caught, -O, one binary per variant. rustc 1.94.0-nightly (8d670b93d 2025-12-31).

variant instructions per error against the baseline
no trace in the error at all 37 524 906 — baseline
a trace captured per error 603 017 418 +28 300 +1507 %
the same binary, capture suppressed 38 536 318 +51 +2.7 %

Sixteen times the whole program. Not a percentage to weigh against ergonomics — a different order of magnitude, for a value almost nothing reads.

And the trap, which is why the number was worth taking. The obvious implementation — put a Backtrace in the error type and assume it is dormant until someone asks — is the 1507 % row the moment anything enables capture. Deduction would have reached “it costs something”; it would not have reached “a single environment variable moves the program between those two rows”, and that is the part that decides the design.

The switch is the language’s own, and the reason is a second trap. Backtrace::capture() consults RUST_BACKTRACE/RUST_LIB_BACKTRACE and is Disabled unless one of them is set — checked rather than assumed:

  capture() force_capture()
RUST_BACKTRACE unset Disabled Captured
RUST_BACKTRACE=0 Disabled Captured
RUST_BACKTRACE=1 Captured Captured

So an error type built on capture() behind a flag of its own would tell the reader to set that flag and then silently not capture. D3’s NIKAIA_TRACE is therefore read once and drives force_capture: one variable decides whether there is a trace, and it is the one the message names.

Why instruction count is the right quantity here

The objection to instruction counts is that they answer how much work and not how long, and that they are blind exactly where memory behaviour, branch prediction or vector width change. So the same runs under cachegrind:

  D refs D1 miss rate LLd miss rate
no trace 14 891 477 0.2 % 0.0 %
captured 198 298 276 0.0 % 0.0 %

Nothing about memory behaviour moved — the miss rate falls, because a stack walk is a tight loop over hot data. The cost is work and only work, which is the case in which an instruction count is not a proxy for the answer but the answer. A change that had moved those rates would have needed a different measurement, and this note is here so that the next reader can tell the two situations apart.

Reproducibility. Callgrind simulates rather than samples: the same binary gives the same count to the digit, and did (37 524 906 twice). That is why it is used here and wall-clock is not — but the number is only stable for this binary, so the toolchain is recorded above. A count taken under a different rustc is not comparable to these, and comparing across time without saying so is the mistake this paragraph exists to prevent.

What would have falsified the decision: a per-error cost under about a hundred instructions. It is 28 300.


3. Decisions

D1 — An error does not capture a stack trace unless the program asked

The default is no capture and no Backtrace field’s worth of work. NIKAIA_TRACE turns it on for a process, read once rather than per error.

This is ADR-010’s rule at a second site — “instead of making every program pay for the worst case” — and the measurement is what settles which side of it a trace falls on. Where ADR-010 could pick the cheap thing for the common case and the safe thing for the rare one, here there is no cheap capture: it is 28 300 instructions or nothing.

A runtime switch, not a build one. A build flag that changed whether errors carry a trace would make the error type depend on how the program was built, and ADR-011 D4 says the switch chooses the executor and nothing else. The same source produces the same types.

D2 — Part I 7.1’s promise is narrowed, and says so

The chapter promised file, line and a trace on every error. File and line — the site — stay, and cost nothing at run time: ADR-023 D6 has the compiler write the raise site into the binary as text, which is a string constant rather than work. The trace does not stay. A reader of 7.1 must not be able to conclude that a trace is there when it is not.

D3 — The absence of a trace is stated, never silent

error.full() on an error raised without tracing ends in (no trace; set NIKAIA_TRACE=1 to capture one). An operator reading a report must not have to wonder whether a trace was lost, or whether the program was built without one, or whether this error simply has none.

The same rule covers an error from below Nikaia: it has no site to name, and says “raised below Nikaia; no site recorded” rather than inventing one. This is ADR-024 D1’s ? as a sentence — the absence of a claim is itself reportable.

D4 — error.full() is a method, not a format spec

ADR-023 D7 said {error:full} needed no new syntax, because a hole may carry a format spec after the first colon. That was wrong in a way only the implementation shows: a format spec is passed through to the language below, which has no full, so it would have needed a special case in the emitter — exactly the “new syntax” the decision claimed to avoid.

A hole already holds an expression, including a call. f"{error.full()}" therefore needs nothing at all, which is what D7 wanted and did not get.


4. Consequences

5. What is not done

This site is open source. Improve this page.