Skip to content

fix(expr): keep the live memory footprint accurate when errors are absorbed - #417

Merged
mwiebe merged 1 commit into
OpenJobDescription:mainfrom
mwiebe:fix/sound-absorption
Sep 29, 2026
Merged

mwiebe merged 1 commit into
OpenJobDescription:mainfrom
mwiebe:fix/sound-absorption

Conversation

@mwiebe

@mwiebe mwiebe commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

What was the problem/requirement? (What/Why)

The expression evaluator enforces a memory limit by tracking a live
footprint: current_memory is what is held right now, and every new
allocation 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.* and
Task.* are unresolved. Each continued on an evaluator whose footprint
no longer matched what was held.

One path under-counted, 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 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_span released a call's operands
    only on success. A failed operator's operands stayed charged.
  • eval_ifexp never released its branch values when the test was
    unresolved, even though the result carries only a type.
  • eval_boolop never released operands its result did not carry.
  • eval_list tracked each element and then tracked the list built from
    them 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_compare hands its operands to dispatch outright. When the
    chain 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_node and dispatch_with_span share a new
    dispatch_released, which releases the operands on both paths before
    the error is positioned. eval_call already did this for function
    arguments.
  • A new eval_speculative helper 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; peak
    memory and the operation count keep what the attempt consumed.
  • eval_ifexp releases every branch value. eval_boolop releases the
    unresolved placeholder, any concrete operand replaced by the next, and
    any speculative operand that did not decide the result.
  • eval_list releases the elements as they are consumed into the list,
    before the construction check.

What is the impact of this change?

  • Expressions that absorb a failed sub-expression are no longer charged
    for values that were discarded, so a lowered evaluation budget no
    longer rejects valid expressions for memory that is not live.
  • Comparisons can no longer let an expression exceed the memory limit.
  • One behaviour change: a budget error in the if-branch of an
    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.
  • Two pinned memory-exceedance diagnostics change by exactly the
    released element trackers of a three-element Int list literal
    (192 bytes): [1, 2, 3] * 10000000 reports 1920000384 instead of
    1920000576, and a comprehension over [1, 2, 3] reports 1800576
    instead of 1800768. The reported figures are the live footprint at the
    point of exceedance; nothing parses them.
  • No public API changes. Speculative, eval_speculative and
    dispatch_released are private.

How was this change tested?

  • Have you run the unit tests? Yes. openjd-expr 3,477 integration
    • 374 unit; openjd-model 1,792 + 372; openjd-cli 154. Full
      workspace cargo clippy --all-features --all-targets -- -D warnings
      and cargo fmt --check clean.
  • Full OpenJD conformance suite: 1139/1139 (Windows).
  • Nine new tests pin the releases (each if/else arm, both branches
    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.
  • Each pinned byte figure in the tests is derived in a comment
    alongside it.
  • An independent review of an earlier revision of this branch found the
    eval_compare double release, which is fixed here; its other findings
    are pre-existing and out of scope (a coercion-size mismatch in
    eval_node, eval_attribute converting budget errors into value
    errors, and untracked slice-bound placeholders in eval_subscript).

Was this change documented?

  • Are relevant docstrings in the code base updated? Yes.
    dispatch_released, eval_speculative and the Speculative enum
    carry rationale docs; the changed comprehension, comparison, boolop
    and list paths have updated inline comments.
  • specs/expr/evaluator.md gains a "Speculative evaluation" section
    and updates the BoolOp, IfExp, Compare, List and Dispatch Flow
    sections to match. specs/expr/public-api.md needs 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?

  • Does the change need to be threat modeled? No. The change makes
    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.

…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>
@mwiebe
mwiebe requested a review from a team as a code owner September 29, 2026 00:54
@mwiebe
mwiebe merged commit 00325a1 into OpenJobDescription:main Sep 29, 2026
23 checks passed
@mwiebe
mwiebe deleted the fix/sound-absorption branch September 29, 2026 22:26
@github-actions github-actions Bot mentioned this pull request Sep 28, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants