fix: [sc-106109] Wait for actual process exit before replacing or deleting agent files - #100
Merged
mlataza merged 1 commit intoAug 12, 2026
Conversation
…eting agent files Install, update and uninstall waited for the old agent process to exit by sleeping a fixed 5 seconds and then acting regardless: runUpdate overwrote the agent executable in place, runUninstall deleted the installation directory. On a loaded endpoint the old process is frequently still alive at the 5 second mark - its shutdown legitimately drains in-flight commands, tears down MQTT and kills its plugin subprocesses one at a time - and Windows refuses to replace a running image, so the update failed with a sharing violation after the service was already stopped, leaving the device offline with nothing to retry. Uninstall had the mirror-image problem: files removed out from under a live process, leaving an installation that neither ran nor reinstalled. The four sleeps are replaced with a wait on real exit signals, all of which must clear: the service manager no longer reporting the service active, no process still executing the agent binary, and the executable no longer held open (a sharing violation on Windows, ETXTBSY on Linux). The three overlap on purpose - the file signal is what actually blocks the write on Windows, but macOS permits writing to a running image, and a service manager can report a service stopped while its process is still winding down. The wait returns on the first round of probes when the process is already gone, so a healthy update is faster than the unconditional sleep it replaces, and is bounded by a documented 2 minute deadline sized for a slow but legitimate shutdown. An elapsed poll interval is never by itself treated as evidence of an exit; a probe that cannot run at all is logged once at Warn and the remaining signals are used. Overrunning the deadline aborts before anything is written or deleted, logging what was still outstanding and for how long. Update then restarts the service it stopped - on any failure after the stop, not just this one - so a failed update no longer leaves an endpoint silently offline; install does the same; uninstall logs plainly that nothing was removed. The agent executable and config file are now written to a temp file in the destination directory and atomically renamed into place, mirroring the postback spool, so a failed or interrupted write leaves the previous file byte-identical instead of truncated. The re-registration path's sleep is replaced by polling until the deleted registration is actually reaped. exitTimeoutOverrideStr is overridable via -ldflags (25s in the integration build) so the deadline case can be observed in seconds, mirroring stopTimeoutOverrideStr.
mlataza
deleted the
bug/sc-106109/wait-for-actual-process-exit-before-replacing
branch
August 12, 2026 00:28
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
Install, update and uninstall all waited for the old agent process to exit by sleeping a fixed five seconds and then acting regardless:
Nothing checked whether the process had exited. After the sleep,
runUpdateoverwrote the agent executable in place andrunUninstalldeleted the installation directory.On a loaded endpoint the old process is frequently still alive at the five second mark — its shutdown legitimately drains the commands in flight, tears down MQTT, and kills its plugin subprocesses one at a time, each waiting out go-plugin's graceful-exit grace period. Windows will not replace a running image, so the write failed with a sharing violation after the service was already stopped, and the updater returned. The customer sees devices silently dropping offline after a release, disproportionately their busiest servers, with nothing retrying. Because it is timing- and host-dependent it looks intermittent and reads as a network problem. Uninstall had the mirror-image failure: files removed out from under a live process, leaving an installation that neither ran nor reinstalled.
This is a fixed-deadline race, not a rare one — the same slow hosts fail every time. Latent in all released versions through v1.5.0 and current
main. Hard failure on Windows (mandatory file locking); a subtler race on Linux and macOS.Fix
The four sleeps (
update.go×2,config.go,uninstall.go) are replaced with a wait on signals that are actual observations of the process. Three of them, all of which must clear:The ACs name the first and third. The process scan is there because the file probe is a no-op on macOS — I verified on darwin that opening a running executable for writing succeeds, where Linux returns
ETXTBSYand Windows a sharing violation. Without the scan, macOS would fall back to the service-manager signal alone, and a service manager can report a service stopped while its process is still winding down. The three overlap deliberately: the file signal is what actually blocks the write on Windows, the scan is what covers macOS and the wind-down window everywhere.An elapsed poll interval is never by itself treated as evidence of an exit.
Fast path costs nothing. The wait probes before it ever sleeps, so a healthy endpoint pays one round of probes — the update gets faster than the unconditional five second sleep, not slower.
Bounded at 2 minutes, and the constant's doc comment says why: it exists to catch a process that never exits, not to race a slow one. Sized for many workers, in-flight commands, and several plugin subprocesses. Overrun logs at
Errorwhat was still outstanding and for how long:A probe that cannot run is not evidence. A restrictive ACL or an unenumerable process table is logged once at
Warnand the remaining signals are used, rather than wedging every update on an endpoint where that probe can never succeed.Callers
runUpdateaborts before writing anything and leaves the installation fully intact. It now also restarts the service it stopped — on any failure after the stop, via a deferred recovery, not just this one — so a failed update no longer leaves the endpoint silently offline. If the failure comes after the registration was deleted (the--service-usernamepath) there is nothing to start, and it says so plainly instead of failing quietly.runUninstallwaits before deleting the registration or any files, and on overrun logsUninstall aborted; nothing was removed. The delete/wait order is swapped so the service handle still exists to be observed.runConfigwaits before deleting the existing registration and replacing the executable, and restarts the service it stopped. Its abort log is honest that the config file was already refreshed at that point — the installed agent and its registration are what had to be left alone.Non-destructive writes
The agent executable and the config file are written to a temp file in the destination directory and atomically renamed into place, mirroring
postback_spool.go:109-119. A failed or interrupted write leaves the previous file byte-identical rather than truncated: the endpoint keeps running the old agent instead of a binary that cannot start.FileSystemgainsRename,RemoveandExecutableInUsefor this.The re-registration path's second sleep is replaced by polling until the deleted registration is actually reaped. If it outlives its deadline the failure is logged and
Createis attempted anyway — aborting there would leave the endpoint with no registration at all, andCreatesurfaces the real conflict if there is one.Testing
Unit tests use a fake clock that advances only when the code under test sleeps, so a two minute deadline is burned instantly and deterministically. Covering exactly the ACs' four cases and then some: a process already gone (asserts zero waiting and a single probe), one that exits partway through, one that never exits (bounded, descriptive error, no destructive write), a failed commit leaving the original byte-identical, plus each probe failing independently, the nil-service handle case, deregistration, and the timeout resolution order. Caller-level tests assert
runUpdatewrites nothing and restarts the service on overrun, that the executable is only ever committed by renaming*.newinto place, and thatrunUninstallstarts removing only after the process is observed gone.ProcessRunningFromExecutableis tested for real against a re-executed child process — it has to be able to say both "running" and "gone", since a scan stuck on either answer would wedge every update or be the fixed sleep again.go test ./...green,golangci-lint run0 issues,GOOS=windows|linux go vetclean, coverage 88.6% (threshold 80).Integration — run 31501216206 on the head commit,
os=all. Every job green across Windows, Linux and macOS:build,build-integration-test,test, andtest-service-user— including the Windows wedged-service abort scenarios and the--service-usernamere-registration path that uses the new deregistration wait. (Theset-matrixjob's status is stuckin_progressin the API with all of its own steps completed successfully; every job downstream of it consumed its output and passed.)I did not add a new integration scenario for the deadline case. Reproducing "service reports
Stoppedbut the process lingers holding the image" needs a fixture distinct fromtest/wedgedservice(which never reportsStopped) plus a new composite action. The ACs call for unit tests; the QA steps call for running the existing workflows, which pass unchanged. Happy to build it out if you want it before merge.exitTimeoutOverrideStris overridable via-ldflagsand set to 25s in the integration build, mirroring the existingstopTimeoutOverrideStr, so QA step 4/7 ("force the deadline case") costs seconds rather than two minutes. A garbage or non-positive override falls back to the constant rather than disabling the bound.Docs
README gains "Waiting for the Old Agent Process to Exit" — the failure mode, the three signals and why three, the deadline and its sizing, what each caller does on abort, and the atomic-write guarantee.
CLAUDE.md'scmd/agent_smithentry points at it.Reviewer notes
ETXTBSYis portable: a running binary on darwin opens for writing without error. If that matches your experience, the process scan is load-bearing on that platform; if not, it is redundant defence and still cheap.gopsutilis a new dependency for this path but already a direct dependency of the module (internal/agenthost info), so nogo.modchange. The scan runs once per 250ms poll; on Windows enumerating processes costs ~100-300ms, which is fine for an operation that already stops a service.agent.NewPathsDatamoved above the stop inrunUpdateso the wait knows which executable to watch. It only reads paths and host tags; no behavioural coupling to the service.runUpdatestill exits 0 on an aborted update — same as before this change, the abort is reported through the log. Called out again because the new restart makes the endpoint recover, so the exit status is now the only thing that still misreports.🤖 Generated with Claude Code