Skip to content

fix: bound the wait for services to stop on shutdown - #14405

Closed
daixtrose wants to merge 2 commits into
siderolabs:mainfrom
daixtrose:fix/shutdown-bounded-wait
Closed

daixtrose wants to merge 2 commits into
siderolabs:mainfrom
daixtrose:fix/shutdown-bounded-wait

Conversation

@daixtrose

Copy link
Copy Markdown

Pull Request

Closes #14404

What? (description)

stopServices ends with

return conditions.WaitForAll(stoppedConds...).Wait(ctx)

on the caller's context — internal/app/machined/pkg/system/system.go#L408. The 30 s deadline declared earlier in the same function, #L381-L383, is shutdownCtx, and it is used only for allDeps.Wait(shutdownCtx) at #L398 — the wait for each service's reverse dependencies. The final wait, for the services themselves, has no deadline, and the callers on this path do not supply one: v1alpha1_controller.go contains neither context.WithTimeout nor context.WithDeadline, and runTask passes the sequence context straight through.

The first commit names the timeout the function already declares (stopServicesTimeout, still 30 s) and applies it to the final wait as well as the reverse-dependency wait. On expiry it logs which services did not stop and returns, so the sequence carries on to unmount the filesystems and power the machine off. A cancellation that comes from the caller is still returned as an error, so Stop and StopWithRevDepenencies keep reporting a genuine cancellation to their callers.

Deliberately, the expiry is not propagated as a task error: a task error aborts the sequence in Controller.run, which would leave the node up just as surely as the hang did. Powering the machine off matters more than stopping every service cleanly.

One point for reviewers: 30 s is the number the function already chose, and reusing it keeps the diff to what the bug requires. It does now bound the whole call rather than the final wait alone, so a stop that legitimately needs longer than 30 s in total will be cut short — with its services named in the log. If you would rather give the final wait its own, larger budget (the per-service worst case is around 45 s: GracefulShutdownTimeout plus killWaitTimeout), that is a one-line change and I am happy to make it.

The second commit, feat: add a force mode to shutdown, mirroring reboot's, adds ShutdownRequest.Mode with the same shape as RebootRequest.Mode, so that talosctl shutdown -m force powers a node off without stopping its services first, exactly as talosctl reboot -m force already reboots without stopping them (#11782, #12049). talosctl shutdown's existing --force skips the cordon/drain, not the service stop, so an operator facing a node whose services will not stop had nothing to reach for. The generated protobuf code was regenerated with the repository's own buf configuration; I confirmed it reproduces the checked-in files byte-identically before making the change. Happy to split that into its own pull request if you would rather take the fix on its own.

Why? (reasoning)

A single service that does not stop blocks the shutdown sequence indefinitely. What is lost is everything after stopServices in stopAllPhaselist — umount, unmountBind, unmountPromotable, unmountSystem, volumeFinalize, stopEverything and the power-off task, v1alpha1_sequencer.go#L509-L557. The machine stays up with its filesystems mounted until someone removes power.

I hit this on a bare-metal node running KubeVirt: the containerd shim backing a VirtualMachineInstance would not go away, cri could not be stopped, and talosctl shutdown never completed.

