Skip to content

feat(pass): cancel run if the engine becomes unavailable mid-resolution - #611

Merged
joe0BAB merged 1 commit into
mainfrom
feat/run-liveness-check
Aug 11, 2026
Merged

feat(pass): cancel run if the engine becomes unavailable mid-resolution#611
joe0BAB merged 1 commit into
mainfrom
feat/run-liveness-check

Conversation

@joe0BAB

@joe0BAB joe0BAB commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Start a background liveness monitor alongside resolveEnv that pings Version() every 5s (3s deadline per ping). If the engine stops responding the monitor cancels the shared context, unblocking pass run instead of hanging indefinitely.

The engine is multi-threaded and services pings concurrently with in-flight secret resolution and open ACM approval prompts, so a failed ping reliably indicates a wedge rather than load.

This mirrors the pattern docker/cli settled on after indefinite hangs were reported against an unreachable daemon:

@joe0BAB
joe0BAB marked this pull request as ready for review August 10, 2026 13:00

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🟢 APPROVE

The liveness monitor implementation looks correct. The goroutine lifecycle is clean: ctx.Done() exits the loop on any cancellation (from either the parent context or cancel(nil) deferred by RunCommand), so there is no goroutine leak. The context.WithCancelCause / context.Cause pattern correctly surfaces the "secrets engine became unavailable" error when the monitor fires, and passes through genuine resolution errors when the monitor hasn't fired yet. The ping uses context.WithoutCancel to deliberately decouple the ping deadline from the already-cancelled parent — that's the right call. The test suite covers both the happy path and the failure path with appropriate intervals.

No bugs introduced by this PR were found.

@Benehiko Benehiko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review — liveness monitor (ca71693)

15 findings from a multi-angle review with adversarial verification (14 CONFIRMED, 1 PLAUSIBLE, 0 refuted). 14 are posted as inline comments below; one finding has no diff anchor, so it is included here.

Top risks: false-positive run cancellation (any Version() error is treated as engine death, a single slow ping kills the run, and the effective header deadline is 1s rather than the intended 3s), error masking by the context.Cause branch, and the monitor both outliving resolution and being structurally unable to catch the per-request wedge it was built for.


[CONFIRMED] correctness — run.go:92 (not in diff): feature premise contradicts client config — missing WithResponseTimeout(0)

run expects resolution to block for minutes on approval prompts, but builds the client without WithResponseTimeout(0), leaving the 1s header timeout that client.WithResponseTimeout's godoc says interactive flows must override.

If the engine does not flush response headers before blocking on approval, every approval taking longer than ~1s dies with a raw timeout awaiting response headers — before the monitor's first 5s tick — making the new mechanism and its curated error unreachable for the pre-header wedge class. If the engine does flush early, the unbounded post-header wait affects all client consumers and only pass run gets a (partial) guard. The timeout regimes (1s header / 3s ping / 5s interval / optional --timeout) live in different layers with no coordination.


🤖 Generated with Claude Code

Comment thread plugins/pass/commands/run.go Outdated
Comment thread plugins/pass/commands/run.go Outdated
Comment thread plugins/pass/commands/run.go Outdated
Comment thread plugins/pass/commands/run.go Outdated
Comment thread plugins/pass/commands/run.go Outdated
Comment thread plugins/pass/commands/run.go
Comment thread plugins/pass/commands/run_test.go Outdated
func TestStartLivenessMonitor(t *testing.T) {
t.Parallel()

t.Run("resolves normally when engine stays alive", func(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] test-coverage — the monitor's two core paths are untested: a hanging Version() and continue-after-successful-ping

Every mock versionFn returns instantly and ignores its ctx, so dropping the context.WithTimeout on pingCtx (or a client change that stops honoring ctx in Version) passes the suite while a truly wedged engine blocks the ping forever and the monitor never fires — the exact hang class this PR exists for. A versionFn that blocks on <-ctx.Done() would cover it.

Subtest 1 ("resolves normally when engine stays alive", 50ms interval) resolves in microseconds and usually cancels before the first tick, making it functionally identical to subtest 3 — a regression breaking continue-after-successful-ping passes CI.

Comment thread plugins/pass/commands/run_test.go Outdated
Comment thread plugins/pass/commands/run_test.go Outdated
return []secrets.Envelope{{ID: secrets.MustParseID("tok"), Value: []byte("val")}}, nil
},
versionFn: func(_ context.Context) (client.DaemonVersion, error) {
t.Error("Version() called unexpectedly")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] test-quality — t.Error inside versionFn runs on the monitor goroutine and is safe only because interval=time.Hour

If a future edit lowers the interval (e.g. copying subtest 1's 50ms "to actually exercise the path"), the ticker can fire after the subtest returns — and the in-flight ping is uncancellable because pingCtx derives from context.WithoutCancel (run.go:201) — so t.Error fires post-completion and the "Log in goroutine after test has completed" panic aborts the whole test binary.

The subtest name "does not fire when resolution completes first" also documents a contract production does not have: in RunCommand the monitor keeps running after resolution (see the defer cancel comment); the test passes only because an hour never elapses.

Comment thread plugins/pass/commands/run_test.go Outdated
Run a single bounded Version() ping before resolving secrets, so an
unreachable engine fails `pass run` fast instead of hanging resolution
indefinitely. The ping only runs when the client request timeout is
indefinite; a timeout configured via WithTimeout already bounds the wait.

The Docker CLI bounds its daemon connection ping the same way
(docker/cli#3722, fixing the unreachable-daemon hang in docker/cli#3652).

Also expose WithResponseTimeout and WithSocketPath run options so
embedders can override the client's 1s response-header timeout and the
engine socket path; the socket option also lets tests point the
preflight ping at a dead socket.

Signed-off-by: Johannes Großmann <grossmann.johannes@t-online.de>
@joe0BAB
joe0BAB force-pushed the feat/run-liveness-check branch from ca71693 to 2e650c7 Compare August 11, 2026 09:20
@joe0BAB
joe0BAB merged commit 0562842 into main Aug 11, 2026
11 checks passed
@joe0BAB
joe0BAB deleted the feat/run-liveness-check branch August 11, 2026 09:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants