From d6a8fc77dcd6102881f134bd107774a631f3f8f6 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 30 Sep 2026 18:57:23 -0700 Subject: [PATCH] Detect stalled Walkieware connections with application heartbeats --- aesel/src/walkieware-thread.mjs | 15 ++++++++++----- lith/walkieware-socket.mjs | 1 + lith/walkieware-socket.test.mjs | 9 +++++++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/aesel/src/walkieware-thread.mjs b/aesel/src/walkieware-thread.mjs index 3705da183b..4c746b55c3 100644 --- a/aesel/src/walkieware-thread.mjs +++ b/aesel/src/walkieware-thread.mjs @@ -13,18 +13,22 @@ export function threadIdentity(storage,key,uuid=()=>crypto.randomUUID()) { const value={id:uuid(),code:null};storage.setItem(key+'-thread',JSON.stringify(value));return value; } export class WalkiewareThread { - constructor({storage,key,token,ledger,state,onStatus,onCommand,WebSocketImpl=globalThis.WebSocket,url='wss://aesthetic.computer/api/walkieware-stream'}) { - Object.assign(this,{storage,key,token,ledger,state,onStatus,onCommand,WebSocketImpl,url}); + constructor({storage,key,token,ledger,state,onStatus,onCommand,WebSocketImpl=globalThis.WebSocket,url='wss://aesthetic.computer/api/walkieware-stream',heartbeatMs=15000,maxIdleMs=45000,reconnectMs=3000}) { + Object.assign(this,{storage,key,token,ledger,state,onStatus,onCommand,WebSocketImpl,url,heartbeatMs,maxIdleMs,reconnectMs}); this.identity=threadIdentity(storage,key);this.revision=Number(storage.getItem(key+'-cloud-revision')||0);this.last=storage.getItem(key+'-cloud-ledger')||''; this.active=false;this.sending=false;this.ready=false; } async resume() { this.active=true;if(this.ws)return; - const token=await this.token();if(!token||!this.active)return; + const token=await this.token();if(!token||!this.active||this.ws)return; const ws=this.ws=new this.WebSocketImpl(this.url); + this.lastSeen=Date.now(); + this.heartbeat=setInterval(()=>{if(Date.now()-this.lastSeen>this.maxIdleMs)this.disconnect(ws);else if(this.ready)this.send({type:'ping'});},this.heartbeatMs); ws.onopen=()=>this.send({type:'authenticate',role:'device',token,id:this.identity.id}); ws.onmessage=async event=>{ let m;try{m=JSON.parse(event.data);}catch{return;} + if(this.ws!==ws)return; + this.lastSeen=Date.now(); if(m.type==='ready') { this.identity.code=m.thread.code;this.storage.setItem(this.key+'-thread',JSON.stringify(this.identity)); const cloud=ledgerText(m.thread.ledger),local=ledgerText(this.ledger()); @@ -46,12 +50,13 @@ export class WalkiewareThread { this.sync();this.update(); } }; - ws.onclose=()=>{if(this.ws!==ws)return;this.ws=null;this.ready=false;this.sending=false;this.onStatus(this.identity.code,'Offline');if(this.active)this.timer=setTimeout(()=>this.resume(),3000);}; + ws.onclose=()=>this.disconnect(ws); ws.onerror=()=>{}; } send(value){if(this.ws?.readyState===1)this.ws.send(JSON.stringify(value));} sync(){if(!this.ready||this.sending)return;const ledger=this.ledger(),next=ledgerText(ledger);if(next===this.last)return;this.sending=true;this.send({type:'sync',revision:this.revision,ledger});} update(){if(this.ready)this.send({type:'state',state:this.state()});} async flush(){for(let i=0;i<100;i++){if(!this.ready)throw Error('Connection lost; inspect history before retrying');if(!this.sending&&this.last===ledgerText(this.ledger()))return;await new Promise(r=>setTimeout(r,100));}throw Error('Version sync pending; inspect before retrying');} - suspend(){this.active=false;clearTimeout(this.timer);this.ws?.close();} + disconnect(ws){if(this.ws!==ws)return;this.ws=null;this.ready=false;this.sending=false;clearInterval(this.heartbeat);this.onStatus(this.identity.code,'Offline');try{ws.close();}catch{}if(this.active)this.timer=setTimeout(()=>this.resume(),this.reconnectMs);} + suspend(){this.active=false;clearTimeout(this.timer);if(this.ws)this.disconnect(this.ws);} } diff --git a/lith/walkieware-socket.mjs b/lith/walkieware-socket.mjs index e55856aa3a..c62a805544 100644 --- a/lith/walkieware-socket.mjs +++ b/lith/walkieware-socket.mjs @@ -43,6 +43,7 @@ export function attachWalkiewareSocket(server,{authenticate,store,authMs=5000,li if(!saved){send(ws,{type:'conflict',thread:publicThread(await (await store()).read(owner,row.code))});return;} row=saved;broadcast({type:'saved',thread:publicThread(row)});return; } + if(m.type==='ping'){send(ws,{type:'pong',online:!!room.device,role,peers:room.clients.size,attached:rooms.get(row._id)===room});return;} if(m.type==='state'&&role==='device') { const state=m.state; if(!state||JSON.stringify(state).length>525000)throw Error('Invalid state'); diff --git a/lith/walkieware-socket.test.mjs b/lith/walkieware-socket.test.mjs index dbc3620c31..8b5a419ec7 100644 --- a/lith/walkieware-socket.test.mjs +++ b/lith/walkieware-socket.test.mjs @@ -47,8 +47,13 @@ test('real sockets isolate accounts, persist versions and relay checked commands const command=await device.next('command');assert.equal(command.text,'Make it 3D');await agent.next('accepted'); agent.send({type:'command',id:'edit-2',action:'undo',baseVersion:0,baseHash:'stale'});assert.equal((await agent.next('result')).error,'Device busy'); device.send({type:'result',id:'edit-1',ok:true,head:1});assert.equal((await agent.next('result')).ok,true); - device.ws.close();assert.equal((await agent.next('presence')).online,false); - agent.send({type:'command',id:'edit-3',action:'undo',baseVersion:0,baseHash:'stale'});assert.match((await agent.next('result')).error,/offline/); + agent.ws.close();await once(agent.ws,'close'); + const second=await client(f.url,{role:'agent',code:'wwRuboh'});assert.equal((await second.next('ready')).online,true); + device.send({type:'ping'});assert.equal((await device.next('pong')).attached,true); + second.ws.close(); + device.ws.close();await once(device.ws,'close'); + const offline=await client(f.url,{role:'agent',code:'wwRuboh'});assert.equal((await offline.next('ready')).online,false); + offline.send({type:'command',id:'edit-3',action:'undo',baseVersion:0,baseHash:'stale'});assert.match((await offline.next('result')).error,/offline/); }); test('phone client persists identity, coalesces ledger sync without echo loop and survives reconnect',{timeout:5000},async t=>{ const f=await fixture(t),values=new Map(),storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}; -- 2.54.0