Skip to content

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

Open
ndeloof wants to merge 3 commits into
docker:mainfrom
ndeloof:deflake-e2e
Open

fix(logs): restart re-attach loses a fast run's output — found by deflaking e2e#14140
ndeloof wants to merge 3 commits into
docker:mainfrom
ndeloof:deflake-e2e

Conversation

@ndeloof

@ndeloof ndeloof commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Three e2e tests failed 9 CI runs across 5 unrelated branches this week. Deflaking them (first commit) turned TestAttachRestart from a flake into a reliable detector — and its first CI run proved the loss is real: with the hardened test waiting a full minute, the third restart's log line still never arrived (2× world for 3× exit notices, the re-attached logs?since=… request hanging empty).

The bug (second commit): re-attaching with since=StartedAt drops a fast run's first lines forever — the daemon starts copying stdout before it records StartedAt, so a process that prints and exits within milliseconds gets its output timestamped just before the anchor. Both re-attach sites (attached up, logs --follow) now anchor on the previous run's FinishedAt: nothing can be logged between one run's end and the next one's start, so the window captures the whole new run without replaying the previous one (zero FinishedAt ⇒ no lower bound, exact for a fresh container).

The deflakes (first commit):

  • RequireServiceState polls until the daemon-reported state converges instead of asserting one compose ps snapshot (TestUpDependenciesNotStopped, ×5);
  • TestAttachRestart awaits the log-line count like it already awaited exit notices — events monitor and re-attached logs stream are unordered channels; a genuinely lost line still fails, by timeout (that is how the bug above was caught);
  • TestUpExitCodeFromContainerKilled's 60s ceiling → 120s (one timeout on a loaded oldstable runner).

🤖 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

✅ All modified and coverable lines are covered by tests.

📢 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
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>
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