Skip to content

Commit e3b93c7

Browse files
authored
fix: [sc-106109] Wait for actual process exit before replacing or deleting agent files (#100)
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.
1 parent a8fa061 commit e3b93c7

21 files changed

Lines changed: 1778 additions & 52 deletions

CLAUDE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ Required tools:
5151
- Service mode: `--config-file --log-file --org-id`
5252
- Uninstall mode: `--uninstall --org-id`
5353

54+
The install, update and uninstall paths never assume the old agent process has
55+
exited: after stopping the service they wait (bounded, 2 minutes, documented)
56+
for real exit signals — the service manager no longer reporting the service
57+
active, no process still executing the agent binary, and the executable no
58+
longer held open — and return as soon as the process is gone. Overrunning the
59+
deadline aborts before writing or deleting anything, leaves the installation
60+
intact, and restarts the service that was stopped so a failed update cannot
61+
leave an endpoint offline. The agent executable and config file are written to a
62+
temp file and atomically renamed into place, so a failed write leaves the
63+
previous file byte-identical. See the README's "Waiting for the Old Agent
64+
Process to Exit" section.
65+
5466
- **internal/agent/**: Device configuration, installation paths, and OS-specific host information
5567
- **internal/interpreter/**: Command execution engine supporting both PowerShell and Bash interpreters
5668
- **internal/mqtt/**: Azure IoT Hub MQTT client implementation with auto-reconnection

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,53 @@ Recovery is the ordinary one: end the wedged agent process, start the service, a
448448
re-run the update or uninstall. Linux and macOS are unaffected — their service
449449
implementations do not use this polling loop.
450450

451+
### Waiting for the Old Agent Process to Exit
452+
453+
Stopping the service is not the same as the old agent process being gone. Install,
454+
update and uninstall used to bridge that gap with a fixed `time.Sleep` of five
455+
seconds and then act regardless — overwriting the agent executable, or deleting
456+
the installation directory. On a loaded endpoint the old process is frequently
457+
still alive at the five second mark, because its shutdown legitimately drains the
458+
commands in flight, tears down MQTT and kills its plugin subprocesses one at a
459+
time. Windows then refuses to replace a running image, so the update failed with
460+
a sharing violation *after* the service was already stopped, leaving the device
461+
offline until someone intervened. Uninstall had the mirror-image problem: files
462+
deleted out from under a live process, leaving an installation that neither ran
463+
nor reinstalled.
464+
465+
Nothing is written or deleted now until the process is observed to be gone:
466+
467+
- The wait polls three **real signals**, all of which must clear: the service
468+
manager no longer reports the service active, no running process is executing
469+
the agent binary, and the executable is no longer held open as a running image
470+
(a sharing violation on Windows, `ETXTBSY` on Linux). The three overlap on
471+
purpose — the file signal is what actually blocks the write on Windows, but
472+
macOS permits writing to a running image, and a service manager can report a
473+
service stopped while its process is still winding down. An elapsed poll
474+
interval is never by itself treated as evidence of anything.
475+
- It **returns as soon as the process is gone**, so a healthy update is faster
476+
than the unconditional five second sleep it replaces, not slower.
477+
- It is bounded by a documented **2 minute** deadline, sized for a slow but
478+
legitimate shutdown (many workers, long-running commands, several plugin
479+
subprocesses). Overrunning it logs at `Error` what was still outstanding and for
480+
how long, and the caller aborts rather than proceeding.
481+
- **Update** aborts before writing anything and leaves the installation fully
482+
intact, then **restarts the service it stopped** so a failed update never leaves
483+
the endpoint silently offline. **Uninstall** aborts before deleting the
484+
registration or any files, and logs that nothing was removed. **Install**
485+
(`--config`) aborts before deleting the existing registration and restarts the
486+
service it stopped.
487+
- A probe that cannot run at all (a restrictive ACL, a process table that cannot
488+
be enumerated) is logged once at `Warn` and the remaining signals are used. A
489+
probe failure is not evidence a process is alive, and must not wedge every
490+
update on an endpoint where it can never succeed.
491+
492+
The agent executable and the config file are also written **atomically** — to a
493+
temporary file in the destination directory, then renamed into place, the same
494+
pattern the postback spool uses. An interrupted or failed write therefore leaves
495+
the previous file byte-identical rather than truncated: the endpoint keeps running
496+
the old agent instead of a binary that cannot start.
497+
451498
### Notification Plugin Supervision
452499

453500
Notification plugins run as separate subprocesses reached over RPC, and every

cmd/agent_smith/config.go

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"net/http"
1010
"os"
1111
"runtime"
12-
"time"
1312

1413
"github.com/RewstApp/agent-smith-go/internal/agent"
1514
"github.com/RewstApp/agent-smith-go/internal/service"
@@ -153,16 +152,20 @@ func runConfig(params *configContext) error {
153152
// Got configuration
154153
logger.Info("Received configuration", "configuration", string(configBytes))
155154

156-
err = params.FS.WriteFile(configFilePath, configBytes, utils.DefaultFileMod)
155+
// Written atomically so a failed write cannot leave a truncated config file
156+
// that the service is then unable to start from.
157+
err = writeFileAtomic(params.FS, configFilePath, configBytes, utils.DefaultFileMod)
157158
if err != nil {
158159
return fmt.Errorf("failed to save config: %w", err)
159160
}
160161

161162
name := agent.GetServiceName(params.OrgId)
163+
agentExecutablePath := agent.GetAgentExecutablePath(params.OrgId)
162164

163165
// Stop and delete the service if it already exists
164166
existingService, err := params.ServiceManager.Open(name)
165167
if err == nil {
168+
stopped := false
166169
if existingService.IsActive() {
167170
logger.Info("Stopping service", "service", name)
168171
// Abort before deleting the registration or overwriting the
@@ -179,6 +182,56 @@ func runConfig(params *configContext) error {
179182
}
180183
return fmt.Errorf("failed to stop service %s: %w", name, stopErr)
181184
}
185+
stopped = true
186+
}
187+
188+
// Wait for the old process to actually exit before the registration is
189+
// deleted and the executable is replaced. A process that is still running
190+
// holds its own image open, so proceeding here is what fails the install
191+
// with a sharing violation on Windows.
192+
logger.Info(
193+
"Waiting for the agent process to exit",
194+
"service", name,
195+
"agent_executable", agentExecutablePath,
196+
)
197+
if waitErr := waitForAgentProcessExit(
198+
logger,
199+
existingService,
200+
params.FS,
201+
agentExecutablePath,
202+
params.exitWait,
203+
); waitErr != nil {
204+
// The config file was already refreshed above, so say so rather than
205+
// claiming nothing changed: the installed agent and its registration are
206+
// what had to be left alone while the old process is alive.
207+
logger.Error(
208+
"Install aborted; the existing agent and its service registration were left untouched",
209+
"service",
210+
name,
211+
"agent_executable",
212+
"not modified",
213+
"service_registration",
214+
"intact",
215+
"config_file",
216+
"updated",
217+
"error",
218+
waitErr,
219+
)
220+
// Put the endpoint back the way it was found rather than leaving a
221+
// stopped service behind.
222+
if stopped {
223+
if startErr := existingService.Start(); startErr != nil {
224+
logger.Error(
225+
"Failed to restart service after aborted install; the endpoint is offline",
226+
"service", name,
227+
"error", startErr,
228+
)
229+
}
230+
}
231+
if closeErr := existingService.Close(); closeErr != nil {
232+
logger.Error("Failed to close service handle", "service", name, "error", closeErr)
233+
}
234+
return fmt.Errorf("failed to wait for agent process to exit: %w", waitErr)
182235
}
183236

184237
// Delete the service
@@ -188,13 +241,26 @@ func runConfig(params *configContext) error {
188241
}
189242
logger.Info("Service deleted", "service", name)
190243

191-
// Wait for some time for the service executable to clean up
192244
err = existingService.Close()
193245
if err != nil {
194246
return fmt.Errorf("failed to close service %s: %w", name, err)
195247
}
196-
logger.Info("Waiting for service executable to stop")
197-
time.Sleep(serviceExecutableTimeout)
248+
249+
// Wait for the deleted registration to be reaped so the name is free to
250+
// register again. A registration that outlives the deadline is reported and
251+
// creation is attempted anyway, which surfaces the real conflict.
252+
logger.Info("Waiting for the service registration to be removed", "service", name)
253+
if deregErr := waitForServiceDeregistration(
254+
params.ServiceManager,
255+
name,
256+
params.exitWait,
257+
); deregErr != nil {
258+
logger.Error(
259+
"Service registration outlived its deletion; registering anyway",
260+
"service", name,
261+
"error", deregErr,
262+
)
263+
}
198264
}
199265

200266
logger.Info("Configuration saved to", "path", configFilePath)
@@ -218,8 +284,14 @@ func runConfig(params *configContext) error {
218284
return fmt.Errorf("failed to read executable file: %w", err)
219285
}
220286

221-
agentExecutablePath := agent.GetAgentExecutablePath(params.OrgId)
222-
err = params.FS.WriteFile(agentExecutablePath, execFileBytes, utils.DefaultExecutableFileMod)
287+
// Written atomically so a failure here leaves any previously installed binary
288+
// byte-identical instead of truncated.
289+
err = writeFileAtomic(
290+
params.FS,
291+
agentExecutablePath,
292+
execFileBytes,
293+
utils.DefaultExecutableFileMod,
294+
)
223295
if err != nil {
224296
return fmt.Errorf("failed to create agent executable: %w", err)
225297
}

cmd/agent_smith/config_context.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,11 @@ type configContext struct {
185185
FS utils.FileSystem
186186
ServiceManager service.ServiceManager
187187
HTTPClient *http.Client
188+
189+
// exitWait holds the test seams for the bounded wait for the old agent
190+
// process to exit. Zero values select the documented defaults, so nothing
191+
// outside tests ever sets it.
192+
exitWait exitWaitOptions
188193
}
189194

190195
// newConfigFlagSet builds the flag set for config mode, binding flags to the

cmd/agent_smith/config_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ func newBaseConfigParams(configURL string) *configContext {
7474
Domain: &mockDomainInfoProvider{},
7575
FS: newConfigTestFS(),
7676
ServiceManager: newConfigTestServiceManager(),
77+
exitWait: stubExitWait(),
7778
}
7879
}
7980

@@ -273,6 +274,55 @@ func TestRunConfig_ExistingService_StopFails(t *testing.T) {
273274
}
274275
}
275276

277+
// A pre-existing agent whose process never exits must abort the install before
278+
// the registration is deleted or the executable replaced, and the service it
279+
// stopped must be brought back up.
280+
func TestRunConfig_ExistingService_ProcessNeverExits(t *testing.T) {
281+
srv := newConfigServer(t, http.StatusOK, validConfigResponseBody("test-org"))
282+
defer srv.Close()
283+
284+
clock := newFakeClock()
285+
params := newBaseConfigParams(srv.URL)
286+
params.exitWait = clock.options(2*time.Minute, 250*time.Millisecond)
287+
var renames [][2]string
288+
params.FS = &mockFileSystem{
289+
executableFunc: func() (string, error) { return "/fake/agent", nil },
290+
readFileFunc: func(string) ([]byte, error) { return []byte("binary"), nil },
291+
writeFileFunc: func(string, []byte, os.FileMode) error { return nil },
292+
mkdirAllFunc: func(string) error { return nil },
293+
removeAllFunc: func(string) error { return nil },
294+
renameFunc: func(oldPath string, newPath string) error {
295+
renames = append(renames, [2]string{oldPath, newPath})
296+
return nil
297+
},
298+
// The old process holds its image open for as long as it lives.
299+
executableInUseFunc: func(string) (bool, error) { return true, nil },
300+
}
301+
existing := &mockService{isActive: true}
302+
params.ServiceManager = &mockServiceManager{
303+
openService: existing,
304+
createService: &mockService{},
305+
}
306+
307+
err := runConfig(params)
308+
309+
if err == nil || !strings.Contains(err.Error(), "failed to wait for agent process to exit") {
310+
t.Errorf("expected the install to abort on the exit wait, got %v", err)
311+
}
312+
if existing.deleteCalled {
313+
t.Error("expected the existing service registration to be left alone")
314+
}
315+
if !existing.startCalled {
316+
t.Error("expected the existing service to be restarted rather than left stopped")
317+
}
318+
agentExecutablePath := agent.GetAgentExecutablePath("test-org")
319+
for _, rename := range renames {
320+
if rename[1] == agentExecutablePath {
321+
t.Error("expected the agent executable never to be replaced while the process is alive")
322+
}
323+
}
324+
}
325+
276326
func TestRunConfig_ExistingService_DeleteFails(t *testing.T) {
277327
srv := newConfigServer(t, http.StatusOK, validConfigResponseBody("test-org"))
278328
defer srv.Close()

cmd/agent_smith/main_test.go

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"errors"
56
"os"
67

78
"github.com/RewstApp/agent-smith-go/internal/service"
@@ -68,11 +69,14 @@ func (mock *mockDomainInfoProvider) EntraDomain(context.Context) (*string, error
6869
}
6970

7071
type mockFileSystem struct {
71-
executableFunc func() (string, error)
72-
readFileFunc func(name string) ([]byte, error)
73-
writeFileFunc func(name string, data []byte, perm os.FileMode) error
74-
mkdirAllFunc func(path string) error
75-
removeAllFunc func(path string) error
72+
executableFunc func() (string, error)
73+
readFileFunc func(name string) ([]byte, error)
74+
writeFileFunc func(name string, data []byte, perm os.FileMode) error
75+
mkdirAllFunc func(path string) error
76+
removeAllFunc func(path string) error
77+
renameFunc func(oldPath string, newPath string) error
78+
removeFunc func(name string) error
79+
executableInUseFunc func(name string) (bool, error)
7680
}
7781

7882
func (m *mockFileSystem) Executable() (string, error) {
@@ -95,21 +99,59 @@ func (m *mockFileSystem) RemoveAll(path string) error {
9599
return m.removeAllFunc(path)
96100
}
97101

102+
// Rename, Remove and ExecutableInUse default to the behaviour of a filesystem
103+
// where the write commits and no process holds the executable, so tests only
104+
// override them when the case under test is about those.
105+
func (m *mockFileSystem) Rename(oldPath string, newPath string) error {
106+
if m.renameFunc == nil {
107+
return nil
108+
}
109+
return m.renameFunc(oldPath, newPath)
110+
}
111+
112+
func (m *mockFileSystem) Remove(name string) error {
113+
if m.removeFunc == nil {
114+
return nil
115+
}
116+
return m.removeFunc(name)
117+
}
118+
119+
func (m *mockFileSystem) ExecutableInUse(name string) (bool, error) {
120+
if m.executableInUseFunc == nil {
121+
return false, nil
122+
}
123+
return m.executableInUseFunc(name)
124+
}
125+
98126
type mockService struct {
99127
isActive bool
100128
stopErr error
101129
deleteErr error
102130
startErr error
131+
// isActiveFunc overrides isActive when a test needs the reported state to
132+
// change from one observation to the next, as a service that is still
133+
// shutting down does.
134+
isActiveFunc func() bool
103135

104136
stopCalled bool
105137
deleteCalled bool
106138
startCalled bool
107139
}
108140

109-
func (m *mockService) IsActive() bool { return m.isActive }
141+
func (m *mockService) IsActive() bool {
142+
if m.isActiveFunc != nil {
143+
return m.isActiveFunc()
144+
}
145+
return m.isActive
146+
}
110147

148+
// Stop mirrors a real service manager: a stop that reports success means the
149+
// service reached Stopped, so it no longer reports itself active.
111150
func (m *mockService) Stop() error {
112151
m.stopCalled = true
152+
if m.stopErr == nil {
153+
m.isActive = false
154+
}
113155
return m.stopErr
114156
}
115157

@@ -131,10 +173,18 @@ type mockServiceManager struct {
131173
createErr error
132174
createService service.Service
133175

176+
openCalls int
134177
createCalls []service.AgentParams
135178
}
136179

137180
func (m *mockServiceManager) Open(name string) (service.Service, error) {
181+
m.openCalls++
182+
if svc, ok := m.openService.(*mockService); ok && svc.deleteCalled {
183+
// A deleted registration is no longer visible to the manager. That is the
184+
// signal the deregistration wait polls for, so the mock has to reproduce it
185+
// or the wait would have nothing to observe.
186+
return nil, errors.New("service does not exist")
187+
}
138188
return m.openService, m.openErr
139189
}
140190

0 commit comments

Comments
 (0)