Skip to content

Commit 05a081c

Browse files
committed
Add service recovery configuration and watchdog enhancements
- Introduced `ConfigureServiceRecovery` to enforce Windows SCM failure policy at startup. - Enhanced watchdog to skip restarts during backend outages with capped suppression windows. - Refined WebSocket reconnection handling and added diagnostics for server errors. - Improved exit diagnostics for better stuck-host analysis.
1 parent 740b677 commit 05a081c

5 files changed

Lines changed: 309 additions & 23 deletions

File tree

install.ps1

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -631,8 +631,11 @@ AGENT_PIN=$Pin
631631
exit 1
632632
}
633633

634-
# Configure service recovery options (restart on failure)
635-
sc.exe failure $Script:ServiceName reset= 86400 actions= restart/5000/restart/10000/restart/30000 | Out-Null
634+
# Configure service recovery options (restart on failure).
635+
# Windows' `sc.exe failure` only honors the first 3 actions before falling
636+
# back to "no action". The agent re-asserts this on every startup via
637+
# platform.ConfigureServiceRecovery, so this is just the initial baseline.
638+
sc.exe failure $Script:ServiceName reset= 86400 actions= restart/5000/restart/10000/restart/30000/restart/60000/restart/300000 | Out-Null
636639

637640
# Configure Windows Firewall rules
638641
Configure-Firewall -AgentPath $binaryPath

lib/platform/service_unix.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,15 @@ func WatchdogRestart() {
3939
// On Unix, we just exit with code 1 and let the init system restart us
4040
os.Exit(1)
4141
}
42+
43+
// ConfigureServiceRecovery is a no-op on Unix.
44+
// Linux uses systemd Restart=always (unbounded retries) and macOS uses launchd
45+
// KeepAlive. Neither has the 3-action cap that Windows SCM has, so there's
46+
// nothing to reconfigure.
47+
func ConfigureServiceRecovery() error {
48+
return nil
49+
}
50+
51+
// WriteLastExitInfo is a no-op on Unix — Unix init systems have their own
52+
// journal/console capture; the agent's rotating log file is enough for diagnosis.
53+
func WriteLastExitInfo(reason, lastError string) {}

lib/platform/service_windows.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,97 @@ func WatchdogRestart() {
224224
os.Exit(1)
225225
}
226226

