Skip to content

util/render: two reproduced value-type defects that ship by default — Quantity provenance chains and unvalidated locale group-separator stripping #574

Description

@Yaraslaut

Consolidates #538 (F20) and #539 (F21) from the #518 sweep. Both are closed in favour of this one. Both are reproduced, both are defects in shipped behaviour, and both are small enough to land in one PR.

They are merged because they are the same architectural mistake in two value types: the type silently produces a result instead of reporting a fact to the layer that owns the policy — invariant 3 of the triage skill. One returns a wrong number, the other returns an unbounded structure. Neither gives the caller a way to find out.

Part A — Quantity provenance is on by default: 5.5x slower, 6.9x memory, stack overflow in Debug

MORPH_QUANTITY_PROVENANCE defaults to 1 (quantity.hpp:38-40). Every leaf construction and every binary operation allocates an ASTNode (sizeof == 200) holding shared_ptrs to its operands (quantity.hpp:833-839), so the ordinary running-total pattern builds a linear chain that is never collapsed:

Quantity<eur> total{Rational{0, DecimalPlaces{2}}};
for (int i = 0; i < n; ++i) total = total + one;    // n retained ASTNodes

Reproduced. Revision: master @ 4017228d. n = 200,000:

build max RSS wall
MORPH_QUANTITY_PROVENANCE=1 (default), -O2 54,156 KB 0.033 s
MORPH_QUANTITY_PROVENANCE=0, -O2 7,884 KB 0.006 s

Destruction is recursive (~shared_ptr -> ~ASTNode -> ~shared_ptr -> ...), and at -O0 it segfaults:

$ ./prov0 200000
total = 200000EUR (chain of 200000 provenance nodes retained)
destroying...
$ echo $?
139                 # SIGSEGV, stack overflow in the destructor chain

At -O2 clang tail-call-optimises the chain away, so this crashes in Debug and survives in Release — the worst possible signature for finding it.

Not verified: the exact depth at which the Debug crash begins was not determined, nor whether equation() (quantity_equation.hpp:91-110, :163-210) overflows at the same depth. It recurses over the same DAG, so it should, but it was not measured.

Why it matters: 5.5x slower and 6.9x the memory for a loop that adds integers, by default, in a framework whose Quantity sits in model state and on the wire path. If the loop bound comes from wire input — a ledger replay, a batch of rows — this is a remote memory-exhaustion and crash vector.

Fix, in order of value

  1. Flip the default to 0. Provenance is a debugging/explanation feature.
  2. Make node destruction iterative — a ~ASTNode that walks the left spine into a local worklist and releases it flat — so depth cannot overflow the stack regardless of the toggle. This is the standard fix for a shared_ptr list and should land even if (1) is rejected.
  3. Make equation()'s traversal iterative for the same reason.
  4. Consider a depth cap: past N nodes, collapse to a named leaf holding the value. An explanation 200,000 steps deep is not an explanation.

Part B — normalizeLocaleNumber strips group separators without validation: "1.5" becomes 15

render/locale_format.hpp:71-74 strips every occurrence of groupSeparator unconditionally, before the decimal check and with no check that groups are three digits or that the separator appears before the decimal point.

Reproduced. Revision: master @ 4017228d.

normalize("1.5",     dec=",", grp=".")  = "15"     <-- de-DE form, US-style decimal typed
normalize("1.50",    dec=",", grp=".")  = "150"
normalize("1.2.3.4", dec=",", grp=".")  = "1234"
normalize("1,5",     dec=".", grp=",")  = "15"     <-- en-US form, EU-style decimal typed
normalize("1.5",     dec=".", grp=".")  = "15"     <-- separators equal, no diagnostic
normalize("-5" with U+2212 MINUS)       = NULLOPT

Not verified: whether the QML mirror in src/qt/forms/qml/DynamicForm.qml behaves identically. Check this as part of the fix — if it does, the two edges are consistently wrong rather than inconsistently, which is a smaller problem but still a wrong value, and both need fixing.

Why it is a defect and not a design choice: a German user typing 1.5 into a price field submits 15. This is the single most common real-world locale entry mistake, and the function converts it into a valid-looking value rather than rejecting it — so nothing downstream can catch it. The user is charged ten times with no diagnostic anywhere.

Fix

Validate group placement before stripping: a group separator must be preceded by 1-3 digits, followed by exactly three digits, and must not appear after the decimal separator. "1.5" then correctly returns std::nullopt and the caller can tell the user to fix the entry. Two smaller fixes while there:

  • assert(decimalSeparator != groupSeparator) — today the decimal is silently eaten.
  • A negativeSign parameter, so a locale using U+2212 MINUS can round-trip.

Note on scope

#537 (INT64_MIN UB in four Rational operations) is the third defect of this family from the same sweep. It is deliberately excluded — PR #561 is already open for it. Do not touch rational.hpp in this PR; rebase on #561 if it lands first.

What would change the verdict

  • Close A(1) if provenance-by-default is a deliberate product decision — but A(2) and A(3) should land regardless, since a stack overflow is not an acceptable failure mode for either setting.
  • Close B if the function is documented as accepting only already-canonical input — but its whole purpose is to normalise user-typed text at the control edge.

Acceptance — and the test that actually tests

Per AGENTS.md: a check that would still pass with the feature removed is not evidence. Both of these have a specific trap:

  • A: build a chain of 100,000 nodes and destroy it at -O0. A test at -O2 passes with or without the fix, because clang tail-call-optimises the chain away. Confirm the test fails on unfixed code before claiming it covers anything.
  • B: tests/test_render_locale_format.cpp has 18 cases and none of them is normalize("1.5", ",", "."). Add it, plus the equal-separators case. A single-locale test passes either way.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: utilSubsystem: utilbugSomething isn't workingtriage: validWell-framed; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions