fix(up): stop late starters racing the on-exit abort

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 <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-09-22 13:19:57 +02:00
parent 96f3a1f8f5
commit ca867482ad
+26 -1
View File
@@ -343,7 +343,19 @@ func (u *upSession) killApplication() {
func (u *upSession) stopOnFirstExit() api.ContainerEventListener {
once := true
return func(event api.ContainerEvent) {
if !once || event.Type != api.ContainerEventExited {
if !once {
// The abort races the start phase, which runs on a deliberately
// uncancelable context (SIGTERM management): a container whose
// start was in flight when the application was swept comes up
// AFTER the stop, and would keep the session alive until its
// natural end. The events stream reveals such late starters —
// stop each one as it appears.
if event.Type == api.ContainerEventStarted {
u.stopLateStarter(event.Service)
}
return
}
if event.Type != api.ContainerEventExited {
return
}
if u.options.Start.OnExit == api.CascadeFail && event.ExitCode == 0 {
@@ -356,6 +368,19 @@ func (u *upSession) stopOnFirstExit() api.ContainerEventListener {
}
}
// stopLateStarter stops one service started after the on-exit abort swept the
// application — see stopOnFirstExit.
func (u *upSession) stopLateStarter(service string) {
u.eg.Go(func() error {
err := u.stop(context.WithoutCancel(u.globalCtx), u.project.Name, api.StopOptions{
Services: []string{service},
Project: u.project,
}, u.printer.HandleEvent)
u.appendErr(err)
return nil
})
}
// captureExitCodeFrom captures the exit code of the first container to exit
// for the service selected by --exit-code-from
func (u *upSession) captureExitCodeFrom() api.ContainerEventListener {