227+
// recoveryAction describes a single failure recovery action for the Windows SCM.
228+
// delayMs is the wait before the action (in milliseconds); action is "restart" / "reboot" / "run".
229+
type recoveryAction struct {
230+
action string
231+
delayMs int
232+
}
233+
234+
// DefaultRecoveryActions is the SCM failure policy applied on every agent startup.
235+
//
236+
// Why so many actions? Windows' `sc.exe failure` supports at most 3 configured
237+
// actions before falling back to the system default ("take no action"). After
238+
// the 3rd action, a service that keeps failing is left stopped until a human
239+
// intervenes. On Linux/macOS the equivalent (systemd Restart=always,
240+
// launchd KeepAlive) restarts forever — so Windows is uniquely fragile.
241+
//
242+
// We work around the 3-action limit by re-asserting a wider policy on every
243+
// startup (the `sc.exe failure` command silently caps the action list and
244+
// shifts later actions into "subsequent failures"). Even when SCM starts
245+
// ignoring our actions after the 3rd, the `reset= 0` clears the failure
246+
// counter immediately so a single successful run resets the slate.
247+
//
248+
// This is intentionally aggressive (final delays up to 5 minutes) — a long
249+
// retry window is much safer than giving up.
250+
var DefaultRecoveryActions = []recoveryAction{
251+
{action: "restart", delayMs: 5000}, // 5s — 1st failure
252+
{action: "restart", delayMs: 10000}, // 10s — 2nd failure
253+
{action: "restart", delayMs: 30000}, // 30s — 3rd failure
254+
{action: "restart", delayMs: 60000}, // 60s — 4th+ failure
255+
{action: "restart", delayMs: 300000}, // 5m — subsequent failures
256+
}
257+
258+
// ConfigureServiceRecovery re-asserts the SCM failure policy on every startup.
259+
// This fixes existing installs that were installed before the wider policy was added.
260+
//
261+
// Safe to call repeatedly: `sc.exe failure` is idempotent. Failures are logged
262+
// at warn level but never fatal — a hardened host that denies SC_MANAGER access
263+
// will continue running; the agent just won't auto-restart on hard crashes.
264+
//
265+
// `reset= 0` clears the failure counter so the very first successful run resets
266+
// the SCM's "have we failed recently" state.
267+
func ConfigureServiceRecovery() error {
268+
if !IsRunningAsService() {
269+
// Not running as a service — nothing to configure.
270+
return nil
271+
}
272+
273+
exe, err := os.Executable()
274+
if err != nil {
275+
return fmt.Errorf("configure recovery: failed to get executable path: %w", err)
276+
}
277+
278+
// Build the action string: "restart/5000/restart/10000/..."
279+
parts := make([]string, 0, len(DefaultRecoveryActions))
280+
for _, a := range DefaultRecoveryActions {
281+
parts = append(parts, fmt.Sprintf("%s/%d", a.action, a.delayMs))
282+
}
283+
actions := strings.Join(parts, "/")
284+
285+
cmd := exec.Command("sc.exe", "failure", "NetWatcherAgent",
286+
"reset=", "0",
287+
"actions=", actions,
288+
)
289+
out, err := cmd.CombinedOutput()
290+
if err != nil {
291+
return fmt.Errorf("configure recovery: sc.exe failure failed: %w (output: %s)", err, strings.TrimSpace(string(out)))
292+
}
293+
294+
log.WithFields(log.Fields{
295+
"exe": filepath.Base(exe),
296+
"actions": actions,
297+
}).Info("Service recovery policy reconfigured")
298+
return nil
299+
}
300+
301+
// WriteLastExitInfo persists diagnostic info about why the agent is exiting.
302+
// Used by both graceful exits and the watchdog so post-mortem analysis doesn't
303+
// require catching the live log. Always overwrites — only the most recent exit
304+
// reason matters for diagnosing a stuck host.
305+
func WriteLastExitInfo(reason, lastError string) {
306+
exe, err := os.Executable()
307+
if err != nil {
308+
return
309+
}
310+
path := filepath.Join(filepath.Dir(exe), "last_exit.json")
311+
payload := fmt.Sprintf(`{"reason":%q,"last_error":%q,"timestamp":%q}`+"\n",
312+
reason, lastError, time.Now().Format(time.RFC3339))
313+
if err := os.WriteFile(path, []byte(payload), 0644); err != nil {
314+
log.WithError(err).Warn("Failed to write last_exit.json")
315+
}
316+
}
317+
227318
// spawnRestartProcess creates a detached process that will restart the service
228319
// after the current process exits. This ensures the SCM sees a clean shutdown
229320
// before the service is restarted.

main.go

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,28 @@ var (
3232
disableUpdater bool
3333
)
3434

