feat: detached up runs on a single plan by ndeloof · Pull Request #14290 · docker/compose · GitHub
Skip to content

feat: detached up runs on a single plan - #14290

Merged
glours merged 27 commits into
docker:mainfrom
ndeloof:up-detached-single-plan
Oct 15, 2026
Merged

glours merged 27 commits into
docker:mainfrom
ndeloof:up-detached-single-plan

Conversation

@ndeloof

@ndeloof ndeloof commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does, in one sentence

docker compose up -d now runs its Create and Start phases as a single plan over a single daemon snapshot, instead of the old create() + start() sequence — two separate snapshots, the second blind to what the first had just done.

Context

Detached up has always run two unrelated things back to back: create() converges the project (networks, volumes, containers) and plans a Plan, then start() re-lists containers from the daemon from scratch and runs the older imperative engine to start them. That second listing can't see anything create()'s plan changed in the same call — it only sees whatever the daemon looked like before this invocation of up began, modulo its own separate converge from the first listing's point of view.

Lot 1 of epic #14081 already taught the reconciler and executor to plan and execute a Start phase in the same Plan as the Create phase (ReconcileOptions.Scope: ScopeCreateStart), precisely so a caller like detached up could stop needing a second pass. Until this PR, nothing actually requested that scope — it shipped as inert code with no consumer.

What the PR brings

preparePlan takes an explicit scope now (create() passes ScopeCreate, byte-identical to its previous implicit behavior). A new upDetached runs preparePlan(..., ScopeCreateStart) + executePlan once for up -d; --wait stays a final, post-plan verification (re-listing containers once execution is done, since the plan may have just created or recreated some) on the same waitDependencies primitive start() already uses. Interactive up is untouched — its own create/start phase boundary is a separate, riskier item of the same lot.

Making ScopeCreateStart a real, exercised path for the first time surfaced two latent bugs in the recreate machinery that nothing had ever driven end to end: a recreated container's Start-phase progress events (and a dependent's health-wait events) kept reporting the container's internal temporary rename-dance name instead of its final one. Both are fixed here, each pinned by a regression test — see the commit message for the detail; caught and confirmed via the full e2e suite (TestRestartWithDependencies) against a locally built binary, not just unit tests.

The switchover is also where the wait semantics of the Start phase get exercised for real, so a few guardrails are pinned by tests: --wait-timeout gives each dependency wait its own window and still reports a depends_on timeout as such (and an inherited deadline is never blamed on --wait-timeout); a required: false dependency that times out is skipped rather than failing up (this applies to the shared waitDependency primitive, so the imperative callers — restart, run, start, --wait — share it); Create is a hard barrier before Start, and a failure in one phase never cancels unrelated in-flight work of the other.

Part of #14081.

🤖 Generated with Claude Code

@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

@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 PR is a clean, well-scoped refactor: upDetached now runs a single preparePlan(…, ScopeCreateStart) + executePlan pass instead of the old two-snapshot create()/start() sequence. The two latent rename-dance bugs (progress events reporting the temporary name) are properly fixed with regression tests in executor_start_test.go. The executor's thread-safety (containersMu guards all containersByService accesses; reconciliationContext.mu guards pctx) is maintained correctly. The interactive-up path is untouched and behaviourally equivalent to the old code.

Lower-confidence findings (not posted inline)

  • [low] pkg/compose/executor_ops.go:407 — silent no-op when renamed container is absent from containersByService slice (unverified low; informational only)
  • [low] pkg/compose/up.go:117 — DeadlineExceeded error message may cite wrong timeout when WaitTimeout==0 and parent ctx carries a deadline (unverified low; informational only)

@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

The wait timeout does not cover blocking dependency waits executed inside the new plan.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Moves detached up to a single Create+Start reconciliation plan and fixes recreated-container naming state.

Changes:

  • Adds scoped plan preparation and detached-up execution.
  • Refreshes renamed containers in executor state.
  • Adds rename/start regression tests.
File Description
pkg/​compose/​up.go Runs detached up through one plan and handles final waiting.
pkg/​compose/​create.go Extracts scoped plan preparation from creation.
pkg/​compose/​reconcile.go Associates recreated-container renames with their service.
pkg/​compose/​executor_ops.go Updates execution state after container renames.
pkg/​compose/​executor_start_test.go Tests final names in start events and live state.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pkg/compose/up.go Outdated

