Repository navigation
Conversation
`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]>
|
Reproducer for #14404, attached as It is a deterministic Go unit test inside the Talos tree. It needs only the Go toolchain the checkout's Run it against a clean checkout: ./run.sh /path/to/a/clean/talos/checkoutOne run, two passes. Pass 1 uses the tree as checked out and the test fails — 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 Last run today against a clean checkout of The The fix, together with the test and a |
|
Closing this until the actual bug report #14404 provides enough details to understand the root cause. |
Pull Request
Closes #14404
What? (description)
stopServicesends withon the caller's context —
internal/app/machined/pkg/system/system.go#L408. The 30 s deadline declared earlier in the same function,#L381-L383, isshutdownCtx, and it is used only forallDeps.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.gocontains neithercontext.WithTimeoutnorcontext.WithDeadline, andrunTaskpasses 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, soStopandStopWithRevDepenencieskeep 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:
GracefulShutdownTimeoutpluskillWaitTimeout), 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, addsShutdownRequest.Modewith the same shape asRebootRequest.Mode, so thattalosctl shutdown -m forcepowers a node off without stopping its services first, exactly astalosctl reboot -m forcealready reboots without stopping them (#11782, #12049).talosctl shutdown's existing--forceskips 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 ownbufconfiguration; 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
stopServicesinstopAllPhaselist—umount,unmountBind,unmountPromotable,unmountSystem,volumeFinalize,stopEverythingand 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,
cricould not be stopped, andtalosctl shutdownnever completed.Every neighbouring wait on the same path is already bounded —
stopAndRemoveAllPodscaps its CRI work at three minutes (#L929), the process runner escalates to SIGKILL afterGracefulShutdownTimeout(process.go#L487), and the containerd runner bounds the post-SIGKILL reap atkillWaitTimeout(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.gostarts a service whose runner does not return when its context is cancelled — the stand-in for a shim that will not die — and asserts thatShutdownreturns. It fails onmainafter its own 40 s deadline and passes with the fix, taking the 30 s bound: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
mainat7f5327dwith Go 1.26.8: unpatched FAIL, patched PASS, checkout restored.go build ./...passes on the branch;gofmt -lis silent andgo vetis clean forinternal/app/machined/pkg/system/...,internal/app/machined/pkg/runtime/v1alpha1/...,cmd/talosctl/...andpkg/machinery/client/....Acceptance
Please use the following checklist:
make conformance)make fmt)make lint)make docs)make unit-tests)Rather than tick boxes for targets I did not run, here is exactly what was run, in
golang:1.26.8under Docker withGOFLAGS=-mod=readonly(go.modandgo.sumuntouched):go build ./...— passes.gofmt -loverinternal/app/machined/pkg/system/,internal/app/machined/pkg/runtime/v1alpha1/,cmd/talosctl/cmd/talos/andpkg/machinery/client/— silent.go vet ./internal/app/machined/pkg/system/... ./internal/app/machined/pkg/runtime/v1alpha1/... ./cmd/talosctl/...andgo vet ./client/...inpkg/machinery— clean.go test ./internal/app/machined/pkg/system/ -run TestSystemServicesSuite/TestShutdownBoundedWithStuckService— the two-pass result quoted above.conform enforcefromghcr.io/siderolabs/conform:latest, the imagemake conformanceuses — DCO, conventional commit, header length, header case, header last character, imperative mood, spellcheck, commit body and the license file header all pass.GPG Identityfails, as it does on every contribution from outside thesiderolabsorganisation, because the keyring it verifies against is the organisation's.Not run:
make lintin full (golangci-lint, vulncheck, deadcode, protobuf and markdown linters),make unit-tests,make docs, and any target that builds a Talos image. Thewebsite/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.