fix: [sc-112409] Report StopPending checkpoints while the Windows service stops - #121
Merged
Merged
Conversation
…vice stops windowsRunner.Execute published StartPending at entry and Running once the agent was up, but on receiving svc.Stop/svc.Shutdown it only signalled its internal stop channel and then published Stopped after the agent had finished shutting down. The service went from Running straight to Stopped with a silent gap in between, and the SCM reads that silence as a hang: it expects a StopPending acknowledgment followed by an incremented CheckPoint within the published WaitHint, and logs a service that goes quiet as not responding (events 7009/7043). The gap is not short — shutdown drains in-flight command workers, each bounded by command_timeout_seconds (30 minutes by default), then waits on plugin shutdown — so a stop, restart, auto-update or uninstall issued mid-command looked wedged for its whole duration. The stop control is now acknowledged immediately, before the runner starts draining, with StopPending at CheckPoint 1 and a 30-second WaitHint, and reportStopProgress republishes StopPending with an incrementing CheckPoint every stopPendingCheckpointInterval (10 seconds) until the runner returns. The WaitHint is three times the interval so a merely late checkpoint on a busy endpoint does not read as a wedge either. All of Execute's status updates route through one statusReporter that enforces the SCM's lifecycle order. That is load-bearing, not tidying: Execute reports from three goroutines and the dispatcher stops reading the response channel once Stopped arrives, so a checkpoint waking up late would block its goroutine forever on a channel nobody reads. The reporter also drops Running once a stop has been acknowledged, since a stop control can land before startup finishes and reporting Running after StopPending tells the SCM the service went healthy again. Stopped is still published exactly once, after host.runner.Execute returns, and is always the last status sent. The caller-side bounded wait in Stop() is unchanged: this fixes what the service reports during that wait, not the wait.
mlataza
force-pushed
the
bug/sc-112409/windows-service-stop-never-reports
branch
from
September 11, 2026 12:01
268fdcd to
c99f2fc
Compare
mlataza
deleted the
bug/sc-112409/windows-service-stop-never-reports
branch
September 11, 2026 12:37
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
windowsRunner.ExecutepublishedStartPendingat entry andRunningonce the agent was up, but on receivingsvc.Stop/svc.Shutdownit only signalled its internal stop channel and returned —Stoppedwas published afterhost.runner.Executehad fully returned. The service therefore went fromRunningstraight toStoppedwith a silent gap in between, and never sentsvc.Status{State: StopPending}at all.Windows reads that silence as a hang. The SCM expects a stopping service to acknowledge the control with
StopPendingand then keep showing progress — an incrementedCheckPointwithin theWaitHintthe service published — and logs a service that goes quiet as not responding (events 7009/7043), withservices.mscornet stopreporting failure while the process is still legitimately shutting down.The gap is not short. The agent's shutdown drains in-flight command workers, each of which an operator can let run for as long as
command_timeout_seconds(30 minutes by default), then waits on plugin shutdown. A stop, restart, auto-update or uninstall issued while a long command was in flight looked like a wedged service for its whole duration — enough to trigger alerting or an external forced kill mid-update — even though the agent was following its own bounded shutdown sequence correctly.No customer report; found by code review while auditing the service-stop path alongside the caller-side bound from sc-108855 (README §"Bounded Windows Service Stop"). That ticket bounded how long the updater waits for
Stopped; this is the service's side of the same stop.Fix
1. The stop control is acknowledged immediately, before the runner starts draining:
StopPendingwithCheckPoint1 and a 30-secondWaitHint. The report goes out beforestop <- struct{}{}, so the SCM has the acknowledgment in hand before any shutdown work begins.2.
reportStopProgressrepublishesStopPendingwith an incrementingCheckPointeverystopPendingCheckpointInterval(10 seconds) until the runner returns — for as long as the shutdown actually takes, not once at the start.stopPendingWaitHintis three times the interval, so a merely late checkpoint on a busy endpoint does not read as a wedge either. Both constants carry the reasoning in their doc comments; the interval is injectable (windowsRunner.checkpointInterval) so the periodic behavior is unit-testable without waiting real seconds.3. All of
Execute's status updates route through onestatusReporterthat enforces the SCM's lifecycle order. This is load-bearing rather than tidying —Executereports from three goroutines, and the dispatcher stops reading the response channel onceStoppedarrives:Stopped. A checkpoint goroutine waking up late would otherwise block forever on a channel nobody reads, and would contradict the terminal status. The mutex also makes the ordering deterministic: a checkpoint that wins the race is published beforeStopped, one that loses is dropped.Runningis dropped once a stop has been acknowledged. A stop control can land before startup finishes, and publishingRunningafterStopPendingtells the SCM the service went healthy again and re-advertises the controls it accepts. Pre-existing hole, cheap to close once every send went through one place.4.
Stoppedis still published exactly once, afterhost.runner.Executereturns, and is always the last status sent — unchanged from today apart from the ordering guarantee.cancelStop()ends the checkpoints first so the SCM does not see progress reported after the service has stopped.No change to the caller-side bounded wait.
Stop()'s 5-minute poll loop is untouched; this fixes what the service reports during that wait. A shutdown that genuinely wedges still fails on the same bound with the same error.Documented in the README under "Reporting Stop Progress to the SCM (Windows only)", directly after the existing "Bounded Windows Service Stop" section it complements, and in CLAUDE.md's
internal/service/entry.Tests
internal/service/service_windows_test.go.mockRunnergainedstopping/shutdownchannels so a test can hold the runner in the middle of its shutdown, the way a draining command worker or a slow plugin does in production.TestWindowsRunner_Execute_AcknowledgesStopBeforeStopped— the AC's core assertion: at least oneStopPendingprecedesStopped, carries a non-zeroWaitHintandCheckPoint, nothing followsStopped, exactly oneStopped, and it is last.TestWindowsRunner_Execute_CheckpointsWhileShuttingDown— holds the runner mid-shutdown with a 1 ms checkpoint interval, asserts checkpoints keep arriving with strictly incrementingCheckPoint, then releases the shutdown and confirmsStoppedstill terminates the sequence.TestStatusReporter_DropsReportsAfterStoppedandTestStatusReporter_DropsRunningOnceStopping— the two lifecycle rules directly.TestStopPendingStatus_WaitHintExceedsCheckpointInterval— invariant guard, so tuning one constant without the other cannot silently produce aWaitHintthe checkpoints miss._SendsRunningThenStopped,_ShutdownAlsoStopsand_ReturnsExitCodeupdated for the newStopPendingin the sequence.go build ./...,go vet ./...,gofmtandgolangci-lint run ./...are clean for the host GOOS and forGOOS=windows;go test ./...passes. The new tests are//go:build windowsand compile underGOOS=windows go test -cbut cannot execute on the dev machine — CI'stest.ymlcoverswindows-latest.Because of that, the concurrency in this change was also verified locally by running the reporter, the checkpoint loop and
Execute's goroutine structure through a standalone-raceharness (a stubsvc.Status, same logic, repeated runs): fast shutdown produces exactlyStopPending(cp 1, 30000 ms) →Stopped; a held shutdown produces checkpoints 1..5 strictly incrementing; nothing is sent afterStopped; no races reported.QA
sc queryex <service>in a loop that the service reportsSTOP_PENDINGthroughout the shutdown — withCHECKPOINTadvancing — instead of sitting atRUNNINGand jumping straight toSTOPPED.Stop()gives up on the same 5-minute bound with the samedid not stop within 5m0serror, and update/uninstall still abort without touching files.STOP_PENDINGbeing briefly visible.