fix(expr): keep the live memory footprint accurate when errors are absorbed - #417
Merged
Merged
Conversation
…sorbed The evaluator tracks two kinds of resource counters: the live footprint (current_memory, which each new allocation is checked against) and the cumulative spend (peak_memory and operation_count, which are never refunded). Several code paths left the live footprint wrong after a value was discarded or a sub-expression failed. When the error propagated to the top this did not matter, because the counters were never read again. But two constructs absorb value errors and keep evaluating: an if/else whose test is unresolved evaluates both branches, and an and/or chain evaluates operands after an unresolved one. Each continued on an evaluator whose footprint no longer matched what was held. This fixes those paths and adds one helper that every absorbing construct uses. Under-counting (footprint too low, so an expression could exceed the memory limit): - eval_compare passed clones of its operands to dispatch, which releases what it is given, and then released the originals too. Every comparison subtracted its operands twice, and the excess came out of whatever the enclosing expression still held. With a 1 MB string held and a comparison of two 200 KB strings, the footprint dropped by 400 KB more than was freed, and a 1.7 MB expression passed a 1.5 MB limit. Each link now hands its operands to dispatch and, when the chain continues, clones the right operand first and tracks the clone as the carried value. Over-counting (footprint too high, so valid expressions failed): - dispatch_with_node and dispatch_with_span released a call's operands only on success. A failed operator's operands stayed charged. Both now share dispatch_released, which releases on either path. eval_call already did this for function arguments. - eval_ifexp never released the branch values when its test was unresolved. The result is an Unresolved carrying only a type, so every branch value is now released: the surviving branch when one fails, both when both succeed. - eval_boolop never released operands its result did not carry: the unresolved placeholder, a concrete operand replaced by the next, or a speculative operand that did not decide the result. All are released. - eval_list tracked each element and then tracked the list built from them without releasing the elements, so a literal was charged for the list and every element again, in its own construction check and for as long as it was held. One 600 KB element read as 1.2 MB. Elements are released as they are consumed into the list. New helper eval_speculative is the one place the two absorption rules live, used by eval_ifexp's branches and eval_boolop's post-unresolved operands. A budget error (directly or inside a compound error) propagates immediately; a value error is absorbed and the live footprint is reset to what it was before the attempt, while peak memory and the operation count keep what the attempt consumed. One behaviour change: a budget error in the if-branch now propagates before the else-branch is evaluated. Previously both branches always ran, and a budget failure paired with a value failure surfaced inside a "Both branches fail" compound error; now the budget error surfaces as itself. Two pinned diagnostics move by exactly the released element trackers of a three-element Int list literal (192 bytes): `[1, 2, 3] * 10000000` 1920000576 -> 1920000384, and a comprehension over `[1, 2, 3]` 1800768 -> 1800576. One test that asserted the compound error is rewritten to assert the fail-fast behaviour, and one byte-count expectation is updated for the released boolop placeholder. Nine new tests pin the releases and each fails on the previous code; a tenth guards the comparison chain against a leak. specs/expr/evaluator.md gains a Speculative evaluation section and updates BoolOp, IfExp, Compare, List and Dispatch Flow to match. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
AlexTranAmz
approved these changes
Sep 29, 2026
Merged
mwiebe
added a commit
to mwiebe/openjd-rs
that referenced
this pull request
Sep 30, 2026
Cross-cutting design for openjd-expr, openjd-model, and openjd-sessions covering the resolved-value constraint gates (template validation, job creation, run time), the lower-bound rule for partially-static format strings, the CallerLimits / SessionLimits configuration surface, and the follow-up log through PRs OpenJobDescription#373, OpenJobDescription#383, OpenJobDescription#397, OpenJobDescription#399, OpenJobDescription#404, OpenJobDescription#407, OpenJobDescription#409, OpenJobDescription#410, and OpenJobDescription#417.
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.
What was the problem/requirement? (What/Why)
The expression evaluator enforces a memory limit by tracking a live
footprint:
current_memoryis what is held right now, and every newallocation is checked against it. It separately tracks cumulative spend
(
peak_memory,operation_count), which is never refunded.Several code paths left the live footprint wrong after a value was
discarded or a sub-expression failed. When an error propagates to the
top of the expression this is harmless, because the counters are never
read again. But two constructs absorb value errors and keep evaluating:
an if/else whose test is unresolved evaluates both branches, and an
and/or chain evaluates the operands after an unresolved one. Both are
common in templates evaluated at job creation, where
Session.*andTask.*are unresolved. Each continued on an evaluator whose footprintno longer matched what was held.
One path under-counted, so an expression could exceed the memory
limit:
eval_comparepassed clones of its operands to dispatch, whichreleases what it is given, and then released the originals as well.
Every comparison subtracted its operands twice; the excess came out of
whatever the enclosing expression still held. With a 1 MB string held
and a comparison of two 200 KB strings, the footprint dropped 400 KB
more than was freed, and a 1.7 MB expression passed a 1.5 MB limit.
The others over-counted, so valid expressions could fail:
dispatch_with_node/dispatch_with_spanreleased a call's operandsonly on success. A failed operator's operands stayed charged.
eval_ifexpnever released its branch values when the test wasunresolved, even though the result carries only a type.
eval_boolopnever released operands its result did not carry.eval_listtracked each element and then tracked the list built fromthem without releasing the elements. A literal with one 600 KB element
read as 1.2 MB, in its own construction check and for as long as it
was held.
What was the solution? (How)
eval_comparehands its operands to dispatch outright. When thechain continues, the right operand is cloned first and the clone is
tracked as the value carried into the next link, so each operand is
charged once and released by the link that consumes it.
dispatch_with_nodeanddispatch_with_spanshare a newdispatch_released, which releases the operands on both paths beforethe error is positioned.
eval_callalready did this for functionarguments.
eval_speculativehelper is the one place the two absorptionrules live, used by
eval_ifexp's branches andeval_boolop'spost-unresolved operands. A budget error (directly or inside a
compound error) propagates immediately. A value error is absorbed and
the live footprint is reset to what it was before the attempt; peak
memory and the operation count keep what the attempt consumed.
eval_ifexpreleases every branch value.eval_boolopreleases theunresolved placeholder, any concrete operand replaced by the next, and
any speculative operand that did not decide the result.
eval_listreleases the elements as they are consumed into the list,before the construction check.
What is the impact of this change?
for values that were discarded, so a lowered evaluation budget no
longer rejects valid expressions for memory that is not live.
unresolved-test conditional now propagates before the else-branch is
evaluated. Previously both branches always ran, and a budget failure
paired with a value failure surfaced inside a "Both branches fail"
compound error. Now the budget error surfaces as itself.
released element trackers of a three-element
Intlist literal(192 bytes):
[1, 2, 3] * 10000000reports 1920000384 instead of1920000576, and a comprehension over
[1, 2, 3]reports 1800576instead of 1800768. The reported figures are the live footprint at the
point of exceedance; nothing parses them.
Speculative,eval_speculativeanddispatch_releasedare private.How was this change tested?
openjd-expr3,477 integrationopenjd-model1,792 + 372;openjd-cli154. Fullworkspace
cargo clippy --all-features --all-targets -- -D warningsand
cargo fmt --checkclean.succeeding, a failed operator absorbed by a boolop and by a
conditional, non-deciding boolop operands, a single-charged list
literal, a comparison holding a large value, and fail-fast on a budget
error in the if-branch). Each was run against the previous code and
fails there. A tenth guards the comparison chain against a leak.
alongside it.
eval_comparedouble release, which is fixed here; its other findingsare pre-existing and out of scope (a coercion-size mismatch in
eval_node,eval_attributeconverting budget errors into valueerrors, and untracked slice-bound placeholders in
eval_subscript).Was this change documented?
dispatch_released,eval_speculativeand theSpeculativeenumcarry rationale docs; the changed comprehension, comparison, boolop
and list paths have updated inline comments.
specs/expr/evaluator.mdgains a "Speculative evaluation" sectionand updates the BoolOp, IfExp, Compare, List and Dispatch Flow
sections to match.
specs/expr/public-api.mdneeds no change.Is this a breaking change?
No. No public signatures change. The observable differences are the
error message shape for a budget exceedance in an if-branch (the budget
error itself rather than a compound wrapping it) and a small change in
two reported byte figures. Neither is a contract callers parse.
Does this change impact security?
the evaluation memory limit hold in a case where it previously did not
(the comparison double release let an expression exceed it), and
otherwise makes the limit's accounting more accurate. It creates or
modifies no files or directories.
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.