Skip to content

Fix keep-alive deadlock: wait for a signal instead of bare select {} - #405

Merged
sumitanvekar merged 3 commits into
ExpediaGroup:mainfrom
gsi85:fix/keepalive-deadlock-on-idle
Jul 30, 2026
Merged

sumitanvekar merged 3 commits into
ExpediaGroup:mainfrom
gsi85:fix/keepalive-deadlock-on-idle

Conversation

@gsi85

@gsi85 gsi85 commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

📝 Description

When -exit-after-warmup is not set (the default), block() kept the process alive by parking the main goroutine on a bare select {}. This is not a run-forever primitive: after warm-up completes, every other goroutine winds down, leaving the empty select as the only remaining goroutine. Go's runtime then aborts the process with fatal error: all goroutines are asleep - deadlock! the moment it goes idle — crash-looping the container even though it has already written its probe files. This reproduces on recent Go toolchains and matches the stack trace in #366 (cmd/root.go, block()); older toolchains only lengthened the fuse.

This replaces the bare select with a wait on SIGINT/SIGTERM. A signal receive is externally wakeable, so the deadlock detector never fires, and the process now shuts down gracefully when the orchestrator sends a termination signal. The -exit-after-warmup behaviour is unchanged.

Reproduce (before this change): build with a recent Go toolchain and run so that no warm-up requests are sent, e.g.

CGO_ENABLED=0 go build -o mittens . && ./mittens -max-readiness-wait-seconds 1

→ fatal error: all goroutines are asleep - deadlock!. After this change the process stays up and exits cleanly on Ctrl-C / SIGTERM.

New unit tests cover the -exit-after-warmup short-circuit and that the keep-alive wait unblocks on a signal. go vet ./..., go build, and go test ./... all pass locally.

⚠️ Why this surfaces now

The deadlock is only armed when Mittens is built with Go 1.22 or newer: a runtime scheduler change in 1.22 lets the deadlock detector fire once the process is genuinely idle. Earlier Go versions kept a background thread alive that masked it — which is why published release images (built with older Go) have not shown it, and why #366 could sit un-triaged for so long.

Crucially, main already declares go 1.24.0 in go.mod (bumped via #389), and that has not yet been in a tagged release. So a build from current main — i.e. the next official release — will ship this crash by default. Landing the fix before that release avoids regressing every consumer the moment they pick up the new version.

🔗 Related Issues

Fixes #366

After warm-up, `block()` parked the main goroutine on a bare `select {}` to
keep the container alive when `-exit-after-warmup` is false. This is not a
run-forever primitive: once warm-up completes every other goroutine winds
down, leaving the empty select as the only goroutine. Go's runtime treats
that as a deadlock and aborts the process with "all goroutines are asleep -
deadlock!" the moment it goes idle, crash-looping the container after it has
already written its probe files. The crash surfaces when the binary is built
with newer Go toolchains; older ones only lengthened the fuse.

Block on SIGINT/SIGTERM instead. A signal receive is wakeable, so the
deadlock detector never fires, and the process now shuts down gracefully when
the orchestrator sends a termination signal. `-exit-after-warmup` behaviour is
unchanged. Adds tests covering the exit-after-warmup short-circuit and that
the keep-alive wait unblocks on a signal.

Fixes ExpediaGroup#366

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gsi85
gsi85 requested a review from a team as a code owner July 29, 2026 09:10
@gsi85 gsi85 mentioned this pull request Jul 29, 2026
Comment thread cmd/root.go
if opts.ExitAfterWarmup {
return
}
sig := make(chan os.Signal, 1)

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.

Could you test this in a kubernetes environment and confirm the behaviour is as expected?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added TestKeepAliveExitsGracefullyOnSigterm (1ebb539) that reproduces the pod scenario at process level: it runs the built binary with the target never becoming ready (so warm-up sends nothing and the process goes idle — the exact condition that used to deadlock), asserts the process stays up, then sends SIGTERM (what kubelet delivers on pod termination) and asserts a clean exit (status 0). Before the fix that idle state aborts with all goroutines are asleep - deadlock!; after it, the container stays up and exits 0 on SIGTERM. Runs in ~3.5s locally and in CI.

We've also already validated the behaviour on live pods in our own Kubernetes environment. Happy to go into more detail over internal channels if that would help.

Comment thread cmd/root_test.go Outdated
@@ -0,0 +1,83 @@
//Copyright 2019 Expedia, Inc.

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.

could you move these tests into test/root_test.go. I know there other test files in the same package and we will clean them up too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1ebb539 — moved into test/root_test.go as TestKeepAliveExitsGracefullyOnSigterm. The old cmd tests reached unexported helpers (block/opts), so rather than move them verbatim I reworked them into a black-box test that builds and runs the binary and drives the real signal path, and removed cmd/root_test.go. I also simplified block() back to the inline signal wait since the test seam is no longer needed.

Laszlo Sisa and others added 2 commits July 29, 2026 13:13
Replace the cmd-package unit tests (which reached unexported helpers) with a
black-box test in test/root_test.go that builds and runs the binary, confirms
it stays alive once idle, and exits cleanly on SIGTERM — the signal Kubernetes
sends on pod termination. Simplify block() back to the inline signal wait now
that the test seam is no longer needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previous run hit an unrelated flaky test (gRPC reflection); no code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sumitanvekar
sumitanvekar merged commit 3f497d3 into ExpediaGroup:main Jul 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Mittens Deadlock error

2 participants