diff --git a/carwatch/webchat.py b/carwatch/webchat.py
index 39f8cf8..3c2063f 100644
--- a/carwatch/webchat.py
+++ b/carwatch/webchat.py
@@ -531,18 +531,19 @@ def _switch_later(name: str) -> None:
d.textContent=s.answer||'';}
bw.style.display=bar?'block':'none';
}
-async function pollVoice(){const c=new AbortController();const t=setTimeout(()=>c.abort(),8000);
- try{const r=await fetch(_q('/api/voice/state'),{signal:c.signal});renderVoice(await r.json());}catch(e){}finally{clearTimeout(t)}}
+async function pollVoice(signal){try{const r=await F('/api/voice/state',{signal},8000);renderVoice(await r.json());}catch(e){}}
$('speakBtn').onclick=async()=>{try{await fetch(_q('/api/voice/start'),{method:'POST'});pollVoice();}catch(e){}};
document.querySelector('.vstate').addEventListener('click',e=>e.currentTarget.classList.toggle('open'));
-// Idle back-off (petrus, 15 Sep 2026: the VTA sat at 80 C with its fans up
-// for four hours on the desk; measured on the box: chrome + cage = one full
-// core, GPU 0 %, llama-server idle. The cause was this page repainting on
-// 1 / 1.5 / 2 s timers all day). Fast rates while the car is live, the wheel
+// Idle back-off (petrus, 15 Sep 2026, "reduce unnecessary polling"). These
+// 1 / 1.5 / 2 s self-polls were request waste on a page that sits idle for
+// hours. They were NOT the VTA's 80 C that evening: that was the kiosk
+// drawing the dash for a panel that was not attached (measured by a pause
+// test; see CarWatch #60). Fast rates while the car is live, the wheel
// moves, voice is busy or a finger touches the screen; after 60 s without any
// of that the same polls run at 5 / 6 / 10 s. The first poll that sees a
// change flips back to fast, so a turned wheel or a started engine is noticed
-// within one idle interval. Hidden tab: no polling at all.
+// within one idle interval. Hidden tab: the loop keeps ticking but skips the
+// fetch, so nothing is requested until the tab is visible again.
const CW_FAST={voice:1500,status:2000,steer:1000}, CW_IDLE={voice:6000,status:10000,steer:5000};
let cwLastActive=Date.now();
function cwActive(){cwLastActive=Date.now();}
@@ -550,18 +551,30 @@ def _switch_later(name: str) -> None:
['pointerdown','keydown','touchstart','wheel'].forEach(ev=>document.addEventListener(ev,cwActive,{passive:true}));
// The next run is scheduled no matter what fn() does: a request that never
// settles (codexmb, post-merge review of #57) must not stop the loop, which is
-// what setInterval used to guarantee. fn() is raced against CW_DEADLINE ms;
-// the loser is abandoned (its own fetch is bounded by F / pollVoice's abort).
-const CW_DEADLINE=15000;
+// what setInterval used to guarantee. Each run gets an AbortSignal that fn
+// passes to its fetch; at CW_DEADLINE the signal is aborted and the run is
+// awaited until it settles (codexmb, #59: an abandoned run is not a cancelled
+// one). Runs that honour their signal, which all three polls do through F,
+// therefore never overlap; a fn that ignores its signal is abandoned after
+// CW_SETTLE_GRACE so one hung request cannot stop the loop, and may overlap.
+const CW_DEADLINE=15000, CW_SETTLE_GRACE=2000;
function cwLoop(name,fn){const run=async()=>{if(!document.hidden){
- let t;try{await Promise.race([fn(),new Promise(r=>{t=setTimeout(r,CW_DEADLINE)})])}catch(e){}finally{clearTimeout(t)}}
+ const c=new AbortController();let t,g;
+ const p=Promise.resolve().then(()=>fn(c.signal)).catch(()=>{});
+ const late=await Promise.race([p.then(()=>false),new Promise(r=>{t=setTimeout(()=>r(true),CW_DEADLINE)})]);
+ clearTimeout(t);
+ if(late){c.abort();await Promise.race([p,new Promise(r=>{g=setTimeout(r,CW_SETTLE_GRACE)})]);clearTimeout(g);}}
setTimeout(run,(cwIsIdle()?CW_IDLE:CW_FAST)[name]);};run();}
document.addEventListener('visibilitychange',()=>{if(!document.hidden)cwActive();});
cwLoop('voice',pollVoice);
const F=(u,o={},ms=4000)=>{const c=new AbortController();const t=setTimeout(()=>c.abort(),ms);
+ // An outer signal (the scheduler's) aborts this request too.
+ if(o.signal){if(o.signal.aborted)c.abort();else o.signal.addEventListener('abort',()=>c.abort(),{once:true});}
// The abort timer covers the body read too, not only the headers: r.json() is
// wrapped so the timer clears when the body has arrived (codexmb, #57 review).
- return fetch(_q(u),Object.assign({signal:c.signal},o)).then(r=>{const j=r.json.bind(r);r.json=()=>j().finally(()=>clearTimeout(t));return r},e=>{clearTimeout(t);throw e});};
+ // Options first, then OUR signal: a caller's {signal} (or {signal:undefined}) must not
+ // replace the linked one, or the timeout never reaches the request (codexmb, #61).
+ return fetch(_q(u),Object.assign({},o,{signal:c.signal})).then(r=>{const j=r.json.bind(r);r.json=()=>j().finally(()=>clearTimeout(t));return r},e=>{clearTimeout(t);throw e});};
const ACT={brief:['/api/car-brief','composing + speaking your car brief',130000],read:['/api/obd','one live engine read',70000],record:['/api/obd/record-arm','armed: records 120s raw CAN on the next moving read',30000],
pair:['/api/car-pair','scan + pair car Bluetooth (MBUX in pairing mode)',70000],update:['/api/update','pull latest code + restart',90000]};
function show(t){const o=$('out');o.style.display='block';o.textContent=t;o.scrollTop=o.scrollHeight;
@@ -718,13 +731,13 @@ def _switch_later(name: str) -> None:
Object.values(d.groups).forEach(v=>Object.values(v).forEach(r=>{if(r&&r.key)o[r.key]=r}));return o}
const STAT=[['engine_rpm','engine rpm','⚙'],['hybrid_battery_pct','hybrid battery','🔋'],
['module_voltage','12V system','⚡'],['coolant_c','coolant','🌡'],['engine_load_pct','engine load','📈']];
-async function poll(){
- try{const s=await(await F('/api/status')).json();
+async function poll(signal){
+ try{const s=await(await F('/api/status',{signal})).json();
if(s.error==='token required'){$('status').innerHTML='open from the app link or Tailscale';}
else{const f=s.facts||{};$('status').innerHTML=(f.network||'')+' · '+(f['your temperature']||'')+'
'+(f.uptime||'')+'';
if(s.listening!==undefined)setListen(s.listening);}
}catch(e){$('status').innerHTML='cannot reach the car'}
- try{const d=await(await F('/api/obd/all')).json();
+ try{const d=await(await F('/api/obd/all',{signal})).json();
if(d&&d.groups&&Object.keys(d.groups).length){
const m=flat(d);const spd=m.speed_kmh;
$('spd').textContent=spd&&spd.value!=null?spd.value:'-';
@@ -760,9 +773,9 @@ def _switch_later(name: str) -> None:
// disproven parked (yaw) and stays out. The label says candidate until
// petrus watches this bar track his wheel live - that test is the point of
// unhiding it.
-async function pollSteer(){
+async function pollSteer(signal){
try{
- const d=await(await F('/api/steering')).json();
+ const d=await(await F('/api/steering',{signal})).json();
const st=(d&&d.ok!==false&&d.value!=null)?{last:d.value}:null, w=$('steerwrap');
if(!st||st.last==null){ $('steerval').textContent='-'; $('steerfill').style.width='0';
w.className='steer stale'; return; }
diff --git a/tests/test_dash_loop.py b/tests/test_dash_loop.py
index a61792a..b2d22eb 100644
--- a/tests/test_dash_loop.py
+++ b/tests/test_dash_loop.py
@@ -8,6 +8,34 @@
SRC = pathlib.Path(__file__).resolve().parents[1] / "carwatch" / "webchat.py"
+def _fetch_helper_js():
+ text = SRC.read_text()
+ m = re.search(r"const F=\(u,o=\{\},ms=4000\)=>.*?\};\n", text, re.S)
+ assert m, "F helper not found in webchat.py"
+ return m.group(0)
+
+
+F_HARNESS = r"""
+%s
+const _q=(u)=>u;
+const seen=[];
+const fetch=(u,o)=>{seen.push(o.signal);return new Promise(()=>{});}; // never answers
+(async()=>{
+ const AbortControllerReal=AbortController;
+ // 1. no outer signal: F's own timeout must abort the signal fetch received
+ F('/a',{},20); await new Promise(r=>setTimeout(r,60));
+ const ownTimeoutAborts=seen[0]&&seen[0].aborted===true;
+ // 2. caller passes {signal:undefined} (pollVoice called by hand): same
+ F('/b',{signal:undefined},20); await new Promise(r=>setTimeout(r,60));
+ const undefinedOuterOk=seen[1]&&seen[1].aborted===true;
+ // 3. outer signal aborted by the scheduler must abort the request before F's timeout
+ const outer=new AbortControllerReal(); F('/c',{signal:outer.signal},10000); outer.abort(); await new Promise(r=>setTimeout(r,10));
+ const outerAborts=seen[2]&&seen[2].aborted===true;
+ console.log(JSON.stringify({ownTimeoutAborts,undefinedOuterOk,outerAborts,distinct:seen[2]!==outer.signal}));
+})();
+"""
+
+
def _scheduler_js():
text = SRC.read_text()
m = re.search(r"const CW_FAST=.*?function cwLoop.*?run\(\);\}\n", text, re.S)
@@ -17,6 +45,7 @@ def _scheduler_js():
HARNESS = r"""
const document={hidden:false,addEventListener(){}};
+const AbortController=class{constructor(){this.signal={aborted:false,_l:[],addEventListener(n,f){this._l.push(f)}}}abort(){this.signal.aborted=true;this.signal._l.forEach(f=>f())}};
let now=0; const timers=[];
const setTimeout=(f,ms)=>{timers.push({at:now+ms,f});return timers.length};
const clearTimeout=(id)=>{if(id)timers[id-1]=null};
@@ -28,15 +57,17 @@ def _scheduler_js():
const Date={now:()=>now};
%s
(async()=>{
-let calls=0; const stalled=()=>{calls++;return new Promise(()=>{});}; // never settles
+let calls=0, settled=0; const order=[];
+// never settles on its own, but honours the abort signal like a real fetch
+const stalled=(signal)=>{calls++;order.push('call'+calls);return new Promise((res,rej)=>{signal.addEventListener('abort',()=>{settled++;order.push('settle'+calls);rej(new Error('aborted'))})})};
cwLoop('voice',stalled);
await advance(0); // first call
const c1=calls;
await advance(CW_DEADLINE-1); // before the deadline: no second call (non-overlap)
const c2=calls;
-await advance(CW_FAST.voice+2); // deadline passed, next interval elapsed
+await advance(CW_FAST.voice+2); // deadline passed: first run aborted + settled, next interval elapsed
const c3=calls;
-console.log(JSON.stringify({c1,c2,c3,deadline:CW_DEADLINE}));
+console.log(JSON.stringify({c1,c2,c3,settled,order,deadline:CW_DEADLINE}));
})();
"""
@@ -50,3 +81,20 @@ def test_stalled_poll_does_not_stop_the_loop(tmp_path):
assert r["c1"] == 1, r
assert r["c2"] == 1, "a second poll started while the first was still pending"
assert r["c3"] == 2, "the loop stopped after a poll that never settled"
+ assert r["settled"] == 1, "the deadline did not abort the stalled poll"
+ assert r["order"] == ["call1", "settle1", "call2"], f"second run started before the first settled: {r['order']}"
+
+
+@pytest.mark.skipif(shutil.which("node") is None, reason="node not installed")
+def test_fetch_helper_abort_reaches_the_request(tmp_path):
+ """codexmb, #61: Object.assign({signal}, o) let the caller's options overwrite
+ the helper's own signal, so its timeout never aborted the request."""
+ js = tmp_path / "f.js"
+ js.write_text(F_HARNESS % _fetch_helper_js())
+ out = subprocess.run(["node", str(js)], capture_output=True, text=True, timeout=30)
+ assert out.returncode == 0, out.stderr
+ r = __import__("json").loads(out.stdout.strip().splitlines()[-1])
+ assert r["ownTimeoutAborts"], "F's own timeout did not abort the request (no outer signal)"
+ assert r["undefinedOuterOk"], "a caller passing {signal: undefined} disabled F's timeout"
+ assert r["outerAborts"], "an aborted outer signal did not abort the request"
+ assert r["distinct"], "F passed the outer signal itself instead of its linked one"