diff --git a/carwatch/webchat.py b/carwatch/webchat.py index 1ca4640..39f8cf8 100644 --- a/carwatch/webchat.py +++ b/carwatch/webchat.py @@ -531,7 +531,8 @@ def _switch_later(name: str) -> None: d.textContent=s.answer||'';} bw.style.display=bar?'block':'none'; } -async function pollVoice(){try{const r=await fetch(_q('/api/voice/state'));renderVoice(await r.json());}catch(e){}} +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)}} $('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 @@ -547,12 +548,20 @@ def _switch_later(name: str) -> None: function cwActive(){cwLastActive=Date.now();} function cwIsIdle(){return Date.now()-cwLastActive>60000;} ['pointerdown','keydown','touchstart','wheel'].forEach(ev=>document.addEventListener(ev,cwActive,{passive:true})); -function cwLoop(name,fn){const run=async()=>{if(!document.hidden){try{await fn()}catch(e){}} +// 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; +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)}} 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); - return fetch(_q(u),Object.assign({signal:c.signal},o)).finally(()=>clearTimeout(t));}; + // 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});}; 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; diff --git a/tests/test_dash_loop.py b/tests/test_dash_loop.py new file mode 100644 index 0000000..a61792a --- /dev/null +++ b/tests/test_dash_loop.py @@ -0,0 +1,52 @@ +"""The dash page's poll scheduler (cwLoop) must keep scheduling when a poll +never settles. Post-merge review of #57 (codexmb): the first version awaited +fn() with no bound, so one stalled voice request stopped that loop for good, +which setInterval never did. The scheduler is extracted from webchat.py and +run under node with fake timers; skipped when node is not installed.""" +import pathlib, re, shutil, subprocess, pytest + +SRC = pathlib.Path(__file__).resolve().parents[1] / "carwatch" / "webchat.py" + + +def _scheduler_js(): + text = SRC.read_text() + m = re.search(r"const CW_FAST=.*?function cwLoop.*?run\(\);\}\n", text, re.S) + assert m, "cwLoop block not found in webchat.py" + return m.group(0) + + +HARNESS = r""" +const document={hidden:false,addEventListener(){}}; +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}; +// Promise continuations (the race settling, finally, the reschedule) are +// microtasks: drain them after every fired timer or the harness never sees +// the next setTimeout(run, ...) that the scheduler registers. +const flush=async()=>{for(let i=0;i<20;i++)await Promise.resolve();}; +async function advance(ms){const target=now+ms;for(;;){const due=timers.filter(t=>t&&t.at<=target).sort((a,b)=>a.at-b.at)[0];if(!due)break;now=due.at;timers[timers.indexOf(due)]=null;due.f();await flush();}now=target;await flush();} +const Date={now:()=>now}; +%s +(async()=>{ +let calls=0; const stalled=()=>{calls++;return new Promise(()=>{});}; // never settles +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 +const c3=calls; +console.log(JSON.stringify({c1,c2,c3,deadline:CW_DEADLINE})); +})(); +""" + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not installed") +def test_stalled_poll_does_not_stop_the_loop(tmp_path): + js = tmp_path / "loop.js" + js.write_text(HARNESS % _scheduler_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["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"