Skip to content

desktop: Every settings write reports success unchecked; one failure mode makes Theme a completely dead button #4497

Description

@MervinPraison

Found by a six-lens audit of the desktop app on 27 Aug 2026. Every finding was reproduced against the code, not inferred.
Severity: medium · audit rank 10 of 16.

Breaks: saveCfg mutates CFG/cfg, calls applyPrefs, then await fetch(POST /settings) with no r.ok check and no catch. apply (:1952-1958) awaits it and only then runs applyTheme(nv) and renderSettings(), and b.onclick = () => set(o.value) (:1820) discards the promise. When the POST rejects — and the base_url/api_key rows at :716,720 explicitly tell the user the engine will restart — clicking "Light" produces no data-theme, no highlight, no error, no toast. Toggles are worse: cfg is already mutated but the switch never moves, so visible and in-memory state diverge and the next click flips the invisible one back. A 500 is silent in the other direction: the row grows a "reset to default" affordance for a value that was never persisted. setTextSize (:1783) does not even await, and toasts "Text size 16 px" while every write fails.
Who / likelihood: any settings change made across an engine restart — which the UI itself tells users to expect.
Where: ui/index.html:1801-1806.

--- a/ui/index.html
+++ b/ui/index.html
@@ -1801,7 +1801,15 @@
 async function saveCfg(patch){
-  CFG={...CFG,...patch}; applyPrefs(CFG);
-  cfg = {...cfg, ...patch};
-  await fetch('http://127.0.0.1:'+PORT+'/settings',{method:'POST',
-    headers:{'content-type':'application/json'},body:JSON.stringify(patch)});
+  // Apply only what was stored. Mutating first and never checking is how a
+  // toggle moved in memory, never moved on screen, and un-flipped itself on
+  // the next click.
+  let r=null;
+  try{
+    r=await fetch('http://127.0.0.1:'+PORT+'/settings',{method:'POST',
+      headers:{'content-type':'application/json'},body:JSON.stringify(patch)});
+  }catch(_){}
+  if(!r||!r.ok){ toast('Could not save that setting.'); return false; }
+  CFG={...CFG,...patch}; applyPrefs(CFG);
+  cfg = {...cfg, ...patch};
+  return true;
 }

with apply (:1952) gating applyTheme/renderSettings on the returned boolean.

Test — frontend/tests/settings-effects.test.mjs (already boots the real page per-config and asserts observable differences): stub POST /settings as 500, click Theme → Light, and assert both that document.documentElement has no data-theme and that a toast/error is visible; then click a toggle and assert cfgGet(key) is unchanged. Currently the first passes vacuously and the rest fail.



Not yet fixed. Filed so it is not lost with the session that found it. The fix and the test above are proposals from the audit — worth re-checking against current main before implementing, since the file has moved since.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingclaudeAuto-trigger Claude analysis

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions