feat!: require create_job's context to cover the template's extensions - #407
Conversation
|
This PR makes |
Regression: strict eval errors at
|
base 70cd40b |
this PR f0c5601 |
|
|---|---|---|
openjd check |
passes | passes |
openjd summary -p Files=a,b,c (runs create_job) |
succeeds | fails |
openjd run -p Files=a,b,c --step Render |
succeeds, prints a c |
blocked by create_job |
ERROR: Model validation error: 1 validation error for JobTemplate
steps[0] -> script -> actions -> onRun -> args[0]:
Failed to parse interpolation expression at [0, 65]. List comprehension filter must be a boolean, got unresolved[bool]
[f for f in Param.Files.split(',') if f != Task.Param.Skip]
^~~~~~~~~~~~~~~~~~~~
Mechanism
eval_listcomp has two paths and they disagree about Unresolved filters.
The unresolved-iterable path accepts one explicitly — is_bool_compatible tests
cond_inner.code() == TypeCode::Unresolved alongside BOOL, Any and a BOOL-containing
union. The concrete-iterable path does not: it requires ExprValue::Bool and errors otherwise.
Which path runs depends on the iterable, and the iterable's concreteness changes per stage:
- pass 8:
Param.FilesisUnresolved, so the iterable is unresolved, tolerant path,Ok. create_job:Param.Filesis bound andTask.Param.Skipis not, so the iterable is concrete
and the filter isunresolved[bool]. Strict path,Err.- run time: everything is bound, filter is a concrete bool,
Ok.
So the failure is specific to a symbol-table state that exists at no other stage — Param.*
concrete, Task.*/Session.* unresolved. The error kind is Other(..), which is why the
report_eval_errors early-return this PR deletes was swallowing it. The defect predates the PR;
this PR makes it reachable.
eval_boolop already takes the position I think eval_listcomp should take here, and
specs/expr/evaluator.md states it: "Errors in operands past the unresolved one are suppressed,
since a runtime short-circuit could make them unreachable." eval_ifexp says the same thing one
section down. eval_listcomp's concrete-iterable filter is the one place an unresolved value
produces a hard error instead of an Unresolved result.
This falsifies one of the new spec sentences
specs/model/job-creation.md, under Error policy:
so an evaluation error at this stage cannot be a context artifact: it is either a template
defect pass 8 missed or a deterministic value-dependent failure that every session resolving
the field would hit.
The repro above is a third thing. Not a template defect — pass 8 accepts it, correctly, because at
run time the filter is a concrete bool. Not a failure every session would hit — every session
resolves it fine. It is an artifact of partial resolution, an axis the context contract does not
address.
I would not fix this by rewording the sentence alone. That leaves a template that decodes, would
run, and is rejected at submission. The narrow code fix: on the concrete-iterable path, when the
filter evaluates to Unresolved, abandon the accumulated result and return
unresolved(list[body_type]) — per-element inclusion is undecidable, so the whole comprehension is
unknown, which is what the unresolved-iterable path already concludes via
unresolved_list_from_elements. Around ten lines, plus keeping absorb_counters and the
regex_cache restore on the new exit path. Then the sentence holds, with the caveat that it is
asserting an exhaustive disjunction and a future partial-resolution asymmetry would falsify it
again.
Scope, honestly
I looked for how often this triggers and found nothing. Across the repo's fixtures, tests and spec
examples, deadline-cloud-samples, and openjd-specifications, there are nine distinct
comprehension-with-filter forms and all nine filter on the loop variable or on Param.*, both
concrete at job creation. None references Task.* or Session.*. So this is a verified regression
with no template on hand that trips it.
What makes me raise it anyway is the spec sentence rather than the reachability. A documented
invariant that is false is what stops the next reader checking.
The other finding is separate
eval_boolop did not get the budget exemption eval_ifexp got, so the bypass survives behind an or. Split out into its own comment: #407 (comment)
Verified and not verified
Verified at f0c5601: cargo fmt --check clean, cargo clippy --all-features --all-targets --workspace -- -D warnings clean, workspace suite green. Both items above reproduced by running
code, base against tip.
Not verified: anything on Windows or macOS, so I could not exercise the path-format fix — note its
test is a no-op on Linux and macOS, since PathFormat::host() is Posix there. Reverting
path_format to host() leaves that test green on two of the three CI lanes. It does fail on
windows-latest, which the matrix runs, so the fix is pinned in CI but not on any Linux dev
machine. I also did not re-run the conformance suite or the mutation checks.
|
| expression | result |
|---|---|
'A' * 10000000 if Session.Flag else 'B' |
propagates — the arm this PR tests |
'B' if Session.Flag else 'A' * 10000000 |
propagates — mirror arm, no test |
Session.Flag or ('A' * 10000000 == 'x') |
absorbed, peak_memory 264 |
Session.Flag and ('A' * 10000000 == 'x') |
absorbed |
Session.Flag or (('A' * 10000000 if Session.Flag else 'B') == 'x') |
absorbed |
int('nope') if Session.Flag else 7 |
absorbed — correct, the control |
Why
eval_boolop's suppression arm is
Err(_) => { /* suppressed — unresolved might short-circuit */ }and specs/expr/evaluator.md gives the reason: "Errors in operands past the unresolved one are
suppressed, since a runtime short-circuit could make them unreachable." That holds for a value
error and not for a budget, for exactly the reason contains_budget_error's new doc comment gives
for IfExp — the memory and operations were spent in this evaluation no matter which operand run
time would reach. peak_memory reporting 264 bytes after a 10 MB allocation is the same accounting
gap seen from the other side.
Three lines: the same contains_budget_error check in that arm. Worth doing here because the PR
description states budgets can no longer be bypassed via unresolved-test conditionals, and a test
for it would also close the (Ok(b), Err(oe)) gap above — both existing budget tests put the
failure in the if-branch, so deleting that arm's early return leaves the suite green.
Separately in the same area, eval_listcomp returns child.eval_node(..)? before
self.absorb_counters(&child), so a failing comprehension's child-evaluator spend is discarded —
the same "cost already spent" leak, for the case eval_ifexp deliberately absorbs. Not part of
this PR's scope; noting it so it is written down somewhere.
create_job documented that passing a ValidationContext whose
extensions differ from the template's declared ones is supported
application-level policy (e.g. "strip EXPR even if the template
requests it"). That contract forced the job-creation resolved-value
checks into a lenient error policy: an evaluation error could be a
context artifact rather than a template defect, so all evaluation
errors except budget exceedances were silently skipped - which in turn
required budget-kind special-casing and left "field errored and was
skipped" indistinguishable from "field checked and passed".
The contract is enforced instead: the context's revision must match
the template's, and its extensions must cover every extension the
template declares (enabling more is allowed) - a Compatibility error
otherwise. An application that does not support an extension already
rejects the template at decode via supported_extensions, and no
production caller diverges (CLI, for-js, and the tests all derive the
context from the template). With the ambiguity gone, the job-creation
checks report evaluation errors exactly as template validation's pass 8
does, deleting the lenient policy and the special-casing outright.
The strictness surfaced two latent defects, both fixed:
- The evaluator absorbed a failing branch of an unresolved-test
conditional into an Unresolved success even when the failure was a
budget exceedance, so a lowered evaluation budget silently stopped
applying inside conditionals (e.g. "{{ 'A' * Param.N if
Session.HasPathMappingRules else 'B' }}" with huge N was accepted).
Budget errors now propagate out of the absorption; value errors are
still absorbed, since run time may select the healthy branch, but
the budget was spent in this evaluation either way.
- The job-creation checks evaluated under the host path format while
create_job builds its check symtabs under PathFormat::Posix (the
uniform format for everything it resolves, including the let
bindings seeded into those symtabs), so Posix path values drew
"Path format mismatch" errors on Windows - previously masked by the
lenient policy, exposed by 11 conformance failures under the strict
one. The checks now evaluate under Posix to match their symtabs.
Also: the check-symtab seeding no longer discards SymbolTable::set
results - a failed seed silently degraded a check to a no-op. Failures
propagate as ModelError (never a panic), matching how create_job
already handles its other symtab.set calls; the seed keys are
uppercase-rooted and let-binding names must start lowercase, so this
only fails if an internal invariant is broken.
Verification: clippy clean; expr/model/cli suites green including new
tests (contract rejection, value-dependent evaluation errors reported
at create_job, budget propagation out of unresolved conditionals at
both expr and model level, path-format agreement); conformance
1139/1139.
BREAKING CHANGE: create_job returns a Compatibility error when the
context's revision differs from the template's or its extensions do
not cover the template's declared extensions. Callers deliberately
stripping extensions at job creation must instead reject the template
at decode via supported_extensions.
Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
f0c5601 to
3198c91
Compare
The review's two code findings (eval_listcomp unresolved-filter regression, eval_boolop budget suppression) were fixed on the PR branch; these record what remains: the listcomp child-counter discard on error paths, an audit of the remaining operators for incomplete Unresolved propagation, and the rule that any future error-absorption site must carry the budget exemption.
Post-review follow-ups to the create_job profile-contract change: - The CLI (run, summary) and openjd-for-js each hand-rolled the ValidationContext for create_job - re-parsing the template's extensions list with a hardcoded 2023-09 revision. Now that create_job enforces its context contract, three drift-prone copies become three calls to job_template.default_validation_context(). - The CLI decoded templates under common::caller_limits() (the 32K resolved-arg cap and eval budgets) but ran create_job with default limits - exactly the trap the default_validation_context() docs warn about. Both CLI call sites now layer the same limits onto the create_job context, so a param-dependent arg blowup fails at job creation instead of mid-session. Sessions already enforce the same caps at run time, so this moves detection earlier without adding a new restriction. specs/cli/run.md's enforcing-stages paragraph now covers all three stages. - value_dependent_evaluation_error_fails_at_create_job asserted only the field path; it now pins the full diagnostic (message, expression, caret) per the repo's error-message test standard. Verification: clippy clean (cli, for-js, model); model and cli suites green; conformance 1139/1139 with the release CLI; openjd-for-js checks clean on wasm32-unknown-unknown. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Everything the model evaluates outside host context runs under
PathFormat::Posix - that was already true of every evaluation
create_job performs, but pass 8 was the outlier: its format strings
evaluated under the host format, and validate_let_bindings under the
evaluator's default (also host), while the step-level let bindings
feeding range expressions hardcoded Posix. The mixture was masked
because each pass-8 site happened to agree with the symtab values it
read, but the agreement was incidental - and the FsEval::path_format
doc justified host() with 'pass 8's symtabs hold no concrete path
values', which is wrong (a static let binding like
out = path('/x') puts a concrete host-format path value in the
pass-8 symtab).
Pass 8 now evaluates under Posix everywhere: FsEval applies it in
both options() (format strings) and budgeted() (let bindings), which
also makes the range-let-binding site's explicit override redundant.
With the strict-error-policy split gone (d2e09ea) the path format was
the last difference between FsEval::new and FsEval::for_job_creation,
so the field and the second constructor are deleted outright - pass 8
and the job-creation re-checks now construct identically.
Besides consistency with job creation, this makes validation outcomes
independent of the OS running them: a template accepted by check on
Linux is accepted on Windows and vice versa. Documented as a Path
Format section in specs/model/validation.md.
Verification: clippy clean; model suites green; conformance 1139/1139
on Windows - the platform where host and POSIX formats differ.
Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
…gation Two review follow-ups from the create_job profile-contract change, both comments only: - create_job's revision-mismatch arm cannot fire while V2023_09 is the only SpecificationRevision variant, and no test can pin its error message until a second one exists. Say so at the check, so revision coverage doesn't silently go missing when one is added. - contains_budget_error propagates a compound error whole when a budget exceedance sits among value sub-errors, rather than trimming to just the budget error. Document that as deliberate - the caller gets the failing branch's full diagnostic context. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
…lement
Review of the strict create_job error policy found a regression: a
comprehension over a concrete iterable whose filter condition
evaluates to an unresolved value hard-errored ("List comprehension
filter must be a boolean, got unresolved[bool]") instead of
propagating Unresolved. The defect predates the strict policy - the
lenient policy's silent skip masked it - but strictness made it
reachable: job creation evaluates under a symbol state no other stage
sees (Param.* concrete, Task.*/Session.* unresolved), so a template
with
args: ["{{ [f for f in Param.Files.split(',') if f != Task.Param.Skip] }}"]
passed openjd check (iterable unresolved - the tolerant path), ran
cleanly on workers (everything bound), and was rejected at create_job
- the one stage where the iterable is concrete and the filter is not.
eval_listcomp's two paths now agree, via a shared helper: when a
filter condition evaluates unresolved on a concrete element,
per-element inclusion is undecidable, so the accumulated elements are
abandoned (BudgetedVec only pre-checks the budget; the list is
tracked only by make_list_checked, which is never reached) and the
comprehension concludes unresolved(list[body_type]). The body type is
derived under an *unresolved* loop variable: evaluating the body on a
concrete element the runtime filter may exclude could raise a
spurious value error (e.g. [10 // x for x in [0, 2] if x > Task.N]).
A filter whose type can never be a boolean still errors on both
paths.
Also from the same review: eval_boolop suppressed *all* errors in
operands after an unresolved one, including budget exceedances - the
same bypass eval_ifexp had. Budget errors now propagate; value errors
are still suppressed (a runtime short-circuit may skip them).
The job-creation spec's claim that every evaluation error at this
stage is a template defect or a deterministic value-dependent failure
was falsified by the repro; it now states its real dependency - the
evaluator propagating Unresolved without error through every operator.
Verification: clippy clean; expr/model suites green including seven
new tests (unresolved filter over concrete list and range iterables,
body value-error shielding, non-bool unresolved filter still refused,
concrete-then-unresolved short-circuit filters, boolop budget
propagation + suppression control, and the reviewer's template
through create_job); the repro passes check/summary/run end to end;
conformance 1139/1139.
Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
The boolop budget fix covered the or-case with one test; the review's probe matrix identified three untested cases, now pinned: - The else-branch (Ok, Err) arm of eval_ifexp - both existing budget tests put the failure in the if-branch, so deleting that arm's early return left the suite green. - 'and' shares eval_boolop's suppression arm with 'or'. - A boolop wrapping an unresolved-test conditional must not re-absorb the budget error the conditional just propagated - the exemptions compose. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Range-scope analogue of the job-creation path-format test, from an earlier review round: step-level let bindings evaluate into the range symbol table under PathFormat::Posix, and before the uniform-POSIX change the range format strings reading them evaluated under the host format - a path-valued binding referenced from a parameterSpace range drew 'Path format mismatch' on Windows. Verified against the pre-fix code on Windows: fails there, passes now. A no-op on Linux/macOS (Posix == host); the Windows CI lane exercises it. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
…naries The job-creation error-policy text promised budget propagation only 'from inside an unresolved-test conditional' - written when IfExp was the only absorption site with the exemption. With eval_boolop carrying the same guard, state the guarantee in terms of the class (every construct that absorbs errors under an unresolved operand) and cite both IfExp and BoolOp, so the next absorption site added without the exemption contradicts the spec instead of hiding behind it. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
3198c91 to
f39387d
Compare
| err.kind(), | ||
| crate::error::ExpressionErrorKind::MemoryLimitExceeded { .. } | ||
| | crate::error::ExpressionErrorKind::OperationLimitExceeded { .. } | ||
| ) || err.sub_errors().iter().any(contains_budget_error) |
There was a problem hiding this comment.
Nit, not blocking — ship it.
The sub_errors() recursion here is load-bearing but has no test. I found it by
mutation: deleting the || err.sub_errors().iter().any(contains_budget_error) clause
leaves the entire workspace suite green, 7785 tests, including the five budget
tests added in f0152a5.
It is not dead code, and I want to be precise about that. The one construct that
attaches sub-errors is eval_ifexp's (Err, Err) arm, and a compound from there does
flow through a boolop. Measured, 1 MiB budget, Session.Flag unresolved:
Session.Flag or ('A' * 10000000 if Session.Flag else int('nope')) == 'x'
| result | |
|---|---|
| as shipped | Err — "Both branches fail in the if/else: …" propagates |
| recursion clause deleted | Ok(unresolved) — absorbed, budget silently bypassed |
So the clause is exactly the fix for a real bypass, it just has no falsifying test.
The new boolop_does_not_reabsorb_budget_error_from_nested_conditional does not reach
it: its inner ifexp takes the (Err, Ok) arm, which returns a bare
MemoryLimitExceeded with no sub-errors, so the matches! arm answers first.
Suggestion, whenever convenient: the expression above as a test next to that one. The
risk otherwise is a future refactor deleting the clause with a green suite, which
reopens the bypass for exactly the compound case the new doc comment above describes.
One related gap for the backlog rather than this PR. specs/model/job-creation.md now
says budget errors "propagate out of every construct that absorbs errors under an
unresolved operand". eval_attribute can still defeat that: its dotted-path arm
rewrites a failed base evaluation as UndefinedVariable, and its property arm
discards the original into a fresh error, neither attaching sub-errors — so a budget
error raised inside either is invisible to contains_budget_error and gets absorbed
by an enclosing or. I have not constructed a reaching expression, so treat it as
plausible rather than confirmed. Worth either narrowing the spec sentence or attaching
sub-errors at those two sites.
For the record on the rest of the revision: both of my earlier findings are fixed and
I mutation-checked both. Reverting else if cond.is_unresolved() in eval_listcomp
fails five expr tests plus listcomp_with_task_param_filter_over_bound_param_passes_create_job;
reverting the contains_budget_error early return in eval_boolop fails three
test_memory tests. cargo fmt, clippy -D warnings and cargo test --workspace
(7785 passed, 0 failed) all clean at f39387d2.
The listcomp fix is also better-shaped than what I proposed. Re-deriving the body type
under an unresolved loop variable, rather than mirroring is_bool_compatible in place,
is what keeps [10 // x for x in [0, 2] if x > N] from raising at job creation — my
version would have got that wrong.
What was the problem/requirement? (What/Why)
Background: templates are validated against a
ValidationContext—the spec revision, the enabled extensions (like
EXPR, the expressionlanguage), and any caller limits.
create_jobtakes the same contextwhen it instantiates a job from a validated template. Since #404 it
also re-checks the template's carried-forward strings (action
command/args, environment variables, embedded-filedata) withthe real parameter values bound, catching value-dependent failures at
submission instead of on every worker.
create_jobdocumented that passing a context whose extensionsdiffer from what the template declares is supported policy — e.g.
"strip EXPR even if the template requests it". That one allowance
poisoned the new checks: with a mismatched context, an evaluation
error could mean either "the template is broken" or "the context
turned a feature off", so the checks had to silently skip all
evaluation errors except budget exceedances. Silent skips are
unobservable — a field that errored looks identical to a field that
passed — and the special-casing this required was itself buggy.
What was the solution? (How)
Remove the allowance and enforce the coherent contract: the context's
revision must match the template's, and its extensions must cover
everything the template declares (more is fine; fewer is a
Compatibilityerror). An application that doesn't support anextension already rejects such templates at decode via
supported_extensions— no production caller passes a mismatchedcontext.
With mismatch impossible, every evaluation error at job creation is a
real defect, so the checks now report them all, and the skip machinery
is deleted. That strictness immediately surfaced two latent bugs it
had been masking, both fixed here:
x if test else ywith a
testonly a worker can resolve (Session.*), the evaluatorruns both branches and absorbs a single failing branch (run time may
never take it). It also absorbed memory/operation budget errors —
but those are spent in this evaluation no matter which branch run
time takes, so a caller's lowered budget silently stopped applying.
Budget errors now propagate; value errors are still absorbed.
create_jobresolveseverything (including
letbindings) with POSIX-format paths, butthe re-checks evaluated under the host format — so a path value
flowing from a
letbinding into an arg drew a "Path formatmismatch" error on Windows. The checks now evaluate under POSIX to
match the values they read.
Also, the symbol-table seeding for these checks no longer discards
setfailures (let _ =) — they propagate asModelError, never apanic, so language bindings surface them as exceptions.
What is the impact of this change?
Callers passing a context derived from the template (the norm,
via
default_validation_context()or equivalent) see no change.Evaluation errors that depend on parameter values now fail
create_jobinstead of every session that runs the job. Loweredevaluation budgets are enforced inside unresolved conditionals, the
idiomatic construction.
How was this change tested?
cargo clippy --all-features --all-targets --workspace -- -D warningsclean.cargo testfor expr/model/cli green, including new tests: contractrejection message, value-dependent evaluation errors reported at
create_job, budget propagation out of unresolved conditionals(pinned at both the expr and model level), value-error absorption
control, and path-format agreement.
Was this change documented?
Yes —
create_jobandFsEvaldocs,specs/model/job-creation.md(error policy,
ctxcontract),specs/model/public-api.md, andspecs/expr/evaluator.md(IfExp absorption and its budget exemption).Is this a breaking change?
Yes.
create_jobreturns aCompatibilityerror when the context'srevision differs from the template's or its extensions don't cover the
template's declared ones. Callers deliberately stripping extensions at
job creation must instead reject the template at decode via
supported_extensions.Does this change impact security?
No new files or permissions. Enforcement-wise it strengthens the
caller-limits story: evaluation budgets can no longer be bypassed via
unresolved-test conditionals.
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.