Skip to content

fix(logs): restart re-attach loses a fast run's output — found by deflaking e2e - #14140

Merged
ndeloof merged 9 commits into
docker:mainfrom
ndeloof:deflake-e2e
Sep 22, 2026
Merged

ndeloof merged 9 commits into
docker:mainfrom
ndeloof:deflake-e2e

Conversation

@ndeloof

@ndeloof ndeloof commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

What this PR does, in one sentence

Turns the flakiest e2e tests into reliable ones by fixing the product races they were detecting: restart re-attach anchoring, shutdown log draining, and wait on already-exited targets — one dedicated commit per test for traceability.

Context

TestAttachRestart, TestWaitAndDrop and TestWatch cost a CI rerun on most runs. Investigating them one by one showed the first two were not test problems: the tests were correctly detecting real product races that only needed a loaded runner to fire.

What the PR brings

One commit per fixed test:

  • TestAttachRestart — two product fixes. (1) Re-attach after a restart anchored its log window on the NEW run's start, losing a fast run's first lines forever (the daemon starts copying stdout before it records StartedAt): the window is now anchored on the previous run's end, observed from the events stream. (2) The residual failure mode — exposed by this PR's own daemon-view dump: the daemon held all three lines while compose printed two — was shutdown racing delivery: the monitor returns on the final die event and up canceled the context with the last run's line still in flight on the logs connection. Re-attach streams are now drained to their natural EOF (guaranteed once containers exited, bounded against a wedged daemon, skipped on Ctrl-C) before teardown. Passes 5/5 locally; failed within 2 CI attempts before.
  • TestWaitAndDrop — a product fix. wait listed running containers only, so a target that finished between up and the listing failed with "no containers for project" in milliseconds instead of returning its recorded exit code — the exact condition wait exists to observe, already satisfied. A fallback full listing runs only when nothing is running, scoping the change to the previously-erroring path: a stale exited one-off can never short-circuit a wait that has live containers to observe. Exit-code propagation verified end to end (an already-failed service returns 7).
  • TestUpExitCodeFromContainerKilled — a product fix. The on-exit abort ("Aborting on container exit") sweeps the application while the start phase — deliberately uncancelable for SIGTERM management — may still be starting services: a container started after its own stop stays up, the monitor never drains, and up hangs until killed. The abort listener now watches the events stream past its trigger and stops any late starter as it appears — event-driven, so no listing window to miss, and the late starter's own 143 still flows to --exit-code-from. Passes 5/5 locally.
  • TestWatch — a test-budget fix. The "until watch is up" bootstrap loop ran under poll's default 10s, which had to absorb pull/build, container start and watcher init on a loaded runner. Only the bootstrap gets a 2-minute budget; every later step keeps the sharp default so a real sync regression still fails fast.

Also in the series: the three state/stream deflakes that started this investigation, the daemon-view failure dump that made the residual AttachRestart mode diagnosable, and unit tests locking each behavior (log-window anchoring, wait fallback listings, drain accounting).

🤖 Generated with Claude Code

@ndeloof
ndeloof requested review from a team as code owners August 27, 2026 10:12
@ndeloof
ndeloof requested a review from glours August 27, 2026 10:12

@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: 🔴 CRITICAL

Two issues in the new polling closure introduced by this PR:

  1. Unsafe type assertion (panics)serviceState["State"].(string) at line 46 has no nil/type guard. If compose ps returns {} or {"State": null} (possible when a service is not yet registered), the assertion panics inside the poll.WaitOn closure and crashes the test goroutine instead of retrying.

  2. Wrong poll result on missing service — Line 43 returns poll.Error (terminates polling) when the service key is absent/mismatched. During a startup race this converts a transient "not yet visible" condition into a hard failure — the same class of race this PR aims to fix.

Comment thread pkg/e2e/assert.go Outdated
Comment thread pkg/e2e/assert.go Outdated
@ndeloof ndeloof changed the title test(e2e): deflake the three state/stream race suspects fix(logs): restart re-attach loses a fast run's output — found by deflaking e2e Aug 27, 2026
@ndeloof

ndeloof commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

CI caught the loss again with the FinishedAt anchor (still 2× world for 3× exit notices after a full minute, oldstable runner) — so there is a second mechanism. Local experiments can't reproduce it: 15 bare-daemon runs of a millisecond-lived --restart=on-failure:2 container always land 3 worlds in docker logs, and the e2e passes consistently on this machine with the fix.

Added instrumentation instead of speculation: on timeout the test now dumps the daemon's own docker logs view. Next CI occurrence will discriminate: line present there but absent from compose's output → compose still fails to relay; absent from both → the daemon's copier loses ultra-short-lived output (moby issue). The FinishedAt anchor stays — the StartedAt race it closes is real regardless.

@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.77465% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/compose/wait.go 50.00% 1 Missing and 1 partial ⚠️
pkg/compose/up.go 97.56% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@ndeloof
ndeloof force-pushed the deflake-e2e branch 2 times, most recently from e0ef00b to f4f7d5e Compare August 27, 2026 14:04
@ndeloof
ndeloof requested a lite review from Copilot August 27, 2026 14:15

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.