@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

Comment thread pkg/compose/executor_ops.go
@ndeloof
ndeloof requested a review from docker-agent October 6, 2026 15:02

@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.

This review covers only the changes pushed since 76a7391e10b1.

Assessment: 🟡 NEEDS ATTENTION

Comment thread pkg/compose/up.go
@ndeloof
ndeloof requested review from docker-agent and a balanced review from Copilot October 6, 2026 15:11

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

The wait timeout now incorrectly bounds create, start, and hook operations rather than only health waits.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (1)

Comment thread pkg/compose/up.go Outdated

@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: 🟡 NEEDS ATTENTION

This review covers only the commits pushed since d39bcc914df9 — the two fixes in commit 0a007fc07 (warning log in execRenameContainer and DeadlineExceeded translation around getContainers).

Both changes from the previous review are correctly addressed. One new finding from the latest commit.

Comment thread pkg/compose/up.go Outdated

@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.

ℹ️ This review covers only the commits pushed since 0a007fc07210 (the previous completed review).

Assessment: 🟡 NEEDS ATTENTION

One medium-severity finding in the new deadline-scoping logic.

Lower-confidence findings (not posted inline)

  • [low] pkg/compose/executor.go:253 — All OpWaitCondition nodes share the same absolute waitDeadline; if preceding create/start/hook ops consume most of the budget, health-wait nodes get little or no effective time (confidence: weak 34/100). This is documented as intentional ("one overall budget") and mirrors the old code's behavior under the previous WithTimeout-wrapped plan; flagged for awareness.

Comment thread pkg/compose/up.go Outdated
@ndeloof
ndeloof requested a review from docker-agent October 6, 2026 15:49

@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.

This review covers only the commits pushed since b6b0d598100d2a593a3364969391cc0b979a6fa2.

Assessment: 🟢 APPROVE

The single new commit (570e0944a) introduces applyRemainingDeadline — a well-structured helper that correctly handles all three deadline states: zero (returns a no-op cancel and the unmodified context), already-elapsed (returns a user-friendly error directly, before any daemon call can produce a confusing bare DeadlineExceeded), and future (delegates to context.WithDeadline as normal). The call site in upDetached is wired correctly: the defer cancel() is registered only after a nil-check via the if err != nil { return err } guard, so the nil cancel returned in the error path is never invoked.

This is exactly the fix that was requested in the prior review thread (PRRT_kwDOAOWUd86pi1aP, now resolved), and the implementation matches the suggested pattern while extracting the logic into a named, tested, documented function — an improvement over an inline block.

No bugs were found in the changes introduced by this commit.

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Provider-backed services with dependencies can allow downstream containers to start before provider deployment completes.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (1)

Comment thread pkg/compose/up.go

@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

@ndeloof
ndeloof requested a review from docker-agent October 6, 2026 16:10

@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.

This review covers only the commits since 570e0944a75c.

Assessment: 🟢 APPROVE

The incremental diff makes a single targeted fix to planProviderStart in pkg/compose/reconcile.go: the old code included the create-phase RunProvider node in startChainEnds only as a fallback (when no dependency nodes existed); the new code always includes it, guarded by !slices.Contains to prevent duplicates. This correctly ensures that consumers wait for both the provider's upstream dependencies and the provider's own deployment node — plugging the race where a consumer could start while the provider's plugin/relay was still deploying.

All edge cases check out: provider with no deps and no node in serviceNodes (ends stays empty, same as before); provider with deps but no node (ends = depNodes only, same as before); provider with both deps and a node (new behavior: ends includes both, fixing the race). No new issues introduced.

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

Slow non-wait plan operations can incorrectly exhaust --wait-timeout and fail an otherwise healthy deployment.

Review effort: Balanced
Findings: None

Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Exclude startup and hook work from --wait-timeout

pkg/​compose/​up.go:181

This still makes --wait-timeout expire because of non-wait work: the absolute deadline starts before exec.run, and this branch fails immediately if slow create/start/hook nodes finish after it, even when no health wait timed out (or all services are already healthy). That effectively reintroduces the timeout on operations that exec.waitDeadline was designed not to bound, and differs from the previous flow where the final --wait timeout began after startup. Keep a duration for per-condition waits (derive their timeout when each wait starts) and start a separate timeout for the final post-plan verification rather than rejecting elapsed plan time here.

