Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions carwatch/webchat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
52 changes: 52 additions & 0 deletions tests/test_dash_loop.py
Original file line number Diff line number Diff line change
@@ -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"
Loading