Pull request overview

This PR addresses a log-following correctness bug in Compose where re-attaching after a restart can miss output from very fast container runs, and it also deflakes several e2e tests that were intermittently failing in CI.

Changes:

  • Fix log re-attach anchoring by using the previous run’s end time (and tracking it from ordered events) instead of anchoring on StartedAt.
  • Deflake e2e coverage by polling for convergent daemon state (RequireServiceState) and waiting for asynchronous log lines in TestAttachRestart.
  • Increase an e2e timeout to reduce sporadic CI timeouts on slower runners.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
pkg/e2e/compose_up_test.go Extends a scenario step timeout to reduce CI flakiness.
pkg/e2e/compose_test.go Hardens TestAttachRestart by waiting for expected log output instead of asserting a single snapshot.
pkg/e2e/assert.go Makes RequireServiceState poll until compose ps converges to the expected state.
pkg/compose/up.go Changes up log re-attach to use a safer “since” anchor captured at start-event time.
pkg/compose/logs.go Implements runEndTracker and switches follow/re-attach log anchoring away from StartedAt.
pkg/compose/logs_test.go Adds a unit test validating the run-end tracking anchor behavior across fast restarts.

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

Comment thread pkg/compose/logs.go
Comment thread pkg/compose/logs_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: 🟡 NEEDS ATTENTION

One CONFIRMED medium-severity finding in the new RequireServiceState poll helper.

Comment thread pkg/e2e/assert.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

The anchor-on-FinishedAt approach is sound, and the runEndTracker design correctly captures the previous run's exit time synchronously before the re-attach goroutine starts. One edge case in the new Observe guard may reintroduce the original log-loss bug in rare daemon environments.

Comment thread pkg/compose/logs.go
Three tests failed 9 CI runs across 5 branches this week, all on
asynchronous-observation races, none reproducible locally:

