Skip to content

Commit fdc352a

Browse files
localai-botmudler
andauthored
fix(settings): start watchdog on cold-enable from the React UI (#9125) (#10287)
fix(watchdog): start the live watchdog on a cold enable from Settings (#9125) The React Settings "Enable Watchdog" master toggle only ever writes the idle/busy flags; watchdog_enabled is vestigial in that UI. The live start/stop decision in UpdateSettingsEndpoint keyed off the raw, stale watchdog_enabled request field, so a cold enable (idle/busy=true, watchdog_enabled=false) called StopWatchdog() and the watchdog stayed stopped until the next restart - at which point startup re-derived it from the idle flag. Net: enabling the watchdog appeared to do nothing. Derive the run-state from idle||busy as the single source of truth, mirroring the startup invariant: - ApplyRuntimeSettings now sets WatchDog = idle||busy whenever either field is present (so a full disable also brings it down), while an API client posting only watchdog_enabled keeps its explicit value. - Add ApplicationConfig.WatchdogShouldRun() mirroring startWatchdog's gating (idle/busy, LRU eviction, memory reclaimer); the /api/settings handler uses it to decide start vs stop. - Belt-and-suspenders: the Settings.jsx master toggle also writes watchdog_enabled = idle||busy. Assisted-by: claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 692970e commit fdc352a

5 files changed

Lines changed: 111 additions & 9 deletions

File tree

core/config/application_config.go

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,16 @@ func (o *ApplicationConfig) GetEffectiveMaxActiveBackends() int {
488488
return 0
489489
}
490490

491+
// WatchdogShouldRun reports whether the live watchdog process should be
492+
// running for the current config. It mirrors the gating in
493+
// (*Application).startWatchdog so the /api/settings start/stop decision and
494+
// the startup path agree on a single source of truth: the watchdog runs when
495+
// idle/busy checks are enabled (WatchDog), when LRU eviction is active
496+
// (effective max active backends > 0), or when the memory reclaimer is on.
497+
func (o *ApplicationConfig) WatchdogShouldRun() bool {
498+
return o.WatchDog || o.GetEffectiveMaxActiveBackends() > 0 || o.MemoryReclaimerEnabled
499+
}
500+
491501
// WithForceEvictionWhenBusy sets whether to force eviction even when models have active API calls
492502
func WithForceEvictionWhenBusy(enabled bool) AppOption {
493503
return func(o *ApplicationConfig) {
@@ -1198,18 +1208,22 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
11981208
}
11991209
if settings.WatchdogIdleEnabled != nil {
12001210
o.WatchDogIdle = *settings.WatchdogIdleEnabled
1201-
if o.WatchDogIdle {
1202-
o.WatchDog = true
1203-
}
12041211
requireRestart = true
12051212
}
12061213
if settings.WatchdogBusyEnabled != nil {
12071214
o.WatchDogBusy = *settings.WatchdogBusyEnabled
1208-
if o.WatchDogBusy {
1209-
o.WatchDog = true
1210-
}
12111215
requireRestart = true
12121216
}
1217+
// The React Settings "Enable Watchdog" master toggle manages only the
1218+
// idle/busy checks — watchdog_enabled is vestigial in that UI. Whenever
1219+
// either idle/busy field is present in the body, derive the run-state from
1220+
// idle||busy so a cold enable starts the watchdog and a full disable stops
1221+
// it, instead of trusting the stale watchdog_enabled the UI never updates.
1222+
// This mirrors the startup invariant in startup.go. An API client posting
1223+
// only watchdog_enabled (idle/busy absent) keeps its explicit value.
1224+
if settings.WatchdogIdleEnabled != nil || settings.WatchdogBusyEnabled != nil {
1225+
o.WatchDog = o.WatchDogIdle || o.WatchDogBusy
1226+
}
12131227
if settings.WatchdogIdleTimeout != nil {
12141228
if dur, err := time.ParseDuration(*settings.WatchdogIdleTimeout); err == nil {
12151229
o.WatchDogIdleTimeout = dur

core/config/application_config_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,69 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
223223
Expect(appConfig.WatchDogBusy).To(BeTrue())
224224
})
225225

226+
// Residual #9125: the React Settings "Enable Watchdog" master toggle
227+
// manages only watchdog_idle_enabled / watchdog_busy_enabled — it never
228+
// touches the vestigial watchdog_enabled field. On a cold enable the
229+
// body therefore carries watchdog_enabled=false alongside idle/busy=true.
230+
// The derived run-state (WatchDog) must follow idle||busy so the live
231+
// watchdog actually starts, not the stale watchdog_enabled=false.
232+
It("should derive WatchDog from idle||busy on a cold enable even when watchdog_enabled=false", func() {
233+
appConfig := &ApplicationConfig{WatchDog: false}
234+
235+
watchdogEnabled := false
236+
watchdogIdle := true
237+
watchdogBusy := true
238+
rs := &RuntimeSettings{
239+
WatchdogEnabled: &watchdogEnabled,
240+
WatchdogIdleEnabled: &watchdogIdle,
241+
WatchdogBusyEnabled: &watchdogBusy,
242+
}
243+
244+
appConfig.ApplyRuntimeSettings(rs)
245+
246+
Expect(appConfig.WatchDog).To(BeTrue())
247+
Expect(appConfig.WatchdogShouldRun()).To(BeTrue())
248+
})
249+
250+
// The disable direction: the master toggle off sends idle=false,
251+
// busy=false, but watchdog_enabled may still be the stale true loaded
252+
// before the change. WatchDog must follow idle||busy down to false so
253+
// the live watchdog is stopped (it stays stopped unless LRU / memory
254+
// reclaimer keep it alive, which is gated by WatchdogShouldRun).
255+
It("should disable WatchDog when both idle and busy are turned off", func() {
256+
appConfig := &ApplicationConfig{WatchDog: true, WatchDogIdle: true, WatchDogBusy: true}
257+
258+
watchdogEnabled := true
259+
watchdogIdle := false
260+
watchdogBusy := false
261+
rs := &RuntimeSettings{
262+
WatchdogEnabled: &watchdogEnabled,
263+
WatchdogIdleEnabled: &watchdogIdle,
264+
WatchdogBusyEnabled: &watchdogBusy,
265+
}
266+
267+
appConfig.ApplyRuntimeSettings(rs)
268+
269+
Expect(appConfig.WatchDog).To(BeFalse())
270+
Expect(appConfig.WatchdogShouldRun()).To(BeFalse())
271+
})
272+
273+
// Backward compatibility: an API client that posts only watchdog_enabled
274+
// (idle/busy nil) keeps the explicit value — the idle/busy derivation
275+
// only kicks in when those fields are actually present in the body.
276+
It("should preserve explicit watchdog_enabled when idle/busy are absent", func() {
277+
appConfig := &ApplicationConfig{WatchDog: false}
278+
279+
watchdogEnabled := true
280+
rs := &RuntimeSettings{
281+
WatchdogEnabled: &watchdogEnabled,
282+
}
283+
284+
appConfig.ApplyRuntimeSettings(rs)
285+
286+
Expect(appConfig.WatchDog).To(BeTrue())
287+
})
288+
226289
It("should handle MaxActiveBackends and update SingleBackend accordingly", func() {
227290
appConfig := &ApplicationConfig{}
228291

core/http/endpoints/localai/settings.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,18 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc {
221221
// Check if agent job retention changed
222222
agentJobChanged := settings.AgentJobRetentionDays != nil
223223

224-
// Restart watchdog if settings changed
224+
// Restart watchdog if settings changed.
225+
//
226+
// The live start/stop decision derives from the post-apply config
227+
// (WatchdogShouldRun) rather than the raw watchdog_enabled request
228+
// field: the React master toggle only ever writes the idle/busy flags,
229+
// so keying off watchdog_enabled left the live watchdog stopped on a
230+
// cold enable until the next restart (#9125). WatchdogShouldRun mirrors
231+
// the gating in startWatchdog, so a cold enable starts it immediately
232+
// and a full disable (both checks off, no LRU / memory reclaimer) stops
233+
// it.
225234
if watchdogChanged {
226-
if settings.WatchdogEnabled != nil && !*settings.WatchdogEnabled {
235+
if !appConfig.WatchdogShouldRun() {
227236
if err := app.StopWatchdog(); err != nil {
228237
xlog.Error("Failed to stop watchdog", "error", err)
229238
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{

core/http/endpoints/localai/settings_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,20 @@ var _ = Describe("Settings endpoints", func() {
108108
_, err := os.Stat(filepath.Join(tmp, "runtime_settings.json"))
109109
Expect(err).ToNot(HaveOccurred())
110110
})
111+
112+
// Residual #9125: enabling the watchdog from a cold (off) state via the
113+
// React master toggle must start the live watchdog immediately, without a
114+
// restart. The toggle posts watchdog_idle_enabled/busy_enabled=true while
115+
// the vestigial watchdog_enabled stays false (it was loaded false). The
116+
// old handler keyed its stop decision off that raw watchdog_enabled=false
117+
// and called StopWatchdog(), so the watchdog never started until restart.
118+
It("starts the live watchdog on a cold enable even when watchdog_enabled=false", func() {
119+
Expect(app.ModelLoader().GetWatchDog()).To(BeNil(), "precondition: watchdog should be off")
120+
121+
rec := post(`{"watchdog_enabled":false,"watchdog_idle_enabled":true,"watchdog_busy_enabled":true,"watchdog_idle_timeout":"15m","watchdog_busy_timeout":"5m","watchdog_interval":"1s"}`)
122+
Expect(rec.Code).To(Equal(http.StatusOK))
123+
124+
Expect(app.ModelLoader().GetWatchDog()).ToNot(BeNil(),
125+
"watchdog should be running after a cold enable, without waiting for a restart")
126+
})
111127
})

core/http/react-ui/src/pages/Settings.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ export default function Settings() {
294294
</h3>
295295
<div className="card">
296296
<SettingRow label="Enable Watchdog" description="Automatically monitor and manage backend processes">
297-
<Toggle checked={settings.watchdog_idle_enabled || settings.watchdog_busy_enabled} onChange={(v) => { update('watchdog_idle_enabled', v); update('watchdog_busy_enabled', v) }} />
297+
<Toggle checked={settings.watchdog_idle_enabled || settings.watchdog_busy_enabled} onChange={(v) => { update('watchdog_idle_enabled', v); update('watchdog_busy_enabled', v); update('watchdog_enabled', v) }} />
298298
</SettingRow>
299299
<SettingRow label="Enable Idle Check" description="Automatically stop backends that have been idle too long">
300300
<Toggle checked={settings.watchdog_idle_enabled} onChange={(v) => update('watchdog_idle_enabled', v)} disabled={!watchdogEnabled} />

0 commit comments

Comments
 (0)