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
3658func 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