- RequireServiceState asserted on a single `compose ps` snapshot; the
  daemon reports state transitions asynchronously from everything else
  a test observes (TestUpDependenciesNotStopped saw 'created' while
  the container's logs were already flowing). It now polls until the
  state converges (15s bound).
- TestAttachRestart counted restart log lines in a snapshot taken as
  soon as the third exit notice appeared; exit notices come from the
  events monitor while log lines come from the re-attached logs
  stream — two channels with no ordering between them. The count is
  now awaited like the exit notices already were; a genuinely lost
  line still fails, by timeout.
- TestUpExitCodeFromContainerKilled ran a full up+abort cycle under a
  60s ceiling, once exceeded on a loaded oldstable runner; raised to
  120s.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…e new run's start

The CI hardening in the previous commit turned TestAttachRestart's
flake into a reliable detector, and what it detected is a real loss:
re-attaching with since=StartedAt drops a fast run's first lines
forever, because the daemon starts copying stdout before it records
StartedAt.

Anchoring on the inspected FinishedAt fixes the common case but leaves
a narrower race CI still caught: when the new run itself finishes
before compose reacts to its start event, the inspected FinishedAt is
already the NEW run's own end, and the log window drops everything the
run printed — two worlds for three exit notices, the third never
arriving no matter how long you wait.

Both re-attach sites (attached up, logs --follow) therefore anchor on
the session's own record of the container's previous exit
(runEndTracker): the monitor delivers events in order, so the anchor
captured synchronously at start-event time is necessarily the previous
run's end — nanosecond-precise, immune to how fast the new run dies.
The inspected FinishedAt remains the fallback for a container the
session never saw exit, and a fresh container keeps no lower bound. A
unit test pins the ordering contract, including the fast-run sequence
CI caught.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
The FinishedAt anchor did not cure the third-run loss on CI (still 2
worlds for 3 exit notices after a full minute, oldstable runner). The
remaining suspects are on both sides of the API: a line the daemon
never captured (copier torn down before a millisecond-lived run's
output) or a line compose still fails to relay. On timeout the test
now dumps `docker logs` for the container — ground truth that
discriminates the two on the next CI occurrence.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Review finding on runEndTracker.Observe: recording nothing for an exit
event without a timestamp is the deliberate choice — the anchor is
evaluated by the daemon against its own log clock, so substituting the
local clock would introduce real skew mis-anchoring to paper over a
hypothetical daemon quirk, while dropping only degrades that container
to the pre-tracker fallback.

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

Wait listed running containers only, so a service that finished between
up and the listing — a fast run, or a service long done by the time the
user types the command — failed with 'no containers for project' in a
few milliseconds instead of returning its recorded exit code. This is
also the root cause of the TestWaitAndDrop flake: its 'faster' service
sleeps 2 seconds, less than the harness latency between the two steps.

The condition wait observes (container no longer running) already holds
for such a target: fall back to a full listing only when no container
is running, and let ContainerWait return the recorded status
immediately. Scoping the fallback to the previously-erroring path keeps
every other semantics intact — in particular a stale exited one-off can
never short-circuit a wait that has live containers to observe.
Exit-code propagation verified end to end: wait on an already-failed
service returns its code (7), not an error.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
TestAttachRestart kept failing residually after the re-attach anchoring
fix, and its new daemon-view dump told exactly why: the daemon holds
all three 'world' lines while compose printed two. The monitor returns
on the final die event — an events-channel fact — and up canceled the
global context on the spot, killing the re-attach log streams with the
last run's line still in flight: the exit notice outran the output that
preceded it.

Re-attach streams are now counted in a WaitGroup, and the monitor
wrapper waits for them to reach their natural EOF — guaranteed once the
containers exited — before canceling, under a bound that only protects
against a wedged daemon and is skipped entirely when the context is
already down (Ctrl-C). AttachRestart passes 5/5 locally with this
drain; it failed within 2 CI attempts without it.

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

TestWatch/debian flakes on loaded runners during 'writing to a file
until Compose watch is up and running': that bootstrap loop ran under
poll.WaitOn's default 10s budget, which must absorb image pull/build,
container start and watcher initialization. Only the bootstrap gets the
2-minute budget — every later step keeps the sharp default so a real
sync regression still fails fast.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
TestUpExitCodeFromContainerKilled hung for its full 2-minute budget on
CI, and its event transcript shows why: 'Aborting on container exit'
sweeps the application while the start phase — which runs on a
deliberately uncancelable context for SIGTERM management — was still
starting services; test-1 came up AFTER its stop and stayed up, so the
monitor never drained and up never returned.

The abort listener now watches the events stream past its trigger: any
container started after the sweep is a late starter from that race, and
gets stopped as it appears. Event-driven, so there is no listing window
to miss; idempotent stops make duplicates harmless. The exit code
semantics are preserved: the late starter's own 143 flows through
captureExitCodeFrom exactly as when the sweep wins the race.
TestUpExitCodeFrom* pass 5/5 locally.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
glours
glours previously approved these changes Sep 22, 2026

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

runEndTracker/drain/stopLateStarter are well-reasoned and well-tested.

One gap worth a follow-up: the race stopLateStarter fixes for stopOnFirstExit (on-exit cascade) still seems open for gracefulTeardown (Ctrl+C/SIGINT/SIGTERM).

monitor.Start only returns once its containers set is empty, and onContainerStart re-adds any container that starts, regardless of why. stopApplication()'s stop sweep is a one-shot listing — a container that starts after it keeps monitor.Start blocked until it exits on its own. This PR wires stopLateStarter to catch that, but only inside stopOnFirstExit's own closure. gracefulTeardown calls the same stopApplication() with no equivalent listener.

Worse, the window is wider here: stopOnFirstExit only fires once something has already exited (startup is well underway), while Ctrl+C can land at t≈0, before monitor.Start even starts and while s.start() (on its deliberately uncancelable context) is still climbing the dependency graph — most services may still be created, not running. The only recovery today is a second manual Ctrl+C.

Not a regression from this PR, so I wouldn't block on it — but same root cause, same file, worth a fast follow-up (e.g. arm the late-starter watch once u.isTerminated is set, not just inside stopOnFirstExit).

…-exit

glours' review on this PR (pullrequestreview-5277692679): stopLateStarter
only ever got armed inside stopOnFirstExit's own closure, so it caught a
service racing the on-exit cascade's sweep but not one racing
gracefulTeardown's (Ctrl+C/SIGTERM) identical one-shot stopApplication
sweep -- same root cause, same file, just the other trigger. Worse for
that path: Ctrl+C can land at t~=0, before monitor.Start even starts and
while s.start() (its own uncancelable context) is still climbing the
dependency graph, so most services may still be created, not running.
The only recovery today is a second manual Ctrl+C.

u.isTerminated already flags "termination is underway" and was already
set by gracefulTeardown (just not consulted for late-starter catching,
and set after stopApplication rather than before it). Extract the
late-starter watch out of stopOnFirstExit into its own listener,
stopLateStarters, armed on u.isTerminated regardless of which path set
it, and register it unconditionally (Ctrl+C works regardless of the
on-exit policy, unlike stopOnFirstExit's own listener). stopOnFirstExit
now sets isTerminated itself before its sweep, symmetric with
gracefulTeardown (also reordered to set it before stopApplication, not
after).

isLateStarter is extracted as a pure predicate, following the existing
shouldFollowStartEvent precedent, and unit-tested (TestIsLateStarter):
the previous fix (ca86748) shipped only against a flaky test hitting
the on-exit case by chance, with no dedicated regression test at all.

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

ndeloof commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 39bf0d6: extracted the late-starter watch out of stopOnFirstExit into its own stopLateStarters listener, armed on u.isTerminated (already set by gracefulTeardown, just not consulted for this) instead of the local once flag, and registered unconditionally so it also covers Ctrl+C/SIGTERM regardless of the on-exit policy. Added TestIsLateStarter -- the original fix (ca86748) shipped with no dedicated regression test at all, only a flaky test that happened to hit the on-exit case.

@ndeloof
ndeloof enabled auto-merge (rebase) September 22, 2026 12:38

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

LGTM

@ndeloof
ndeloof merged commit 023544a into docker:main Sep 22, 2026
60 checks passed
@ndeloof
ndeloof deleted the deflake-e2e branch September 22, 2026 12:52
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