Every neighbouring wait on the same path is already bounded — stopAndRemoveAllPods caps its CRI work at three minutes (#L929), the process runner escalates to SIGKILL after GracefulShutdownTimeout (process.go#L487), and the containerd runner bounds the post-SIGKILL reap at killWaitTimeout (containerd.go#L347-L354), whose comment states the principle this change applies one level up. That is what makes this one call stand out.

Tests

internal/app/machined/pkg/system/shutdown_bounded_test.go starts a service whose runner does not return when its context is cancelled — the stand-in for a shim that will not die — and asserts that Shutdown returns. It fails on main after its own 40 s deadline and passes with the fix, taking the 30 s bound:

--- FAIL: TestSystemServicesSuite/TestShutdownBoundedWithStuckService (40.04s)
        Error:      Shutdown did not return
        Messages:   still stopping services after 40s; a service which does not stop blocks the shutdown sequence forever

--- PASS: TestSystemServicesSuite/TestShutdownBoundedWithStuckService (30.07s)
    gave up waiting for stuck-shim to stop after 30s, continuing

It waits out a real clock in both directions, which is unavoidable for a missing-deadline bug, so it skips under -short.

A standalone two-pass reproducer that applies only the production part of the change to a clean checkout and reverts it afterwards is attached to the issue, and was rerun today against main at 7f5327d with Go 1.26.8: unpatched FAIL, patched PASS, checkout restored.

go build ./... passes on the branch; gofmt -l is silent and go vet is clean for internal/app/machined/pkg/system/..., internal/app/machined/pkg/runtime/v1alpha1/..., cmd/talosctl/... and pkg/machinery/client/....

Acceptance

Please use the following checklist:

  • you linked an issue (if applicable)
  • you included tests (if applicable)
  • you ran conformance (make conformance)
  • you formatted your code (make fmt)
  • you linted your code (make lint)
  • you generated documentation (make docs)
  • you ran unit-tests (make unit-tests)

Rather than tick boxes for targets I did not run, here is exactly what was run, in golang:1.26.8 under Docker with GOFLAGS=-mod=readonly (go.mod and go.sum untouched):

  • go build ./... — passes.
  • gofmt -l over internal/app/machined/pkg/system/, internal/app/machined/pkg/runtime/v1alpha1/, cmd/talosctl/cmd/talos/ and pkg/machinery/client/ — silent.
  • go vet ./internal/app/machined/pkg/system/... ./internal/app/machined/pkg/runtime/v1alpha1/... ./cmd/talosctl/... and go vet ./client/... in pkg/machinery — clean.
  • go test ./internal/app/machined/pkg/system/ -run TestSystemServicesSuite/TestShutdownBoundedWithStuckService — the two-pass result quoted above.
  • conform enforce from ghcr.io/siderolabs/conform:latest, the image make conformance uses — DCO, conventional commit, header length, header case, header last character, imperative mood, spellcheck, commit body and the license file header all pass. GPG Identity fails, as it does on every contribution from outside the siderolabs organisation, because the keyring it verifies against is the organisation's.

Not run: make lint in full (golangci-lint, vulncheck, deadcode, protobuf and markdown linters), make unit-tests, make docs, and any target that builds a Talos image. The website/content/v1.15/reference/ changes in the branch were generated when the commit was made, not regenerated today.

Disclosure

I used Claude (Anthropic) as an assistant while investigating this and preparing the patch; I have reviewed and tested everything I am submitting, and I take responsibility for it.

daixtrose and others added 2 commits September 17, 2026 23:35
`stopServices` ends with

	return conditions.WaitForAll(stoppedConds...).Wait(ctx)

on the caller's context. The 30 s deadline declared earlier in the same
function covers only the wait for each service's reverse dependencies;
the final wait for the services themselves has no deadline at all, and
the callers on this path -- Controller.runTask, reached from the shutdown
and reboot sequences -- attach none either.

One service which does not stop therefore blocks the sequence forever.
On a physical node the phases which follow are the ones that unmount the
filesystems and power the machine off, so the node stays up with its
disks mounted until someone pulls the power. This was seen on a node
running KubeVirt: the containerd shim backing a VirtualMachineInstance
would not go away, so `cri` could not be stopped and `talosctl shutdown`
never completed.

Bound the whole call with the timeout that the function already declares,
now named `stopServicesTimeout`, and on expiry log which services did not
stop and let the sequence carry on -- powering the machine off matters
more than stopping every service cleanly. A cancellation coming from the
caller is still reported as an error.

Signed-off-by: Markus Werle <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
`talosctl reboot --mode force` skips the graceful stop entirely and
reboots, which is the escape hatch for a node whose services will not
stop. `talosctl shutdown` had no equivalent: its only `--force` flag
skips the cordon/drain, not the service stop, so an operator facing a
node that will not stop its services had nothing to reach for.

Add `ShutdownRequest.Mode` with the same shape as `RebootRequest.Mode`,
and in the shutdown sequence go straight to the power-off phase when it
is FORCE, the way `Sequencer.Reboot` does. `talosctl shutdown -m force`
now mirrors `talosctl reboot -m force`.

Signed-off-by: Markus Werle <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
@github-project-automation github-project-automation Bot moved this to To Do in Planning Sep 17, 2026
@talos-bot talos-bot moved this from To Do to In Review in Planning Sep 17, 2026
@daixtrose

Copy link
Copy Markdown
Author

Reproducer for #14404, attached as [talos-shutdown-repro.zip](https://github.com/user-attachments/files/32356865/talos-shutdown-repro.zip).

It is a deterministic Go unit test inside the Talos tree. It needs only the Go toolchain the checkout's go.work asks for: no cluster, no Kubernetes, no KubeVirt, no root, and nothing to power off.

talos-shutdown-repro/
├── README.md                   what the defect is, and what the test stands in for
├── run.sh                      the two-pass demonstration
├── shutdown_bounded_test.go    the test; copied into internal/app/machined/pkg/system/
└── fix.patch                   the fix, production code only, no test changes

Run it against a clean checkout:

./run.sh /path/to/a/clean/talos/checkout

One run, two passes. Pass 1 uses the tree as checked out and the test fails — Shutdown never returns and the test gives up after its own 40 s deadline. Pass 2 applies fix.patch and the test passes, returning after the 30 s bound and logging which service did not stop. run.sh reverts the patch and removes the copied test on exit, so the checkout is left exactly as it was found; it refuses to start if the checkout has uncommitted changes.

Each pass takes about half a minute, because each waits out a real clock. That is unavoidable for a missing-deadline bug: proving the deadline absent or present means letting it run.

The field trigger was a KubeVirt VirtualMachineInstance whose containerd shim would not go away, so the cri service could not be stopped. This is not a VMI test. It stands in for the VMI with a service whose runner does not return when its context is cancelled — the only property of that situation which stopServices can observe — which is what makes it deterministic and free of dependencies.

Last run today against a clean checkout of main at 7f5327d, Go 1.26.8:

PASS 1 of 2: unpatched tree -- the bug should REPRODUCE (test FAILS)
    Error:      	Shutdown did not return
    Messages:   	still stopping services after 40s; a service which does not stop blocks the shutdown sequence forever
--- FAIL: TestSystemServicesSuite/TestShutdownBoundedWithStuckService (40.04s)

PASS 2 of 2: patched tree -- the bug should be GONE (test PASSES)
2026/09/13 13:10:27 gave up waiting for stuck-shim to stop after 30s, continuing
--- PASS: TestSystemServicesSuite/TestShutdownBoundedWithStuckService (30.07s)

RESULT
unpatched: FAIL (bug reproduced)   patched: PASS (bug fixed)

The README.md inside the zip records an earlier run of the same script against main at 67ae852 with Go 1.27.1, with the same outcome.

The fix, together with the test and a -m force escape hatch for talosctl shutdown mirroring talosctl reboot's, is on branch fix/shutdown-bounded-wait in my fork.

@smira

smira commented Sep 18, 2026

Copy link
Copy Markdown
Member

Closing this until the actual bug report #14404 provides enough details to understand the root cause.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

Shutdown sequence never finishes when a service does not stop: stopServices ends with an unbounded wait, so the node never powers off

2 participants