fix: [sc-106108] Bound the Windows service stop wait so a wedged service cannot hang an update - #99
Merged
mlataza merged 2 commits intoAug 11, 2026
Conversation
…ice cannot hang an update windowsService.Stop() polled the service state every 250 ms in an unbounded loop with no deadline and no escape hatch. A service that never reached Stopped - a wedged agent process, or Windows holding the service in StopPending behind a stuck operation - therefore hung every caller of Stop(), all of which are blocking: runUpdate, runConfig, and runUninstall. Because the auto-updater runs unattended every 48 hours, the visible symptom was a device that went offline during a routine update with no error logged anywhere, because the installer was blocked before it logged anything further, and recovery needed hands on the endpoint. An interactive uninstall simply hung with no output. Stop() now waits at most serviceStopTimeout (5 minutes) and otherwise returns an error naming the service, the deadline, and the last observed state. The bound is deliberately generous: it exists to catch a wedge, not to race a normal shutdown, so a healthy agent draining in-flight commands - which command_timeout_seconds can legitimately stretch to minutes - is never cut short. The loop distinguishes "still stopping" from "will not stop". StopPending keeps being polled to the deadline, while a state the service cannot reach Stopped from fails immediately rather than burning the full deadline. Running and StartPending count as still-stopping, because ControlService reports the status the service last published and can still show the pre-stop state before the service thread picks the control up; rejecting those would break healthy stops. Callers abort with the installation intact instead of proceeding as if the stop succeeded. Update does not overwrite the agent executable or config file, since the old process may still hold the executable open, and says so in the log. Uninstall does not delete the registration or any files out from under a live process, and states plainly that nothing was removed. Fix a related bug in runConfig, which reassigned err with Close()'s result before wrapping it, so a stop failure returned an error formatted from a nil wrapped error and the actual reason vanished - exactly the reason-invisible failure this ticket is about. Close errors are now logged separately and the stop error is returned intact. Unit tests use a fake clock that advances only when the code under test sleeps, so bounded waits are deterministic and instant: a prompt stop (unchanged), a service stuck in StopPending past the deadline, one that reaches Stopped on the last poll before the deadline (no spurious error), Query failing mid-poll, an unexpected terminal state failing with zero polls, and Running being polled rather than rejected. Caller tests assert runUpdate writes no files and neither starts nor deletes the service after a failed stop, and that runUninstall removes no directories and leaves the registration in place.
…ation workflow The bounded stop is unit tested against a mock service handle, which cannot show that a real service pinned in SERVICE_STOP_PENDING now aborts an update instead of hanging it. Add a Windows-only scenario that produces that state on a runner and asserts the endpoint is left recoverable. The wedge is a new fixture, test/wedgedservice: a service that accepts the stop control, returns from its handler, reports StopPending, and never stops until a release file appears. It has to be purpose-built. Killing the agent makes the SCM report Stopped almost at once, which is the happy path, and suspending its threads makes the SCM fail the control request outright rather than exercising the wait - so neither reaches the code under test. Which wedge it is does not matter: what is under test is the bound on the wait, not how the agent got stuck. The wedged-service action registers the fixture under the agent's own service name by swapping that service's ImagePath, so the installed executable and config are untouched and can be hashed before and after. The swap goes through the registry rather than sc.exe, because the agent's command line carries quoted paths and pushing those back through sc.exe's "keyword= value" parsing is a needless way to corrupt the registration the scenario has to restore; the restore reads the value back, since a silently failed one would leave the fixture reaching Running, which no later step could tell apart from a recovered agent. The scenario hashes the installed files, wedges the service, and asserts that --update returns having logged the service name, the deadline and the last observed state, that it did not proceed to write anything, and that both hashes are unchanged. It then re-wedges and asserts --uninstall aborts with nothing removed and the registration and installed directories intact, restores the real agent, and confirms it resubscribes and that an ordinary update then succeeds end to end - the bound must only catch a wedge. The re-wedge in the middle is load-bearing. After a failed stop the service sits in StopPending, and the agent treats only Running as active, so an uninstall issued at that point would skip the stop entirely instead of exercising it. That gap in IsActive() is real but out of scope here; it needs a state accessor on the cross-platform Service interface and is worth its own ticket. The invoker is the integration binary, whose stop deadline is overridden to 25s by a new ldflags-injected stopTimeoutOverrideStr, mirroring the existing sasTokenLifetimeOverrideStr. Each wedged stop then costs seconds instead of the production five minutes, which stays the default for released builds and is covered by unit tests: the resolution order is per-instance seam, then override, then constant, and a garbage or non-positive override falls back rather than disabling the bound. Verified locally as far as a macOS host allows: the workflow and action YAML parse, every new PowerShell block parses under pwsh, the assertion logic passes and fails correctly against a stubbed agent, and the substrings the assertions look for are the real hclog output. The SCM interactions themselves - the fixture reaching Running under a swapped ImagePath, StopPending being what Query() observes, and the restore - can only run on windows-latest, so every failure path dumps the fixture log and sc query output.
mlataza
deleted the
bug/sc-106108/bound-the-windows-service-stop-wait-so-a-wedged
branch
August 11, 2026 12:43
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
windowsService.Stop()polled the service state every 250 ms in an unbounded loop — no deadline, no iteration cap, no error path for "took too long":Every caller of
Stop()is a blocking, user- or updater-initiated operation:runUpdate,runConfig, andrunUninstall. So a service that never reachesStopped— a wedged agent process, or Windows itself holding the service inStopPendingbehind a stuck operation — hung the caller forever.The auto-updater runs unattended every 48 hours, which is what makes this customer-visible. If it stops a service that then fails to reach
Stopped, the installer blocks before it logs anything further: the endpoint drops offline, the new binary is never written (update.goreaches the executable copy only downstream of the hung call), and the customer sees a device that went offline during a routine update with no error anywhere. Recovery needs hands on the endpoint. An operator running an interactive uninstall just sees the command hang with no output, and killing it can leave a partially removed installation.Latent in all released versions through v1.5.0 and current
main. Windows only — the Unix service implementations do not use this polling loop. No customer reports isolated to this cause; found via a reliability audit.Fix
Stop()now waits at mostserviceStopTimeout(5 minutes) and otherwise returns an error naming the service, the deadline, and the last observed state:The bound is deliberately generous, and the constant's doc comment says why: it exists to catch a wedge, not to race a normal shutdown. A healthy agent finishes teardown in seconds, but it first drains whatever commands are in flight, and an operator can set
command_timeout_secondshigh enough that a single command legitimately runs for minutes. Five minutes sits far above any healthy shutdown while still failing an unattended update the same day it runs.The loop distinguishes "still stopping" from "will not stop".
StopPendingkeeps being polled to the deadline; a state the service cannot reachStoppedfrom returns immediately rather than burning the full deadline.RunningandStartPendingcount as still-stopping, becauseControlServicereports the status the service last published and can still show the pre-stop state before the service thread picks the control up — rejecting those would break healthy stops.Callers
All three already returned on a
Stop()error, so the fix there is about what they say and one real bug:runUpdateaborts before any write and logs that the executable and config were not modified. This matters beyond tidiness: the old process may still hold the executable open, so overwriting it would either fail or leave a half-updated install. Leaving everything in place keeps the endpoint recoverable.runUninstallaborts before deleting the registration or any files, and logsUninstall aborted; nothing was removedwithservice_registration=intact installed_files=intact.runConfighad a latent bug of exactly the kind this ticket is about. It reassignederrwithClose()'s result before wrapping it, so a stop failure returned an error formatted from anilwrapped error and the actual reason vanished. Close errors are now logged separately and the stop error is returned intact.Testing
Unit tests use a fake clock that advances only when the code under test sleeps, so bounded waits are deterministic and instant: a prompt stop (unchanged behavior), a service stuck in
StopPendingpast the deadline, one that reachesStoppedon the last poll before the deadline (no spurious error),Queryfailing mid-poll, an unexpected terminal state failing with zero polls,Runningbeing polled rather than rejected, and the timeout resolution order. Caller tests assertrunUpdatewrites no files and neither starts nor deletes the service after a failed stop, and thatrunUninstallremoves no directories and leaves the registration.golangci-lint run0 issues. Coverage 88.4% locally (darwin, so the Windows-only lane is the one that counts — the PR's own coverage job covers it).Integration test — a new Windows-only scenario, and the part worth a careful look.
The wedge is a purpose-built fixture,
test/wedgedservice: a service that accepts the stop control, returns from its handler, reportsStopPending, and never stops until a release file appears. It has to be purpose-built. Killing the agent makes the SCM reportStoppedalmost at once, which is the happy path; suspending its threads makes the SCM fail the control request outright rather than exercising the wait. Neither reaches the code under test. Which wedge it is does not matter — the ticket's own framing is that any wedge will do, because what is under test is the bound on the wait and not how the agent got stuck..github/actions/wedged-serviceregisters the fixture under the agent's own service name by swapping that service'sImagePath, so the installed executable and config are untouched and can be hashed before and after. The swap goes through the registry rather thansc.exe, because the agent's command line carries quoted paths and pushing those back throughsc.exe'skeyword= valueparsing is a needless way to corrupt the registration the scenario has to restore.mode=restorereads the value back afterwards — a silently failed restore would leave the fixture reachingRunning, which no later step could tell apart from a recovered agent.The invoker is the integration binary, whose stop deadline is overridden to 25s by a new ldflags-injected
stopTimeoutOverrideStr, mirroring the existingsasTokenLifetimeOverrideStr. Each wedged stop then costs seconds instead of five minutes; the production default is unchanged and covered by unit tests (resolution order is per-instance seam → override → constant, and a garbage or non-positive override falls back rather than disabling the bound).Measured on
windows-latest(run 31451274395, green first attempt, ~95s for the whole scenario):--updateabortsdid not stop within 25s: last observed state StopPending5AC33FB4…and config4B7354CA…unchanged--uninstallabortsnothing was removed, registration + all 3 directories intactAgent installed to+Service started— the bound only catches a wedgeThe
StopPendingresult is the one I could not predict from a macOS host: it confirmsQuery()observesStopPending, so the polling wait is what ran, not the fast-fail branch.Docs
README gains a "Bounded Windows Service Stop (Windows only)" section covering the failure mode, the deadline and why it is sized as it is, the still-stopping vs will-not-stop distinction, what each caller does on abort, and the recovery procedure.
CLAUDE.md'sinternal/serviceentry points at it.Reviewer notes
StopPending, andIsActive()treats onlyRunningas active — so an uninstall issued at that point skips the stop entirely and would delete files out from under a live process. The scenario starts a fresh wedged run so the uninstall meets aRunningservice, as an operator would. Fixing the underlying gap needs a state accessor on the cross-platformServiceinterface (andIsActive's current meaning is the right one for diagnostic mode), so I left it out of scope. Worth its own ticket.--updatestill exits 0. The abort is reported through the log, not the exit status, so an unattended caller can only tell from the log. Not in the ACs and not changed here, but it is the kind of thing that would make the updater's own error handling possible later.runConfigerror-wrapping fix is technically beyond the ticket's ACs, but it is the same class of defect (the reason a stop failed becoming invisible) on the third caller, and QA step 7 covers that path.if: matrix.os == 'windows-latest', and the ubuntu/macOS lanes are untouched by the agent-side change, but I have not confirmed a greenallrun if you would like one before merge.test/wedgedservicesits behind theintegrationbuild tag so its statements stay out ofgo test ./...and cannot count against the coverage threshold; it carries a non-Windows stub file sogo build -tags integration ./...still works on Linux and macOS, where the package would otherwise have no buildable files.🤖 Generated with Claude Code