…deadline

Copilot review finding on docker#14290: waitDependency decided whether a
DeadlineExceeded on an optional dependency wait was safe to tolerate
using a bare boolean -- whether the caller had configured a timeout
for this wait at all (exec.waitTimeout > 0, or legacy waitDependencies'
timeout > 0). That can't distinguish THIS call's own timeout actually
firing from an earlier deadline inherited from the caller's own ctx
also firing: context.WithTimeout(parent, waitTimeout) still makes both
parent and child report DeadlineExceeded once the parent's earlier
deadline elapses, even though this wait's own timer never got there.
An unsatisfied optional wait could therefore swallow a real external
deadline (the process's own context, a test harness timeout) and
report success instead of propagating it.

Replaced the bool with an origCtx snapshot -- ctx as it stood right
before this call's own conditional context.WithTimeout wrap -- threaded
from executor.go's OpWaitCondition dispatch through execWaitCondition
down to waitDependency, and from waitDependencies' own wrap in
service_containers.go. waitDependency now checks origCtx.Err() == nil
at the moment ctx.Done() fires: only when the pre-wrap context hasn't
failed on its own is this wait's own timeout the one responsible.

TestExecutePlanOptionalWaitDoesNotSwallowInheritedDeadline pins the
negative case (inherited deadline must still propagate even though
exec.waitTimeout is set and the dependency is optional) and
TestExecutePlanOptionalWaitTimeoutIsToleratedWithoutInheritedDeadline
pins the positive one (this wait's own timeout, no inherited deadline,
still tolerated as before) -- both verified by ablation.

Verified: go build, go test ./pkg/compose/... (full suite, not just the
new tests) and golangci-lint clean. Local Copilot CLI review confirmed
the origCtx threading is correct and race-free across all three call
sites; its one actionable finding (missing positive-path coverage for
the plan-executor route specifically) is addressed by the second new
test above.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…-wait

Copilot review finding (low) on docker#14290: with --wait-timeout supplied but
not --wait, a dependency timeout in upDetached returned the raw "context
deadline exceeded" instead of the actionable "timeout waiting for
dependencies" the legacy start()+waitDependencies path already reports
for the exact same failure. The --wait-specific "application not healthy
after %s" translation only ever covered the Wait=true case, leaving the
Wait=false, WaitTimeout>0 case (a legal CLI combination -- --wait-timeout
alone is a hang guard) with no translation at all.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Copilot flagged a behavior-parity regression on docker#14290's Create->Start
barrier: a service's OpWaitCondition node only depended on its own
dependency's chain end, so its exec.waitTimeout clock could start
ticking earlier than the old imperative engine's single
waitDependencies(X) call. That call only begins once EVERY direct
dependency of X has itself finished starting (confirmed by reading
InDependencyOrder's graphTraversal.run: a service's visitorFn only
runs once none of its dependency children are still ServiceStopped).
A slow sibling with no wait node of its own (service_started is a
plain edge, nothing to poll) could silently eat into a health check's
effective grace period compared to the old engine.

startPhaseDependencies now computes allTargets, the union of every
declared dependency's own chain-end for the requesting service, and
passes that into waitConditionNode instead of just the one dependency
being checked.

Because wait nodes are shared/deduplicated across consuming services
by (dep, condition) alone, this widening reopened a second, more
serious finding from the same review pass: two consumers of the same
shared node can have different sibling sets, and merging a sibling
that already (transitively) depends on the shared node -- an entirely
ordinary diamond dependency, or a service whose own chain end
collapses onto the wait node itself when it has nothing left to start
-- creates a dependency cycle. The executor has no cycle detection and
no timeout on the dependency-wait phase itself (exec.waitTimeout only
applies once a node starts executing), so this was a guaranteed
permanent hang, not a correctness nicety.

Fixed with wouldCreateCycle: a wait node's merge now skips any
candidate that already (transitively) depends on it. This is provably
safe, not a workaround -- if a candidate already depends on the wait
node, the wait node is necessarily already done by the time the
candidate's own chain completes, so there is nothing to gain from also
adding the reverse edge; the candidate's own direct dependency on the
shared node (or on an intermediate sibling) already encodes everything
the consumer's depends_on requires.

Five new regression tests in reconcile_start_test.go, all verified by
ablation (reverting the relevant guard reliably reproduces the
original bug, 3-5/5 runs depending on the test):
- TestPlanStart_WaitNodeGatedOnAllSiblingDependencies: the original
  parity fix (db:service_healthy + migrate:service_started siblings).
- TestPlanStart_DiamondDependencyNeverCreatesWaitCycle and
  TestPlanStart_ExceptionalChainCollapseNeverCreatesWaitCycle: the two
  concrete cycle shapes found in review.
- TestPlanStart_TransitiveChainNeverCreatesWaitCycle: exercises
  wouldCreateCycle's BFS two hops deep, not just a direct edge.
- assertNoCycle, a plan-wide helper used by the cycle tests above.

Verified: go build, go test ./pkg/compose/..., and golangci-lint all
clean; full e2e suite re-run clean against a locally built binary
(same 3 pre-existing, environment-specific failures as the stack's
baseline: TestWatch, TestIPC, TestComposeModel). Two rounds of local
Copilot CLI review: the first caught the cycle risk in the initial
allTargets widening, the second confirmed wouldCreateCycle's BFS is
correct and complete, the "skipping is safe" reasoning holds in every
case, and no other call site in reconcile.go has the same unguarded
sharing risk.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Reverts 365f002, a behavior-parity fix for a Copilot finding that the
plan-based start engine's per-(dependency,condition) wait node could
start its exec.waitTimeout clock earlier than the old imperative
engine's single waitDependencies(X) call -- which only begins once
EVERY direct dependency of X has itself finished starting, so a slow
sibling with no wait node of its own could silently extend another
dependency's effective grace period.

On reflection that old-engine coupling is itself the bug: a service's
declared --wait-timeout for a given dependency should bound how long
THAT dependency takes to satisfy its condition, period -- not be
inflated by how long an unrelated sibling dependency happens to take
to start. Letting db's health-check timeout silently grow because
migrate's own dependency chain is slow means --wait-timeout no longer
means what it says. Restoring the narrower, per-dependency gating is a
deliberate, accepted behavior change from the old engine, not a parity
regression to chase.

This also removes the dependency-cycle risk the sibling-widening
introduced (an ordinary diamond dependency, or a service whose own
chain end collapses onto a shared wait node, could merge a node back
into its own transitive dependents -- see 365f002's description) and
the wouldCreateCycle machinery and five regression tests that existed
solely to guard against it, now dead weight with no reachable scenario
to protect against.

Verified: go build, go test ./pkg/compose/..., and golangci-lint all
clean.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
The final readiness check opened its --wait-timeout window before
re-listing the project's containers, so a slow daemon listing could
exhaust the budget and report "application not healthy" before
waitDependencies had polled once. List first on the caller's context,
then derive the timed one, as the imperative path did.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…m DeadlineExceeded

up translated any DeadlineExceeded surfacing from the plan into "timeout
waiting for dependencies" or "application not healthy" whenever
--wait-timeout was set, even one coming from an engine call, a hook or
the secret injection, none of which is a dependency-readiness failure.

A wait node whose own --wait-timeout window expired now returns
errWaitTimeout (still satisfying DeadlineExceeded), and up keys its
message on that. A deadline inherited from the caller is never marked.

The test that checks --wait-timeout does not leak into non-wait nodes now
inspects the context those nodes receive, instead of a mock that would
have succeeded either way.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…th --wait too

Copilot review finding on docker#14296: a wait node's own timeout is a
depends_on condition that did not become satisfied during the plan, not
the final --wait readiness check, which runs after the plan and has its
own "application not healthy" translation. The imperative path reported
"timeout waiting for dependencies" for the former regardless of --wait,
but translateWaitTimeout rewrote it as "application not healthy" when
--wait was set. It now always reports the dependency timeout.

Also mark the test nodes that stand for the Start phase as PhaseStart
(the recreate-then-start plan and the wait whose timeout survives a
skipped dependent), so they exercise the Create->Start barrier the way
the reconciler's plans do instead of running everything in one phase.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Copilot review finding on docker#14296: planServiceStart returned before any
dependency planning for every service whose scale is zero. The imperative
startService only treats deploy.replicas: 0 as an unconditional no-op: a
service with scale: 0 still has its depends_on conditions evaluated first,
so an unhealthy or missing required dependency fails `up`/`start`, and
whatever is ordered after the service stays ordered after those
prerequisites. It is simply never an error for it to have no container to
start, and under scope Start the replicas it already owns are started
(CreateStart removes them all in the create phase).

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Copilot review finding on docker#14300: if a sibling Create-phase failure ever
stopped canceling the other node, VolumeCreate's mock blocks on its ctx
while exec.run waits for it, so the test's own five-second guard could
never fire and the regression would hang until the suite-wide timeout.
Give run a deadline of its own and assert the cancellation is the
sibling's (context.Canceled), not that fallback.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@ndeloof
ndeloof force-pushed the up-detached-single-plan branch from 38a069d to 8e4868f Compare October 14, 2026 14:14
@ndeloof
ndeloof marked this pull request as ready for review October 14, 2026 14:14
@ndeloof
ndeloof requested review from a team as code owners October 14, 2026 14:14
@ndeloof
ndeloof requested review from docker-agent and glours and removed request for docker-agent October 14, 2026 14:14

@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

@glours glours 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.

Two items on 8e4868f, inline.

Comment thread pkg/compose/executor.go Outdated
Comment thread pkg/compose/up.go
…ilure

A node that leaves its dependency wait because its phase was canceled
closes its done channel (deferred) without recording any failure. In
X -> A -> B with X failing, B could select done[A] before it saw the
cancellation, find no failure recorded for A, emit its "Starting" event
and enter executeNode on a canceled context, leaving the row never
finalized.

Record the cancellation as the node's failure at that exit, so a
dependent that wins the race is skipped like any other dependent of a
failed node. The regression test pins GOMAXPROCS to 1, which makes the
bad interleaving the usual one.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…ency

The wiring of --wait-timeout into the plan's dependency waits was only
covered by executor tests that set the timeout by hand, and by an e2e
without depends_on. The scenario has a dependency that never gets
healthy and checks that up -d --wait-timeout fails with the dependency
timeout message (without the raw deadline error) and leaves the
dependent service created but not started.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@ndeloof
ndeloof requested a review from docker-agent October 15, 2026 11:03

@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.

This review covers only the commits pushed since 8e4868f430be1ca9a38a255771ee25aad0a43a03.

Assessment: 🟢 APPROVE

The two-file incremental diff is clean. The executor.go fix correctly calls rs.recordFailure(node.ID, err) in the nodeCtx.Done() branch of the dependency-wait loop before returning — using the right node ID and the right error (nodeCtx.Err()). This completes the failure-recording contract for every exit path in runNode, so a downstream dependent that races past the phase-cancellation check will find the failure recorded and skip its own execution rather than entering executeNode on an already-cancelled context.

The regression test (TestExecutePlanCanceledDependencyIsRecordedAsFailure) is well-constructed: runtime.GOMAXPROCS(1) is set and properly restored via t.Cleanup; AnyTimes() on NetworkCreate correctly covers all 200 loop iterations; VolumeCreate.Times(0) is the precise assertion that would catch a regression; t.Context() is used as required by project conventions; and for range 200 is valid Go 1.22+ range-over-int (project uses Go 1.26.9). No issues found in any added lines.

@ndeloof
ndeloof requested a review from glours October 15, 2026 12:40

@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 PR is well-engineered. It correctly implements the single-plan detached up by sequencing Create and Start phases through runPhase barriers, fixes the rename-dance progress-event name bug, and introduces per-wait-node timeout via execBoundedWaitCondition. The phaseCancel + sync.Once pattern correctly resolves the errgroup first-error race. The origCtx pattern in waitDependency correctly distinguishes the call's own timeout from an inherited parent deadline. Test coverage is thorough.

@glours
glours merged commit 42c2b0d into docker:main Oct 15, 2026
64 of 66 checks passed
glours pushed a commit that referenced this pull request Oct 15, 2026
Copilot review (High) on #14290: run() dispatched every node -- Create
and Start phase alike -- under one errgroup.WithContext(ctx), so any
node failing canceled the shared context for every other goroutine
still in flight. A required OpWaitCondition node hitting
--wait-timeout is a Start-phase failure that can now happen while
unrelated Create-phase work (a sibling service's container or network
create) is still legitimately running -- and that work got aborted
too, with nothing to do with the dependency that timed out. The old
create()-then-start() sequence never had this failure mode: the whole
create phase always finished completely before any dependency wait
even began.

run() now derives two contexts instead of one shared errgroup-context:
createCtx at the root, startCtx as its child. A node picks nodeCtx
(and the matching cancelPhase) from its own node.Phase and calls
cancelPhase() only on its own genuine failure. A Start-phase failure
cancels only startCtx, leaving createCtx and everything still running
under it untouched. A Create-phase failure still cancels the whole
plan, Start phase included, via startCtx's derivation from createCtx
-- matching the old sequence, where start() was never even invoked
once create() failed.

The node-goroutine body moved out of run()'s loop into a runNode
method (golangci-lint's cognitive-complexity limit after this change)
taking a new runState struct bundling the per-run bookkeeping every
node shares.

Two smaller, unrelated fixes bundled in:
- upDetached (up.go) keeps an origCtx snapshot so its two
  DeadlineExceeded-to-friendly-message checks can tell its own
  WaitTimeout window expiring apart from an inherited, unrelated
  parent deadline propagating through the derived context -- both
  report DeadlineExceeded on Err(), only the former earned the
  "application not healthy" framing.
- waitDependency (service_containers.go), shared by the old
  waitDependencies and the new execWaitCondition -- the latter can now
  produce a real deadline for the first time via exec.waitTimeout --
  tolerates a timeout on an optional (BestEffort) dependency the same
  way it already tolerates every other definitive failure: skipped
  with a warning, not a hard failure.

Two regression tests pin both cancellation directions:
TestExecutePlanStartPhaseFailureDoesNotCancelCreatePhase (a
Start-phase wait timing out must not cancel a concurrent, unrelated
Create-phase node) and TestExecutePlanCreatePhaseFailureCancelsCreatePhase
(a Create-phase failure must still fail-fast its Create-phase
siblings, same as before this change). Both verified by ablation.

Full e2e suite re-run clean against a locally built binary -- same 3
pre-existing, environment-specific failures as the stack's baseline,
no new ones.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
glours pushed a commit that referenced this pull request Oct 15, 2026
…deadline

Copilot review finding on #14290: waitDependency decided whether a
DeadlineExceeded on an optional dependency wait was safe to tolerate
using a bare boolean -- whether the caller had configured a timeout
for this wait at all (exec.waitTimeout > 0, or legacy waitDependencies'
timeout > 0). That can't distinguish THIS call's own timeout actually
firing from an earlier deadline inherited from the caller's own ctx
also firing: context.WithTimeout(parent, waitTimeout) still makes both
parent and child report DeadlineExceeded once the parent's earlier
deadline elapses, even though this wait's own timer never got there.
An unsatisfied optional wait could therefore swallow a real external
deadline (the process's own context, a test harness timeout) and
report success instead of propagating it.

Replaced the bool with an origCtx snapshot -- ctx as it stood right
before this call's own conditional context.WithTimeout wrap -- threaded
from executor.go's OpWaitCondition dispatch through execWaitCondition
down to waitDependency, and from waitDependencies' own wrap in
service_containers.go. waitDependency now checks origCtx.Err() == nil
at the moment ctx.Done() fires: only when the pre-wrap context hasn't
failed on its own is this wait's own timeout the one responsible.

TestExecutePlanOptionalWaitDoesNotSwallowInheritedDeadline pins the
negative case (inherited deadline must still propagate even though
exec.waitTimeout is set and the dependency is optional) and
TestExecutePlanOptionalWaitTimeoutIsToleratedWithoutInheritedDeadline
pins the positive one (this wait's own timeout, no inherited deadline,
still tolerated as before) -- both verified by ablation.

Verified: go build, go test ./pkg/compose/... (full suite, not just the
new tests) and golangci-lint clean. Local Copilot CLI review confirmed
the origCtx threading is correct and race-free across all three call
sites; its one actionable finding (missing positive-path coverage for
the plan-executor route specifically) is addressed by the second new
test above.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
glours pushed a commit that referenced this pull request Oct 15, 2026
…-wait

Copilot review finding (low) on #14290: with --wait-timeout supplied but
not --wait, a dependency timeout in upDetached returned the raw "context
deadline exceeded" instead of the actionable "timeout waiting for
dependencies" the legacy start()+waitDependencies path already reports
for the exact same failure. The --wait-specific "application not healthy
after %s" translation only ever covered the Wait=true case, leaving the
Wait=false, WaitTimeout>0 case (a legal CLI combination -- --wait-timeout
alone is a hang guard) with no translation at all.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
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.

4 participants