Skip to content

test(e2e): lock the behavior of the foreground up - #14300

Draft
ndeloof wants to merge 3 commits into
docker:mainfrom
ndeloof:up-interactive-characterization
Draft

ndeloof wants to merge 3 commits into
docker:mainfrom
ndeloof:up-interactive-characterization

Conversation

@ndeloof

@ndeloof ndeloof commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Follows #14296 (merged). Independent of #14302 and #14304.

What this PR does, in one sentence

It adds end-to-end scenarios that pin the observable behavior of the foreground (non-detached) compose up, so the upcoming switch of its machinery to the plan engine can be reviewed against a fixed specification.

Context

Epic #14081 converges the start phase into the plan engine. The foreground up is the riskiest consumer: it creates, then attaches the log printer and the monitor, then starts, with each phase currently driven by its own code path. Most e2e coverage of the start phase runs detached (up -d); the foreground path is covered by the cascade, exit-code and Ctrl+C tests, but not for hooks, health-gated dependencies, containers that exit as soon as they start, restarts, recreation, dependency timeouts or dry-run.

What the PR brings

Seven scenarios, each a one-line obligation on the foreground up:

  • post_start output is streamed into the session, and the service sees what pre_start wrote (pre_start's own output is not streamed by a foreground up today, and is deliberately not asserted);
  • --abort-on-container-exit notices a container that exits the instant it starts, instead of hanging;
  • a container that exited earlier is started again, not recreated;
  • a service is not started before its service_healthy dependency is healthy;
  • after a config change the service is recreated and the new container's output is streamed;
  • a dependency that stays unhealthy fails the up with a timeout, the dependent being created but never started;
  • --dry-run plans the create phase only.

Tests only, no production code. Every scenario passes on the current code, and the health-gating one was checked to fail when the dependency is removed. The use_api_socket case is left out for now: it could not be validated in the environment this was written in.

Why this is the right next brick

The switchover that follows moves the interactive up from two snapshots to a single plan. Locking the current behavior first means any difference it introduces shows up as a failing scenario rather than as a surprise in review.

Part of #14081.

🤖 Generated with Claude Code

@ndeloof
ndeloof requested a review from docker-agent October 8, 2026 08:28
@codecov

codecov Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@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

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.

🟡 Changes recommended

Readiness polling can block necessary starts under bounded concurrency, and two regression checks do not enforce their intended guarantees.

3 open findings
What changed in this PR

Adds seven foreground compose up scenarios to protect behavior during the plan-engine migration in #14081. The current stacked diff also includes underlying lifecycle changes from earlier PRs.

Changes:

  • Covers hooks, immediate exits, restarts, health gates, recreation, timeouts, and dry-run.
  • Moves lifecycle callers onto shared planning and execution.
  • Adds regression coverage for ordering, deadlines, providers, and observed state.
File Description
pkg/​e2e/​up_interactive_test.go Adds seven foreground scenarios.
pkg/​e2e/​testdata/​TestUpInteractiveWaitsForHealthy/​compose.yaml Supplies health-gated services.
pkg/​e2e/​testdata/​TestUpInteractiveRestartsExitedContainer/​compose.yaml Tracks container reuse.
pkg/​e2e/​testdata/​TestUpInteractiveRecreateStreamsNewContainer/​compose.yaml Supplies changing configuration.
pkg/​e2e/​testdata/​TestUpInteractiveImmediateExit/​compose.yaml Pairs immediate and long-running services.
pkg/​e2e/​testdata/​TestUpInteractiveHookLogs/​compose.yaml Supplies hooks and shared data.
pkg/​e2e/​testdata/​TestUpInteractiveDependencyTimeout/​compose.yaml Supplies an unsatisfied health dependency.
pkg/​e2e/​testdata/​TestUpDryRunCreatesNothing/​compose.yaml Supplies dry-run configuration.
pkg/​compose/​watch.go Uses combined create/start planning.
pkg/​compose/​up.go Plans detached startup together.
pkg/​compose/​start.go Uses start-only plans.
pkg/​compose/​start_test.go Removes obsolete imperative tests.
pkg/​compose/​service_containers.go Refines deadlines; removes imperative startup.
pkg/​compose/​service_containers_test.go Tests optional dependency deadlines.
pkg/​compose/​scale.go Uses combined planning.
pkg/​compose/​restart_test.go Updates shared helper calls.
pkg/​compose/​reconcile.go Refines startup ordering and relay handling.
pkg/​compose/​reconcile_start_test.go Covers startup planning regressions.
pkg/​compose/​observed_state.go Scopes observation by phase.
pkg/​compose/​observed_state_test.go Tests scoped observation.
pkg/​compose/​executor.go Separates phases and bounds waits.
pkg/​compose/​executor_start_test.go Tests execution and timeout behavior.
pkg/​compose/​executor_ops.go Propagates deadlines and refreshes renamed containers.
pkg/​compose/​down_test.go Updates shared helper usage.
pkg/​compose/​create.go Extracts shared plan preparation.
pkg/​compose/​containers.go Removes unused running-state predicate.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread pkg/compose/executor.go Outdated
Comment on lines +337 to +341
if err := acquireSlot(nodeCtx, rs.limiter); err != nil {
rs.recordFailure(node.ID, err)
return err
}
defer releaseSlot(rs.limiter)
Comment thread pkg/compose/executor_start_test.go Outdated
Comment thread pkg/e2e/up_interactive_test.go Outdated
@ndeloof
ndeloof requested review from docker-agent and a balanced review from Copilot October 8, 2026 09:11

@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

Lower-confidence findings (not posted inline)

  • [medium] pkg/e2e/up_interactive_test.go:99 — OutputNotContains("Start") is too broad a substring check for dry-run verification (confidence: weak 52/100)


var volumeCreateCtxErr error
volumeCreateDone := make(chan struct{})
apiClient.EXPECT().VolumeCreate(gomock.Any(), gomock.Any()).

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.

[medium] TestExecutePlanCreatePhaseFailureCancelsCreatePhase may hang 5 s on some scheduler orderings

NetworkCreate returns errors.New("boom") immediately — no blocking. When the Go scheduler lets the network goroutine complete before the volume goroutine enters acquireSlot, phaseCancel fires and cancels the shared nodeCtx. The volume runNode then exits at acquireSlot(nodeCtx, …) without ever calling VolumeCreate, so volumeCreateDone is never closed. The select block below waits the full 5 s before t.Fatal fires.

The gomock.EXPECT().VolumeCreate(…) (no .Times(0)) also requires exactly one call, so gomock's test-cleanup assertion adds a second failure mode.

Suggested fix: make the volume goroutine block before acquireSlot until an external signal confirms it started, so the test can guarantee VolumeCreate is entered before the network failure propagates:

// in the VolumeCreate mock:
started := make(chan struct{})
apiClient.EXPECT().VolumeCreate(gomock.Any(), gomock.Any()).
    DoAndReturn(func(ctx context.Context, _ client.VolumeCreateOptions) (client.VolumeCreateResult, error) {
        close(started)          // signal: we are inside the call
        defer close(volumeCreateDone)
        <-ctx.Done()
        volumeCreateCtxErr = ctx.Err()
        return client.VolumeCreateResult{}, ctx.Err()
    })
// make NetworkCreate wait until VolumeCreate has started
apiClient.EXPECT().NetworkCreate(gomock.Any(), "test_default", gomock.Any()).
    DoAndReturn(func(...) (client.NetworkCreateResult, error) {
        <-started
        return client.NetworkCreateResult{}, errors.New("boom")
    })
Confidence Score
🟡 moderate 57/100

Comment thread pkg/e2e/up_interactive_test.go Outdated
Comment thread pkg/e2e/up_interactive_test.go

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.

🔵 Needs a closer look

The stacked lifecycle migration needs human review, with unresolved shared-resource reconciliation and optional-timeout issues.

2 open findings
1 resolved since last review
Previously missed (2)

In code that hasn't changed since last review

Medium severity Honor optional dependency timeouts during condition checks

pkg/​compose/​service_containers.go:235

Optional timeouts are skipped only when the loop observes ctx.Done(). If the wait deadline expires during ContainerInspect for service_completed_successfully, checkDependencyCompleted returns the deadline error and the post-check return bypasses this branch. The deployment then fails despite required: false. Apply the same own-timeout check before returning condition-check errors, preserving errors from inherited deadlines, and add a test with an inspection blocked until the wait deadline.

Medium severity Preserve shared-resource consumers during scoped reconciliation

pkg/​compose/​watch.go:679

Narrowing the model before resource reconciliation hides consumers of shared networks and volumes: servicesUsingNetwork and servicesUsingVolume inspect only project.Services. For example, after a same-name network configuration change, watch --no-up rebuilding one of two independent services sharing that network plans to disconnect only the selected service. Network removal then fails because the other service remains attached, leaving the selected service stopped. Preserve the full consumer set for shared-resource reconciliation while scoping container/start operations, or explicitly skip shared-resource convergence when consumers are excluded.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

@ndeloof
ndeloof requested review from docker-agent and a balanced review from Copilot October 8, 2026 09:24

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

Assessment: 🟢 APPROVE

The incremental diff is small and well-targeted: four changes across the hook-logs compose fixture and the interactive test file.

  • TestUpInteractiveHookLogs: The pre_start command correctly switches from tee (which emitted to stdout) to a plain redirect, aligning the fixture with the documented behavior that foreground up does not stream pre_start output. The assertion is updated to test the hook's effect (app-read:pre-start-file-content) rather than its output. Sound.
  • TestUpInteractiveImmediateExit: Wrapping ServiceState("long", "exited") in Eventually(..., 10*time.Second) addresses the race between the stop signal and the daemon's state update that was flagged in the prior review. Good fix.
  • TestUpInteractiveDependencyTimeout: The added comment clearly documents the ServiceState("web", "created") invariant, resolving the ambiguity raised in the prior review.
  • TestUpDryRunCreatesNothing: Narrowing OutputNotContains("Start") to OutputNotContains(s.Project()+"-app-1 Start") avoids a false-negative on unrelated output that could happen to contain the word "Start". The narrower string is still a reliable signal for the intended contract.

No bugs found in the added lines.

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.

🔵 Needs a closer look

Dependency timeout behavior and watch’s shared-resource reconciliation have unresolved correctness issues.

2 open findings
Previously missed (3)

In code that hasn't changed since last review

Medium severity Wait timeout starts before dependent prerequisites finish

pkg/​compose/​executor.go:400

The timeout now starts before all of the dependent's prerequisites finish starting. waitConditionNode waits only for the dependency it polls; the old InDependencyOrder traversal finished every prerequisite's start chain before calling waitDependencies. For example, with web depending on healthy db and started worker, db becoming healthy at 8 seconds, and worker's startup hook taking 10 seconds, --wait-timeout 3 previously succeeded but can now fail around 3 seconds. Preserve the dependent's prerequisite-start barrier for timed waits and add a multi-dependency regression. Shared wait nodes need special handling so the fix does not introduce dependency cycles.

Medium severity Optional dependency timeouts during inspection incorrectly fail startup

pkg/​compose/​service_containers.go:235

The new optional-timeout handling only covers expiry between polls. For an optional service_completed_successfully dependency, a timeout during ContainerInspect propagates through isServiceCompleted and checkDependencyCompleted, then returns at lines 269–270 without reaching this branch. Startup therefore fails instead of skipping the dependency. Apply the same own-versus-inherited deadline handling to polling errors, and test an inspect that blocks until the wait deadline expires; the current short-timeout tests expire before the first poll.

Medium severity Narrowed services omit shared infrastructure consumers

pkg/​compose/​watch.go:679

Narrowing project.Services also hides shared infrastructure consumers from servicesUsingNetwork and servicesUsingVolume. If a same-name shared network needs recreation during a rebuild, an unrelated running service is excluded from the disconnect plan, so NetworkRemove fails on its remaining endpoint after the selected containers have already been stopped. Approved volume recreation has the analogous in-use failure. Keep the full model available for infrastructure reconciliation and scope service create/start work separately.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

@ndeloof
ndeloof force-pushed the up-interactive-characterization branch from 76664d5 to 79c53dc Compare October 8, 2026 09:51
@ndeloof
ndeloof requested review from docker-agent and a balanced review from Copilot October 8, 2026 09:52

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.

🔵 Needs a closer look

The stacked lifecycle and cancellation changes need maintainer validation, including resolution of the inspection-time deadline handling gap.

2 open findings
1 resolved since last review

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment on lines +231 to +235
if !config.Required && origCtx.Err() == nil {
s.events.On(containerReasonEvents(waitingFor, skippedEvent,
fmt.Sprintf("optional dependency %q did not satisfy %q before the timeout", dep, config.Condition))...)
logrus.Warnf("optional dependency %q did not satisfy %q before the timeout", dep, config.Condition)
return nil

@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

Seven well-structured e2e scenarios that pin the observable foreground up behaviors before the upcoming plan-engine switchover. The production-code changes refactor the executor into a two-phase (Create → Start) barrier model with per-wait-node timeout scoping — the logic is sound, edge cases (ScopeStart relay restart, optional-dependency deadline inheritance) are correctly handled, and the test suite is comprehensive.

Lower-confidence findings (not posted inline)

  • [low] pkg/compose/reconcile.go:1369 — planProviderStart's else-if condition fires when a service node exists but is already in ends, not only when the node is absent (confidence: negligible — unreachable via any valid project plan; maintenance hazard only)
  • [low] pkg/compose/service_containers_test.go:867 — timing-dependent test: time.Sleep(10ms) after a 1ms deadline to guarantee expiry; robust in practice but theoretically racy on heavily-loaded CI (confidence: negligible)
  • [low] pkg/compose/service_containers.go:163 — three-level context nesting (caller → timeout-wrap → errgroup) is correct but subtle; origCtx comment already documents the invariant well (informational only, no action needed)

@ndeloof
ndeloof force-pushed the up-interactive-characterization branch from 79c53dc to 833cd1d Compare October 8, 2026 10:23
@ndeloof
ndeloof requested review from docker-agent and a balanced review from Copilot October 8, 2026 10:23

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.

🟡 Changes recommended

Shared-resource reconciliation and inherited-deadline handling still have unresolved correctness issues.

5 open findings

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread pkg/compose/executor.go
ctx, cancel = context.WithTimeout(origCtx, exec.waitTimeout)
defer cancel()
}
err := exec.execWaitCondition(ctx, origCtx, op)
Comment thread pkg/compose/watch.go
Comment on lines +679 to +683
p, err := project.WithSelectedServices(services, types.IncludeDependents)
if err != nil {
return err
}
err = s.createAndStart(ctx, p, api.CreateOptions{
Comment thread pkg/compose/executor_start_test.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

ndeloof added a commit to ndeloof/compose that referenced this pull request Oct 8, 2026
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-interactive-characterization branch from 833cd1d to ebc865a Compare October 8, 2026 10:48
@ndeloof
ndeloof requested review from docker-agent and a balanced review from Copilot October 8, 2026 10:48

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.

🟡 Changes recommended

The health-gating scenario can pass when web starts before Docker marks its dependency healthy.

5 open findings
1 resolved since last review

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment on lines +69 to +70
OutputContains("web-saw-ready"),
OutputNotContains("web-too-early"))

@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 refactor is well-structured and the e2e scenarios are sound. No CONFIRMED or LIKELY bugs were found in the added code.

Lower-confidence findings (not posted inline)

  • [low] pkg/compose/executor.go:419 — translateWaitTimeout returns a brand-new opaque error, severing the context.DeadlineExceeded chain from errors.Is callers (confidence: weak 26/100). No current caller is affected; a future caller checking errors.Is(err, context.DeadlineExceeded) would get a false negative. Consider fmt.Errorf("timeout waiting for dependencies: %w", err) to preserve the chain.

  • [low] pkg/compose/service_containers.go:230 — origCtx.Err() == nil disambiguation for optional-dependency timeout has an inherent sub-nanosecond race window where an inherited parent deadline firing at the exact same instant could be swallowed as a "this call's own timeout" (confidence: weak 26/100). Acknowledged as a known limitation in the code comment; impact is dropping a warning instead of propagating an error for an optional dependency. Pre-existing technique, first applied to this new code surface.

  • [low] pkg/compose/watch.go:693 — rebuild() now returns createAndStart errors; the distinct create-phase vs. start-phase failure log messages are collapsed into one combined message (confidence: weak 26/100). The rebuild scheduler discards the returned error with _ = s.rebuild(...) so no runtime impact; this is purely a diagnostic observability note.

  • [low] pkg/e2e/up_interactive_test.go:67 — TestUpInteractiveWaitsForHealthy has no service-state assertion after up exits (confidence: weak 26/100). If a log-streaming race causes up to exit before web prints its output, OutputContains("web-saw-ready") would fail with a misleading diagnostic. Adding a ServiceState/Eventually(ServiceState(...)) guard for web's final state would make failures self-diagnosing.

  • [low] pkg/e2e/up_interactive_test.go:99 — TestUpDryRunCreatesNothing uses OutputNotContains(s.Project()+"-app-1 Start") to assert no start event is emitted (confidence: weak 26/100). If the dry-run progress renderer emits "Starting" rather than "Start", the check passes vacuously. Worth verifying the substring matches the renderer's actual verb.

ndeloof added a commit to ndeloof/compose that referenced this pull request Oct 14, 2026
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>
glours pushed a commit that referenced this pull request Oct 15, 2026
Copilot review finding on #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-interactive-characterization branch from ebc865a to 3e3ce09 Compare October 16, 2026 09:01
Most e2e coverage of the start phase runs detached; the foreground
`up`, whose create and start phases are driven by separate code paths,
had none for hooks, health-gated dependencies, a container that exits as
soon as it starts, restarts of exited containers, recreation, dependency
timeouts or dry-run. Add scenarios that fix these observables before the
machinery behind it changes.

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

The hook scenario matched "pre-start-hook-ran" in the output, but the
service's own line "app-read:pre-start-hook-ran" contains it, so the
assertion passed without any pre_start output reaching the session. A
foreground up streams post_start's output but not pre_start's today
(checked on main too). Lock what actually holds: pre_start's effect, read
by the service through a value distinct from anything it logs.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Wait for the long-running service's final state after an abort instead of
reading it the instant the command returns, document why the dependent is
expected in the created state on a dependency timeout, and narrow the
dry-run "no start" check to the service's own lines.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@ndeloof
ndeloof force-pushed the up-interactive-characterization branch from 3e3ce09 to 4e8a4d0 Compare October 16, 2026 13:14

This branch has not been deployed

No deployments
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