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"