Fix two shipped value-type defects: provenance chains that overflow the stack, and locale group separators that are stripped instead of validated - #581
Merged
Conversation
A running total records one `ASTNode` per iteration, chained through `left`,
and nothing collapses that chain. Every walk over it was recursive, so a deep
enough derivation ran the stack out. Measured on clang 20 and clang 22 at
`-O0` with an 8 MiB stack:
$ ./prov0 200000
total = 200000EUR (chain of 200000 provenance nodes retained)
destroying...
$ echo $?
139 # SIGSEGV in the destructor chain
Destruction crossed the limit between 20,800 nodes (exit 0) and 21,000
(SIGSEGV). The same binary at `-O2` survived 200,000, because clang rewrites
that particular chain into a loop -- crashes in Debug, survives in Release.
morph#574 flagged `equation()` as unverified. It overflows too, and with no
optimiser escape, since its frames hold live `Rendered` strings across the
call. Reproduced at `-O0`: returns normally at 24,000 nodes, SIGSEGV at
25,000, in `EquationRenderer::renderSymbolic`.
`~ASTNode` now detaches its children into a local worklist and releases them
one at a time, unlinking a node's own children only when that pop holds the
last reference -- so every `~ASTNode` the loop reaches has null children and
cannot recurse. All four `equation()` traversals run over an explicit stack;
the symbolic and substituted renderings, which differed only in how they stop,
became one stack machine selected by a `RenderMode`, so there is one traversal
to get right instead of two. Output is byte-identical: the existing 62
`[quantity]`/`[render]`/`[locale]` cases pass unchanged.
The two regression tests build a 100,000-node chain. What makes them evidence:
on unfixed code the destruction case is SIGSEGV at `-O0` (verified -- it is
vacuous at `-O2`, and the test says so and says which CI legs are Debug), and
the `equation()` case is SIGSEGV at every optimisation level.
`MORPH_QUANTITY_PROVENANCE` keeps its default of `1`. The measured cost is
real -- 54,056 KB and 0.034 s against 12,236 KB and 0.006 s for a
200,000-iteration total -- but the toggle changes observable behaviour, not
just cost: with it `0`, `equation()` collapses to the bare value and `named()`
discards the name, so flipping it would silently empty both for every build
that never set it. `docs/spec/util/quantity_type.md` promised the default in
four places; the spec now carries the measurement, both readings of the
argument, and the note that the crash was never the toggle's business, since
a stack overflow is not an acceptable failure mode for either setting.
Refs morph#574.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
…them
`normalizeLocaleNumber` dropped every occurrence of the group separator
unconditionally, before the decimal check and with no check on placement.
So a German user typing the US form into a price field submitted ten times
what they meant, and nothing downstream could tell -- the result is a
perfectly valid number:
normalize("1.5", dec=",", grp=".") = "15"
normalize("1.50", dec=",", grp=".") = "150"
normalize("1.2.3.4", dec=",", grp=".") = "1234"
normalize("1,5", dec=".", grp=",") = "15" <-- the en-US mirror
normalize("1.5", dec=".", grp=".") = "15" <-- separators equal
The QML mirror in src/qt/forms/qml/DynamicForm.qml was flagged as unverified.
It is verified now: lifted out and run on the same inputs, it produced those
five answers byte for byte. Consistently wrong is still wrong, so both edges
are fixed, and a 47-case differential over both implementations confirms they
agree on every one.
A group separator is now dropped only where one can legally be: preceded by
one to three digits, followed by exactly three, never after the decimal
separator. All five lines above are `std::nullopt` and the caller can tell the
user to fix the entry. Equal separators are rejected too -- one string in both
roles has no defensible reading of "1.5" -- through the return value rather
than an `assert`, deliberately: an assertion would make a control edge behave
differently in Debug and Release, and would be untestable in the build where
it fires.
normalize("1.050,25", dec=",", grp=".") = "1050.25"
normalize("1.000.000,25", dec=",", grp=".") = "1000000.25"
normalize("1050,25", dec=",", grp=".") = "1050.25"
normalize("1 050,25", dec=",", grp=" ") = "1050.25" (U+202F too)
The existing suite had 18 cases, every one of them single-locale, so all 18
passed with the stripping and with the validation alike. The four new cases
are cross-locale, equal-separator, group-placement, and a control that every
well-formed entry still normalises; three of the four fail on unfixed code (16
assertions). On the QML side `test_foreignDecimalSeparatorIsRejectedNotAbsorbed`
fails on the unfixed mirror and passes on the fixed one, verified by reverting
the file and rebuilding.
examples/ledger/README.md listed this as an open ladder finding; it now
records the fix and keeps the separate `double`-division display item.
Refs morph#574.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
Two entries under Fixed: the locale group-separator validation, with the wrong values it used to produce, and the provenance-chain stack overflow, with the measurement behind leaving MORPH_QUANTITY_PROVENANCE at 1. Refs morph#574. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
Three things clang-tidy-diff would have reported on the changed lines, all
worth doing on their own terms:
- `normalizeLocaleNumber` reached a cognitive complexity of 38 against a
threshold of 25, because the grouping rule was extra state threaded through
the normalising scan. "The grouping is well placed" and "the digits convert"
are two separate statements about an entry, and reading them as one made
neither clear. The placement rule is now `detail::groupingIsWellPlaced`, a
pass of its own that can be read without the scan around it. Verified
behaviour-preserving: the 47-case differential over both control edges is
byte-identical before and after, and still identical to the QML mirror.
- `detail/quantity_equation.hpp` was not self-contained -- it is included from
`util/quantity.hpp` after `ASTNode` exists, so a tool that opens it on its
own saw `unknown type name 'ASTNode'` on every line. Local clang-tidy on the
unmodified file reports 20 such errors and a cascade of bogus findings
("method 'countRefs' can be made static" -- it accesses three members). It
now includes `util/quantity.hpp` back, which `#pragma once` makes free and
which nothing in that header past the include point can notice. Checked with
`clang++ -fsyntax-only -x c++-header` on the file alone.
- `misc-const-correctness` on the worklist handle in `~ASTNode`.
Refs morph#574.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
… bites The first version of these two cases used 100,000 nodes for both, on the ticket's wording rather than on a measurement. Two things came out of measuring: - `equation()` renders the whole chain into one string by repeated concatenation, so its cost is quadratic in the depth: 83 s under ASan at 100,000 against 32 s at 70,000, for the same evidence. - The claim that the `equation()` case "has no optimiser escape" was too strong. Optimisation shrinks the frames rather than eliminating the recursion: unfixed code returns normally at 50,000 and SIGSEGVs at 60,000 under clang 22 `-O2`, where at `-O0` it SIGSEGVs at 25,000. gcc 16 `-O2` SIGSEGVs already at 40,000. So a 40,000-node case would have passed vacuously in a clang Release leg -- the exact shape of check this repository keeps finding. 70,000 sits above every measured survival depth and keeps the cost down. The destruction case stays at 100,000: it is linear, and the ticket asked for that depth. The comment now carries all five measurements and says plainly that the destruction case is load-bearing in an unoptimised build only, naming the CI legs where that is true. Verified after the change: both cases still SIGSEGV on unfixed headers in the clang-debug suite, and both pass on fixed ones. Refs morph#574. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
The spec and the two header comments carried a single -O0 number and an assertion that equation()'s recursion had no optimiser escape. Both are now the measured table: destruction has no failing depth at all under clang -O2, while equation()'s limit only moves with optimisation (25,000 at clang -O0, 60,000 at clang -O2, 40,000 under gcc 16 -O2). Also corrects the compiler these were measured on -- clang 22.1.8 and gcc 16.2.1, not clang 20. Refs morph#574. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
CI caught this, which is the gate working. `test_check_install_export.sh`'s
"the verified header sets widened back to include detail/" case proves
`INTERFACE_HEADER_SETS_TO_VERIFY HEADERS` is load-bearing by deleting it and
requiring the build to break. It broke because `detail/quantity_equation.hpp`
was not self-contained. This branch made it self-contained, every other
detail/ header already was, and with no broken header left in the tree the
mutation stopped breaking anything:
error: NOT caught: the verified header sets widened back to include detail/
-- the gate passed a tree it should reject
Borrowing a real header's brokenness made that case depend on it staying
broken, so the next person to fix one would have hit this wall too. The
mutation now plants its own `detail/selftest_not_standalone.hpp` -- a header
calling an undeclared `morphSelfTestUndeclaredHelper()` -- and adds it to the
detail/ FILE_SET, so the unmutated property keeps it out of the verified set
and deleting the property pulls it in. Same three needles as before (the
checker's own wording, the header path, the identifier), all still
toolchain-independent. The two paths go on one line because a portable `sed`
replacement cannot insert a newline, and `FILES` accepts that.
The CMakeLists comment justified the exclusion by quantity_equation.hpp being
non-self-contained, which is now false. The rule it encodes is not "these
happen to be broken" but "standalone compilation is a promise about public
headers", so it says that instead, and notes that every detail/ header
compiling standalone today is a convenience rather than the rule.
Also here, two branch-coverage repairs for the same reason
(`include/morph/detail` and `include/morph/render` both carry a 97% branch
floor in scripts/check_branch_coverage.py, measured at 100%):
- `countRefs` and `assignLabels` push children unconditionally and handle null
on pop, as the recursive form did on entry. Guarding the push instead left
`root != nullptr` with an arm nothing could take, since `equation()` checks
for a null root before calling.
- Two locale cases for arms nothing else reaches: a grouping locale whose
entry carries no separator at all, and a non-digit inside one.
Refs morph#574.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This was referenced Sep 19, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #574.
Both defects reproduced first, with the issue's own commands, then fixed, then re-run. The
RationalINT64_MIN work (#537 / PR #561) is deliberately untouched.Part A — the provenance chain
Reproduced
-O0-O2equation()-O0equation()-O2equation()-O2The memory and time figures reproduce too: 54,056 KB / 0.034 s with the default against 12,236 KB / 0.006 s with
MORPH_QUANTITY_PROVENANCE=0, for a 200,000-iteration running total at-O2.The issue flagged
equation()as unverified. It overflows too — gdb puts the crash inEquationRenderer::renderSymbolic, and unlike the destructor it has no-O2escape, only a higher limit. gcc's limit is lower than clang's unoptimised one.After
~ASTNodedetaches its children into a local worklist and unlinks a node's own children only when that pop holds the last reference, so every~ASTNodethe loop reaches has null children and cannot recurse.equation(). All four traversals run over an explicit stack. The symbolic and substituted renderings differed only in how they stop, so they became one stack machine (render, selected by aRenderMode) with a three-stage frame — one traversal to get right instead of two. Output is byte-identical; the existing 62[quantity]/[render]/[locale]cases pass unchanged.equation()'s documented output contract, which is a design decision for the type's owner rather than a bug fix. Now that depth cannot crash, it is a readability question.Fix (1) — the default: not flipped, and this is the finding
docs/spec/util/quantity_type.mdpromises default-on in four places, including a section heading ("Provenance — a build-time toggle, on by default") and the sentence "The default is on: everyQuantityis traceable". The issue's own "what would change the verdict" says to close A(1) if provenance-by-default is a deliberate product decision. The spec says it is, so both readings are recorded in the spec rather than one being picked silently:equation()pays it.0,equation()collapses to the bare value andnamed()discards the name. Flipping the default would silently empty both for every existing build that did not set the macro. And provenance is not an incidental extra: the spec opens by naming it as the third of three things aQuantityknows and the reason to reach for the type at all.The measurement is now in the spec and on the macro, with "a bulk path that never calls
equation()should set this to0" stated outright — so the cost is documented rather than discovered.Per the issue, (2) and (3) landed regardless of (1): a stack overflow is not an acceptable failure mode for either setting, and it is now fixed for both.
Part B — locale group separators
Reproduced, C++ and the QML mirror
The issue flagged the QML mirror as unverified. I lifted
normalizeLocaleNumberout ofsrc/qt/forms/qml/DynamicForm.qmland ran it on the same inputs. It produced byte-identical wrong answers:Consistently wrong is still wrong, so both edges are fixed.
After
A group separator is dropped only where one can legally be: preceded by one to three digits, followed by exactly three, never after the decimal separator. A 47-case differential over both implementations confirms the two edges agree on every input, before and after.
Equal separators are rejected through the return value, not an
assert— deliberately, and against the issue's suggestion. An assertion would make a control edge behave differently in Debug and Release, and would be untestable in the build where it fires; that is the same signature as the Part A defect in this very ticket. Reported like any other malformed entry instead.The
negativeSignparameter the issue mentions in passing is not in this PR: it is an API change to both edges and all their call sites, and it is not what makes the value wrong. Happy to file it separately if wanted.The two traps, addressed
1. The
-O0destruction test. Confirmed to fail on unfixed code before claiming it covers anything — headers reverted toorigin/master, suite rebuilt, run:and the same for the
equation()case at:729. Both pass on fixed headers. The test comment carries all five measured depths and says plainly that the destruction case is load-bearing in an unoptimised build only (vacuous at-O2, where no depth fails at all), naming the CI legs that are-O0: gcc-debug, clang-debug, the three sanitizer legs, cl-debug.The
equation()case is priced rather than copied from the ticket: at 100,000 nodes it costs 83 s under ASan against 32 s at 70,000, for the same evidence —equation()concatenates the whole chain into one string, so its cost is quadratic in depth. 70,000 sits above every measured survival depth (highest: 50,000 under clang-O2), so it is not vacuous in an optimised build. An earlier draft used 40,000 and would have been; that is in the history as its own commit, because it is exactly the failure mode AGENTS.md is about.2. The 18 single-locale cases. All 18 passed with the stripping and with the validation alike. Four new cases:
normalize("1.5", ",", "."), equal separators, group placement, and a control that every well-formed entry still normalises. Three of the four fail on unfixed code (16 assertions); the fourth is the control and passes either way by design. On the QML side,test_foreignDecimalSeparatorIsRejectedNotAbsorbedfails on the unfixed mirror and passes on the fixed one, verified by revertingDynamicForm.qmland rebuilding.Specs and docs
The render spec-sync gate is new as of #576 and does apply here;
scripts/check_spec_sync.shon this branch's file list reports OK.docs/spec/util/quantity_type.md— the depth table, the iterative-walk requirement as a property of the type, the measured provenance cost, and both readings of the default.docs/spec/forms/forms.md, Locale data formatting — grouping validated not stripped, the equal-separator rule with the reason it is not an assertion, and "both edges or neither".examples/ledger/README.md— this was listed as an open ladder finding; it now records the fix and keeps the separatedouble-division display item.CHANGELOG.md— one entry each.Review notes (done inline, no
/code-review)~ASTNodeand thread safety. Theuse_count() == 1check is the only race-sensitive point. If another owner exists the count is ≥ 2 and dropping the handle cannot destroy the node, so nothing is unlinked; if the count is 1 we are the sole owner. Either way the nested~ASTNoderuns with null children. Matches what the spec says about reading a completed derivation from several threads.countRefscounts each node once under aseenset, so worklist order cannot change the counts.assignLabelsis a left-before-right pre-order, so the right child is pushed first and the left popped first.atomRenderingpreserves the original short-circuit order exactly, including that a named node wins over a placeholder in symbolic mode and thatisPlaceholderis never reached for a named node. The proof that this is right is thatequation()'s existing output assertions did not move.groupingIsWellPlaced, and still identical to the QML mirror.detail/quantity_equation.hppwas not self-contained. clang-tidy on the file alone reported 20unknown type name 'ASTNode'errors and a cascade of bogus findings ("methodcountRefscan be made static" — it touches three members), because the header is included fromquantity.hppafterASTNodeexists. Changing lines in it would have lit up the clang-tidy-diff gate. It now includesutil/quantity.hppback, which#pragma oncemakes free; checked withclang++ -fsyntax-only -x c++-headeron the file alone.One gate this change broke, and why the repair is not a workaround
scripts/test_check_install_export.shproves thatINTERFACE_HEADER_SETS_TO_VERIFY HEADERSis load-bearing by deleting it and requiring the build to break. It broke becausedetail/quantity_equation.hppwas not self-contained. Making it self-contained left no broken detail/ header in the tree — every other one already compiled standalone, checked one by one — so the mutation stopped breaking anything and CI said so:That is the self-test doing its job. Borrowing a real header's brokenness made the case depend on it staying broken, so the next person to fix one would have hit the same wall. The mutation now plants its own
detail/selftest_not_standalone.hppand adds it to the detail/ FILE_SET, so the property still has something to keep out and deleting it still pulls something in. Same three needles, all still toolchain-independent. The CMakeLists comment justified the exclusion by that header being broken; it now states the rule it actually encodes — standalone compilation is a promise about public headers — and notes that every detail/ header compiling standalone today is a convenience, not the rule.Verification run locally
morph_tests: 1500 cases, 22,329 assertions, all pass (one expected failure, pre-existing).morph_forms_qml_tests(Qt 6.11.2, offscreen): 268 passed, 0 failed.clang-tidy-diff(clang-tidy 22,origin/master...HEAD): clean.clang-format --dry-run -Werroron every changed C++ file: clean.scripts/check_spec_sync.sh,check_spec_citations.sh,check_catch_test_names.sh,check_automoc_includes.sh: all OK.-DMORPH_BUILD_DOCUMENTATION=ONtargetdoc(FAIL_ON_WARNINGS): builds.scripts/test_check_install_export.sh: all seven cases caught, clean tree passes.llvm-cov export→aggregate_lcov_branches.py→ the floors incheck_branch_coverage.py),morph_testsalone:include/morph/render100.00% (94/94, floor 97),include/morph/detail100.00% (106/106, floor 97),include/morph/util96.77% (360/372, floor 93), no partial lines in render or detail. Two of the new locale cases and the shape of the two iterative walks exist to keep those two at 100 — guarding the child pushes instead of handling null on pop leftroot != nullptrwith an arm nothing could take.Filed rather than folded in
Two parts of #574 are deliberately not in this PR, and since this PR closes #574 they are filed so they are not lost with it:
equation()renders an unbounded derivation in full, quadratically. util/render: two reproduced value-type defects that ship by default — Quantity provenance chains and unvalidated locale group-separator stripping #574's fix (4), the depth cap, plus the measurement it did not have: 83 s and a 500,001-character line at 100,000 nodes. It is no longer a crash, which is why it is a separate ticket rather than part of this fix.normalizeLocaleNumbermatches the minus sign as a literal'-'byte, so a U+2212 locale cannot round-trip. util/render: two reproduced value-type defects that ship by default — Quantity provenance chains and unvalidated locale group-separator stripping #574's other "smaller fix while there". It rejects a valid entry rather than mis-converting one, which makes it a different and smaller problem than the group separator was, and an API change to both edges.The one other adjacent defect I met (
doubledivision in the shipped forms renderer's money display) was already recorded inexamples/ledger/README.md, and that record is kept.🤖 Generated with Claude Code
https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6