35+
// watchdogTimeout reads WATCHDOG_TIMEOUT_MINUTES (default 10). Used by the
36+
// no-activity watchdog so deployments can tune how aggressively we restart
37+
// hung agents without recompiling.
38+
func watchdogTimeout() time.Duration {
39+
raw := strings.TrimSpace(os.Getenv("WATCHDOG_TIMEOUT_MINUTES"))
40+
if raw == "" {
41+
return 10 * time.Minute
42+
}
43+
minutes, err := strconv.Atoi(raw)
44+
if err != nil || minutes <= 0 {
45+
log.Warnf("Invalid WATCHDOG_TIMEOUT_MINUTES=%q, using default 10", raw)
46+
return 10 * time.Minute
47+
}
48+
return time.Duration(minutes) * time.Minute
49+
}
50+
51+
// maxServerErrorSuppression caps how long the watchdog will stay suppressed by
52+
// server-error mode. Acts as a safety net in case the flag ever gets stuck
53+
// set due to a future bug — we'd rather restart eventually than stay silent
54+
// forever. 4 hours matches typical backend recovery timeouts.
55+
const maxServerErrorSuppression = 4 * time.Hour
56+
3557
// Env helpers
3658
func getenv(key, def string) string {
3759
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
@@ -115,6 +137,17 @@ func runAgent(ctx context.Context) error {
115137

116138
loadConfig(configPath)
117139

140+
// ---------- Windows Service Recovery ----------
141+
// Re-assert the SCM failure policy on every startup. Fixes existing installs
142+
// that were installed with the narrower (3-action) policy that left the
143+
// service stopped after a few crashes. Idempotent and safe — see
144+
// platform.ConfigureServiceRecovery for details.
145+
if err := platform.ConfigureServiceRecovery(); err != nil {
146+
// Non-fatal: a hardened host that denies SC_MANAGER access just falls
147+
// back to default SCM behavior. Log and continue.
148+
log.Warnf("Could not reconfigure service recovery policy: %v", err)
149+
}
150+
118151
// ---------- Updater ----------
119152
if !disableUpdater {
120153
updateConfig := &UpdaterConfig{
@@ -254,6 +287,11 @@ func runAgent(ctx context.Context) error {
254287
updateActivity()
255288
}
256289

290+
// Pong receipts prove the backend is alive even during quiet periods.
291+
// Without this, the watchdog restarts agents that have a healthy but idle
292+
// connection (no probe_get/speedtest traffic flowing right now).
293+
wsClient.OnActivity = updateActivity
294+
257295
// If your workers expect a uint agent ID now:
258296
workers.SetControllerConfig(cfg.ControllerHost, cfg.SSL, cfg.WorkspaceID, cfg.AgentID, psk)
259297

@@ -274,8 +312,18 @@ func runAgent(ctx context.Context) error {
274312

275313
go wsClient.ConnectWithRetry(agentCtx)
276314

277-
// Watchdog: restart if no activity for 10 minutes
278-
const watchdogTimeout = 10 * time.Minute
315+
// Watchdog: restart if no activity for the configured timeout (default 10 min).
316+
//
317+
// Skipped while we're in server-error retry mode — a backend outage is not
318+
// a stuck agent. Restarting just burns Windows SCM restart-throttle budget
319+
// for nothing. The retry loop's own alive-but-retrying log line makes it
320+
// obvious from the host that the process is alive.
321+
//
322+
// Safety net: if server-error mode has been on for more than
323+
// maxServerErrorSuppression, the watchdog fires anyway. Covers any future
324+
// bug that could leave the flag stuck set.
325+
wdt := watchdogTimeout()
326+
log.Infof("Watchdog: configured timeout = %v", wdt)
279327
go func() {
280328
ticker := time.NewTicker(1 * time.Minute)
281329
defer ticker.Stop()
@@ -288,10 +336,32 @@ func runAgent(ctx context.Context) error {
288336
elapsed := time.Since(lastSuccessfulActivity)
289337
activityMu.Unlock()
290338

291-
log.Debugf("Watchdog: last activity %v ago", elapsed.Round(time.Second))
339+
srvActive, srvSince := wsClient.IsInServerErrorMode()
340+
341+
// Skip the watchdog while we're deliberately retrying 5xx,
342+
// unless we've been in this state longer than the sanity cap.
343+
if srvActive {
344+
stuckFor := time.Since(srvSince)
345+
if stuckFor < maxServerErrorSuppression {
346+
log.Debugf("Watchdog: suppressed (server-error mode, %v since last success, %v in srv-err mode)",
347+
elapsed.Round(time.Second), stuckFor.Round(time.Second))
348+
continue
349+
}
350+
log.Warnf("Watchdog: server-error mode held for %v — exceeding sanity cap of %v, forcing restart",
351+
stuckFor.Round(time.Second), maxServerErrorSuppression)
352+
} else {
353+
log.Debugf("Watchdog: last activity %v ago", elapsed.Round(time.Second))
354+
}
292355

293-
if elapsed > watchdogTimeout {
356+
if elapsed > wdt {
294357
log.Errorf("Watchdog: no successful activity for %v, forcing restart", elapsed.Round(time.Second))
358+
// Leave a breadcrumb for whoever finds the host next — the
359+
// log file may have rotated, but this file is overwritten
360+
// only on exits, so it always shows the most recent reason.
361+
platform.WriteLastExitInfo(
362+
fmt.Sprintf("watchdog: no activity for %v", elapsed.Round(time.Second)),
363+
wsClient.GetLastDialError(),
364+
)
295365
platform.WatchdogRestart()
296366
}
297367
}

0 commit comments

Comments
 (0)