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
47 changes: 30 additions & 17 deletions carwatch/webchat.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,37 +531,50 @@ 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();}
function cwIsIdle(){return Date.now()-cwLastActive>60000;}
['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;
Expand Down Expand Up @@ -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='<span style=color:#ffc857>open from the app link or Tailscale</span>';}
else{const f=s.facts||{};$('status').innerHTML=(f.network||'')+' &middot; '+(f['your temperature']||'')+'<br><b>'+(f.uptime||'')+'</b>';
if(s.listening!==undefined)setListen(s.listening);}
}catch(e){$('status').innerHTML='<span style=color:#ff667d>cannot reach the car</span>'}
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:'-';
Expand Down Expand Up @@ -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; }
Expand Down
54 changes: 51 additions & 3 deletions tests/test_dash_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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};
Expand All @@ -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}));
})();
"""

Expand All @@ -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"
Loading