Skip to content

fix(display): stop ttyWriter.Done from hanging after context cancel - #14119

Open
glours wants to merge 1 commit into
docker:mainfrom
glours:fix/ttywriter-done-hang-after-context-cancel
Open

fix(display): stop ttyWriter.Done from hanging after context cancel#14119
glours wants to merge 1 commit into
docker:mainfrom
glours:fix/ttywriter-done-hang-after-context-cancel

Conversation

@glours

@glours glours commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

What I did
Fixes #14114: Done() blocked forever once the render goroutine had already exited via ctx.Done() (e.g. after SIGTERM), since it sent on an unbuffered channel with nobody left to receive.

Closing the channel instead of sending never blocks, but a bare close() panics the moment ttyWriter runs a second Start/Done cycle on the same shared bus - which already happens today (run's nested create/start, publish's nested push, rm --stop's sequential stop then remove). Start now hands out a fresh, once-only-closable signal each cycle instead of a single shared channel, and run/publish stop opening a redundant nested bracket around calls already inside one.

Related issue
closes #14114
Supersedes #14115

(not mandatory) A picture of a cute animal, if possible in relation to what you did
image

@glours
glours requested review from a team as code owners August 24, 2026 10:56
@glours
glours requested a review from ndeloof August 24, 2026 10:56
@glours glours self-assigned this Aug 24, 2026

@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 core fix is correct and well-structured: replacing the blocking channel send with a close()-based doneSignal (using sync.Once for idempotency) solves the deadlock, the per-cycle fresh signal design prevents double-close panics, and the switch to unexported create/start/push in run.go/publish.go correctly avoids double-bracketing.

Two medium-severity issues were found in the changed code.

Lower-confidence findings (not posted inline)

  • [low] cmd/display/tty.go:199 — TOCTOU race: w.done can be replaced between w.print() and the mutex snapshot in Done() (confidence: 🟠 weak 52/100). Requires concurrent Start()+Done() on the same writer outside the sequential Run() wrapper — unlikely in practice but structurally present.

Comment thread cmd/display/tty.go
Comment thread cmd/display/tty.go
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.55172% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cmd/display/tty.go 96.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Fixes docker#14114: Done() blocked forever once the render goroutine had
already exited via ctx.Done() (e.g. after SIGTERM), since it sent on an
unbuffered channel with nobody left to receive.

Closing the channel instead of sending never blocks, but a bare close()
panics the moment ttyWriter runs a second Start/Done cycle on the same
shared bus - which already happens today (run's nested create/start,
publish's nested push, rm --stop's sequential stop then remove). Start
now hands out a fresh, once-only-closable signal each cycle instead of
a single shared channel, and run/publish stop opening a redundant
nested bracket around calls already inside one.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
@glours
glours force-pushed the fix/ttywriter-done-hang-after-context-cancel branch from 337ec55 to a8ad922 Compare August 24, 2026 11:18
@glours
glours requested a review from docker-agent August 24, 2026 11:19

@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 fix is well-designed and correct. The core changes — replacing the shared unbuffered done channel with a per-cycle doneSignal (wrapping sync.Once) and switching from a blocking send to a non-blocking close() — soundly fix the #14114 hang without introducing any new races or panics.

Key design points verified:

  • sync.Once in doneSignal.close() prevents double-close panics across sequential and nested Start/Done cycles.
  • Start() holds w.mtx while replacing w.done/w.ticker and captures them as local variables in the goroutine, so a later Start() reassigning the struct fields never affects the running goroutine's wait.
  • Done() reads w.done and w.ticker under lock into locals before calling close(), so it always operates on the correct cycle's signal even if a concurrent Start() races in.
  • Switching run.go and publish.go to the unexported create/start/push avoids the nested Start/Done bracket on the shared bus, with strings.ToLower preserved for parity with the public methods.
  • The new tests (TestDoneAfterContextCancelDoesNotHang, TestNestedStartDoneDoesNotPanic, TestSequentialStartDoneEachGetFreshChannel, TestDoneBeforeStartDoesNotPanic) provide solid regression coverage, including goroutine-leak detection via goleak.VerifyNone.

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.

[BUG] ttyWriter.Done still hangs forever when the context was canceled before Done runs

2 participants