fix: Close remaining RFC 0008 wrap-action gaps in hook scoping, cleanup, and preflight - #277
Conversation
…up, and preflight
Fix the WRAP_ACTIONS conformance failures found by running the suite on
Windows, plus four findings from an independent review of the fixes.
Wrapper/wrapped scopes separated. The wrap dispatch resolved
WrappedAction.* on the same symbol table used for the hook's own
resolution, the wrap env's own script-level `let` bindings were missing
from hook scope entirely ("Undefined variable"), and run_task's
effective_script re-applied the step's let bindings over the hook's
scope — a same-named `let` in wrapper and step made one side's value
silently win. seed_wrapped_action_symbols now takes a distinct
inner_symtab: the wrapped action's command/args/timeout/cancelation
resolve against the INNER entity's own scope (built by
build_wrapped_inner_scope: its embedded files + its lets, in the same
order the runners use), while the hook resolves only against the wrap
env's scope (frozen resolved_symtab + its own lets + the
WrappedAction.* overlay). The wrapped step's lets/files are no longer
passed to the runner when wrapping. Pinned by tests binding the same
name in both scopes and covering wrap-env lets in hooks.
WrappedAction.Environment includes declarative variables. Per the
clarified spec (RFC 0008 / Template Schemas 4.3.1, updated in the
specifications repo), the forwarded list carries all session-defined
variables: openjd_env exports AND entered environments' declarative
`variables:` maps. The session already tracked both; a new test pins
the behavior.
Cleanup guarantee in the CLI. RFC 0008 "Lifecycle and cleanup
guarantees": a failed onWrapEnvEnter must still run the inner env's
onWrapEnvExit and the wrapping env's own onExit. The CLI aborted the
run on any environment-enter failure — including a separate `?` early
return for step environments — exiting nothing. Every entered
environment (template, job, and step scoped) is now tracked as
(id, name, step symtab); a failed enter marks the session failed,
prints the failing action's exit-code line, and skips remaining work;
step environments unwind to a per-step baseline; final cleanup exits
everything remaining in LIFO order.
Single-wrap-layer preflight. The RFC requires rejecting a session
whose stack defines wrap hooks in more than one environment BEFORE
entering any environment, but the CLI entered sequentially, so the
first wrapper's own onEnter ran before the session rejected the
second. The CLI now validates every stack the run will build (env
templates + job envs + each selected step's step envs) up front; the
session's enter-time check remains as defense in depth.
Environment-template parameters in hooks. --environment templates are
converted with the preprocessed parameter symbol table so a wrap
template's own parameterDefinitions resolve inside its hooks.
Docs: specs/cli/run.md (cleanup + preflight), specs/sessions/
public-api.md (MultipleWrapEnvironments), specs/model/public-api.md
(convert_environment_with_symtab at the crate root).
Tests: sessions wrap suite +3 (lets in hooks, variables map, scope
separation), CLI +1 end-to-end cleanup-guarantee test with two new
fixture templates. cargo test --workspace green except two
pre-existing Windows cross-user failures unrelated to this change.
Conformance: 2023-09 suite 1112/1112 on Windows including all
previously-skipped WRAP_ACTIONS fixtures.
Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
| // even when an enter action failed — the cleanup guarantee (How Jobs | ||
| // Are Run; RFC 0008 for wrapped exits) is that every environment | ||
| // entered or attempted has its onExit run before the session ends. | ||
| exit_envs_down_to!(0); |
There was a problem hiding this comment.
This final cleanup (and the per-step exit_envs_down_to!(step_env_baseline)) is bypassed when run_task returns an Err rather than a failed ActionState. The three task call sites still use .map_err(...)? (lines 624, 706, 763), so a session-level error from run_task returns early from execute() and skips all environment exits — the same cleanup-bypass class this PR fixes for the enter path.
This matters here specifically because the scope-separation work newly introduces run_task error modes in the wrap path: if a wrap environment’s let bindings or WrappedAction.* resolution fails, seed_wrapped_action_symbols returns SessionError::FormatString, which propagates out of run_task as Err. In that case a wrapper whose onEnter already started a container would never get its onWrapEnvExit/onExit run — the very guarantee the PR is trying to establish end-to-end.
Consider treating a run_task Err the same as a failed enter: log it, set session_failed = true, and fall through to cleanup instead of ?-propagating.
There was a problem hiding this comment.
I think #276 is covering this already. Let's look at it in that pr.
| // environments + each selected step's step environments — before | ||
| // entering anything. (The session's own enter-time check remains as | ||
| // defense in depth for library callers.) | ||
| { |
There was a problem hiding this comment.
nit: this execute function is getting a bit long at 800+ lines, might be worth simplifying it later on
|
Important fix: the cleanup guarantee is still bypassed when This PR fixes the failed-enter path so cleanup always runs, but the same guarantee has a remaining hole on the task path in All three .map_err(|e| format!("Step '{}': {e}", step.name))?and the parameter-space setup inside the step loop ( This is exactly the failure class this PR makes more likely to occur: a wrap hook referencing an undefined variable fails in Note the session state at that point is Suggested fix (mirrors the enter-failure handling this PR already added): let result = match session.run_task(...).await {
Ok(r) => r,
Err(e) => {
eprintln!("ERROR: Step '{}': {e}", step.name);
session_failed = true;
break; // falls through to exit_envs_down_to!(step_env_baseline) / (0)
}
};with the same treatment for the iterator/containment errors. A companion test would be the existing Happy to see this as a fast-follow rather than blocking — the PR is a clear net improvement — but it closes the same guarantee (RFC 0008 "Lifecycle and cleanup guarantees") this PR sets out to establish, so it'd be good to ticket it before merge. |
|
Complexity metrics for the functions this PR touches Measured at head
For comparison, To be fair to this PR: it reduces structural duplication (four divergent copy-pasted enter/exit loops became one At CC ~113, "does every failure path reach cleanup?" is genuinely hard to audit — even with AI assistance, code is really hard to make right at these levels. It's why the four divergent exit loops existed in the first place, and why the remaining Suggested follow-up (not blocking): introduce a |
leongdl
left a comment
There was a problem hiding this comment.
- Important fix — run_task Err bypasses cleanup:
#277 (comment)
(#277 (comment)) — describes the
trigger (wrap-hook dispatch error), why cleanup would have succeeded (session is in ReadyEnding), the
suggested match-and-break fix, and a companion test idea. Framed as fast-follow-acceptable but
ticket-before-merge.
|
Performance: the wrap dispatch now deep-clones the entire
.map(|action| (wrap_env.resolved_symtab.clone(), action))to .map(|action| (wrap_env.clone(), action))which deep-copies the whole
Why it matters: embedding the wrapper's hook script via Suggested fix, either of:
Not blocking, but cheap to fix now while the dispatch code is fresh. |
What was the problem/requirement? (What/Why)
The
WRAP_ACTIONSextension (RFC 0008) lets a job author write a "wrapper" environment.Normally the runtime runs a job's commands directly; with a wrapper active,
the runtime instead calls one of the wrapper's three hook scripts
(
onWrapEnvEnter,onWrapTaskRun,onWrapEnvExit) and hands it adescription of the command it replaced through
WrappedAction.*templatevariables (
.Command,.Args,.Environment, ...). The classic use caseis running every task inside a container: the hook reads those values and
re-runs the command under
docker exec.The OpenJD conformance suite (small job templates that pin the behaviors the
spec requires) recently started running its WRAP_ACTIONS tests on Windows,
and 5 of them failed against our CLI.
A wrapper's own
letvariables didn't work in its hooks. Anenvironment script can define
let:variables (small named valuescomputed once, like
image_tag = 'v2'). The spec says those names areavailable in the environment's actions — and the wrap hooks are actions
of the wrapper's script. But the runtime resolved hooks against the
wrapped step's variables, which know nothing about the wrapper's, so a
hook referencing
{{image_tag}}failed with "Undefined variable".The wrapper's and the wrapped step's variables were not kept separate.
The wrapped command's description (
WrappedAction.*) and thehook's own script were resolved against the same symbol table. If the
wrapper and the step both defined a
letvariable namedwho, oneside's value silently won. Each side must only ever see its own scope.
A failed environment start skipped all cleanup. OpenJD guarantees
that every environment a session entered — or tried to enter — gets
its exit action run before the session ends (so a wrapper that started a
container always gets to tear it down). The CLI instead aborted the whole
run on the first enter failure: nothing was exited. Step-scoped
environments had the same bug through a separate early-return.
Two wrappers could sneak in. The spec allows at most ONE wrapping
environment per session, and requires rejecting a violating session
before entering any environment. Our validator catches two wrappers
inside a single template file, but two wrappers supplied as separate
--environmentfiles were only caught at enter time — after the firstwrapper's own
onEnterhad already run (and possibly started acontainer).
Two smaller gaps:
WrappedAction.Environment(the list ofKEY=valuevariables a hook can forward into its container) omitted variables an
environment declared via its static
variables:map, only includingopenjd_envexports — the spec was clarified to include both; and themodel crate's API doc didn't list a function that is actually exported
at the crate root.
What was the solution? (How)
Two scopes, kept strictly apart (
openjd-sessions). The wrap dispatchnow builds two separate symbol tables:
it run unwrapped (the step's or inner environment's own
letvariablesand embedded files).
WrappedAction.Command/Args/Timeout/Cancelation.*are resolved against this table, and only this table.
its own
letvariables (fixing problem 1), plus theWrappedAction.*values computed above. The hook script resolves against this table, and
only this table.
Previously both jobs were done on one shared table (problem 2). A new
regression test defines the same
letname in both scopes and asserts eachside sees its own value.
A cleanup path in the CLI (
openjd-cli). Theruncommand nowrecords every environment it enters — job-level, template-supplied, and
step-scoped — in one list. A failed enter no longer aborts the run
structure: it marks the session failed, skips remaining work, and falls
through to cleanup, which exits every recorded environment in reverse
(LIFO) order. Step environments unwind to a per-step baseline. This makes
the CLI honor the guarantee end-to-end: a wrapper whose
onWrapEnvEnterfails still gets its
onWrapEnvExitand its ownonExit.Preflight before any enter (
openjd-cli). Before entering anything,the CLI scans every environment stack the run will build (external
--environmenttemplates + the job's environments + each selected step'senvironments) and rejects the run if any stack contains more than one
wrap-defining environment. The session-level enter-time check stays as
defense in depth for library callers.
Environment-template parameters in hooks (
openjd-cli).--environmenttemplates are now converted with a symbol table built from the preprocessed
job parameters, so a wrapper template's own
parameterDefinitionsresolveinside its hooks (
{{Param.Prefix}}in a hook no longer fails).Spec docs updated in the same PR:
specs/cli/run.md(cleanup +preflight),
specs/sessions/public-api.md(newMultipleWrapEnvironmentserror),specs/model/public-api.md(
convert_environment_with_symtabat the crate root).What is the impact of this change?
own parameters and
letvariables, name collisions between wrapper andwrapped scopes are impossible, failed enters still clean up, and invalid
two-wrapper sessions are rejected before any side effects.
openjd run's environment enter/exit flow was restructured; output nowincludes a
Process exited with code: Nline for a failed enter action(which the conformance runner uses to attribute failures).
Windows (the fixture-side changes are in the companion
openjd-specifications PR).
How was this change tested?
cargo test --workspaceis green except two pre-existing Windowscross-user logon-error-mapping failures on the development workstation.
letin hooks,variables:map inWrappedAction.Environment, wrapper/wrapped scopeseparation) and 1 CLI end-to-end test (failed wrap-enter still runs
wrap-exit and the wrapper's own exit) with 2 new fixture templates.
WRAP_ACTIONS fixtures that were previously skipped there.
cargo clippy --all-features --all-targets --workspace -- -D warningsis clean.
CLI with same-named
letbindings in wrapper and step.Was this change documented?
seed_wrapped_action_symbolsdocuments the two-scope model,build_wrapped_inner_scopedocuments how the inner scope is built, andthe CLI's enter/exit macros document the cleanup guarantee. The spec
documents under
specs/were updated in the same commits (see above).Is this a breaking change?
No for the published API surface.
openjd-sessionsgains a newSessionError::MultipleWrapEnvironmentsvariant (additive; breaking onlyfor exhaustive matches on this non-trivial error enum, which the
pre-release policy tolerates). Behavior changes are all corrections toward
what RFC 0008 already required.
Does this change impact security?
No new files, directories, or permission changes. The scope-separation fix
is defensive.
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.