diff --git a/candy/plugin-check/bed_build_window_test.go b/candy/plugin-check/bed_build_window_test.go new file mode 100644 index 0000000..be174a3 --- /dev/null +++ b/candy/plugin-check/bed_build_window_test.go @@ -0,0 +1,33 @@ +package check + +import ( + "testing" + + "github.com/opencharly/spec/poll" +) + +// TestBuildStepEnvSizesTheChildGuardWindow pins the #123 fix: the generated BUILD steps +// carry a build-sized no_progress window to the child through the cli seam's per-call env, +// and that window RESOLVES with the child's other readiness defaults — an override the +// child rejects would silently fall back to the 90s probe window, which is the defect +// itself (the child's idle guard then reads an IO-bound `podman load`/cold export as a +// wedged call and SIGKILLs it). +func TestBuildStepEnvSizesTheChildGuardWindow(t *testing.T) { + env := buildStepEnv() + got := env["CHARLY_READINESS_NO_PROGRESS"] + if got != buildStepNoProgress { + t.Fatalf("buildStepEnv()[CHARLY_READINESS_NO_PROGRESS] = %q, want %q", got, buildStepNoProgress) + } + + // The env name and value must be exactly what the child's resolver reads, and the child + // must not reject the override (ordering: every interval <= no_progress <= absolute_cap). + t.Setenv("CHARLY_READINESS_NO_PROGRESS", got) + rr, err := poll.ResolveReadiness(nil) + if err != nil { + t.Fatalf("the build step's window does not resolve with the child's readiness defaults: %v", err) + } + if rr.NoProgress <= poll.ReadinessNoProgressFallback { + t.Fatalf("the build step's window resolves to %s, which does not exceed the probe fallback %s", + rr.NoProgress, poll.ReadinessNoProgressFallback) + } +} diff --git a/candy/plugin-check/bed_run.go b/candy/plugin-check/bed_run.go index 42a15ef..2086050 100644 --- a/candy/plugin-check/bed_run.go +++ b/candy/plugin-check/bed_run.go @@ -515,6 +515,24 @@ func bedRunGuard(cap *bedStderrCapture, dir, name string, state *bedRunState, re } } +// buildStepNoProgress is the CHILD's readiness no_progress window for a generated BUILD +// step. The image-build step runs `charly box build` through the cli seam, and the child's +// own plugin dispatch carries an idle guard whose window is the project's readiness +// no_progress (default 90s). A build's IO-bound phases — the OCI bridge's `podman load`, a +// cold layer export — make no host-visible progress for minutes, which the guard reads as a +// wedged call and SIGKILLs (plugin-check#123, measured live as +// `bridging … into the podman store: podman load …: signal: killed` at 2m2s). The harness +// must not be the reason a legitimate image build fails, so the build steps size the child's +// window through the seam's per-call env — a build-sized bound, never the probe floor. 30m +// stays within the default absolute_cap (30m), so the child's readiness ordering validates +// without a second override. +const buildStepNoProgress = "30m" + +// buildStepEnv is the per-call env that carries buildStepNoProgress to a build step's child. +func buildStepEnv() map[string]string { + return map[string]string{"CHARLY_READINESS_NO_PROGRESS": buildStepNoProgress} +} + // runCheckBed executes the canonical R10 sequence for one check bed and writes // per-step logs + summary.yml to .check///. Returns the result struct // (always non-nil once setup succeeds) and the first error encountered. @@ -795,7 +813,7 @@ func runCheckBed(ctx context.Context, ex *sdk.Executor, name string, opts bedRun // step records a step's outcome (a `charly` subcommand over the cli seam) and // writes its log file. Returns the run error so the caller can short-circuit. - step := func(stepName string, argv ...string) error { + stepEnv := func(stepName string, env map[string]string, argv ...string) error { state.enterStep(stepName) defer state.leaveStep() t0 := time.Now() @@ -805,7 +823,7 @@ func runCheckBed(ctx context.Context, ex *sdk.Executor, name string, opts bedRun if writeErr := os.WriteFile(logPath, []byte("status: RUNNING\ncommand: "+command+"\n"), 0o644); writeErr != nil { fmt.Fprintf(os.Stderr, "charly check run %s: [%s] cannot initialize log %s: %v\n", name, stepName, logPath, writeErr) } - reply, cerr := bedCliCombined(ex, ctx, argv...) + reply, cerr := bedCliCombinedEnv(ex, ctx, env, argv...) dur := time.Since(t0) logText := cliStepLog(reply) // R10's "a warning is not a pass", MEASURED. Scan the retained log before deciding the @@ -873,6 +891,11 @@ func runCheckBed(ctx context.Context, ex *sdk.Executor, name string, opts bedRun return nil } + // step runs a non-build step: its child keeps the project's readiness windows. + step := func(stepName string, argv ...string) error { + return stepEnv(stepName, nil, argv...) + } + // cleanup is the ONE venue-teardown BODY: nested children in reverse deploy order, then the // root verb, then deploykit.TearDownMembers. It is called ONLY by bedVenueTeardown, which owns // the gate (retention / disposability / already-torn-down) — the body therefore carries no @@ -1014,7 +1037,7 @@ func runCheckBed(ctx context.Context, ex *sdk.Executor, name string, opts bedRun if bedBuildsImage(d.IsVM, isKubeVirt, d.IsLocal, d.Image) { // Disposable check beds ALWAYS bake the IN-DEVELOPMENT charly toolchain via // --dev-local-pkg — so a bed tests the code under development. - if err := step("image-build", withRunTag([]string{"box", "build", d.Image, "--dev-local-pkg"}, d.ImageTag)...); err != nil { + if err := stepEnv("image-build", buildStepEnv(), withRunTag([]string{"box", "build", d.Image, "--dev-local-pkg"}, d.ImageTag)...); err != nil { return fail("image build %s: %w", d.Image, err) } if d.RunBuild { diff --git a/candy/plugin-check/command.go b/candy/plugin-check/command.go index b10c431..eb4beab 100644 --- a/candy/plugin-check/command.go +++ b/candy/plugin-check/command.go @@ -156,15 +156,18 @@ func bedCli(ex *sdk.Executor, ctx context.Context, capture bool, argv ...string) return bedCliReq(ex, ctx, spec.CliRequest{Argv: argv, Capture: capture}) } -// bedCliCombined is bedCli with COMBINED capture (stdout+stderr merged into reply.Stdout) — used for -// the check-bed per-step .log so a `charly check …` child's STDERR-written results are persisted -// (pre-relocation parity: core runCapture captured combined output; plain bedCli captures stdout -// only, which would drop the check results from the log). -func bedCliCombined(ex *sdk.Executor, ctx context.Context, argv ...string) (spec.CliReply, error) { - return bedCliReq(ex, ctx, spec.CliRequest{Argv: argv, Capture: true, Combined: true}) +// bedCliCombinedEnv is the check-bed per-step cli call: COMBINED capture (stdout+stderr merged +// into reply.Stdout, so a `charly check …` child's STDERR-written results are persisted — plain +// bedCli captures stdout only, which would drop the check results from the log) plus a per-call +// ENV — the seam's placement-invariant carrier for settings the CHILD must resolve for itself +// (its own readiness bounds), which the caller cannot set into a shared process. The generated +// BUILD steps use it to size the child's idle window (buildStepNoProgress, bed_run.go) so an +// IO-bound image build is not read as a wedged plugin call (plugin-check#123). +func bedCliCombinedEnv(ex *sdk.Executor, ctx context.Context, env map[string]string, argv ...string) (spec.CliReply, error) { + return bedCliReq(ex, ctx, spec.CliRequest{Argv: argv, Capture: true, Combined: true, Env: env}) } -// bedCliReq is the shared cli-seam marshal/dispatch/decode (R3 — one body for bedCli/bedCliCombined). +// bedCliReq is the shared cli-seam marshal/dispatch/decode (R3 — one body for bedCli/bedCliCombinedEnv). // // The forked child gets the invocation's per-bed env explicitly via CliRequest.Env (never via // inherited process env): the bed run carries its RunEnv on ctx (spec.RunEnvFrom), and this