mirror of
https://github.com/telemt/telemt.git
synced 2026-11-02 13:52:03 +03:00
Bounded WEB HTTP conveyor with replay-safe streaming downlink added
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
'use strict';
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const vm=require('node:vm');
|
||||
const path=require('node:path');
|
||||
|
||||
function renderedPage(){
|
||||
if(process.argv.includes('--stdin'))return fs.readFileSync(0,'utf8');
|
||||
let page=fs.readFileSync(path.join(__dirname,'document.html'),'utf8');
|
||||
const modules={RESPONSE:'response',REQUEST:'request',BUFFER:'buffers',RECOVERY:'recovery',CONVEYOR:'conveyor',DOWNLINK:'downlink'};
|
||||
page=page.replace('__DIAGNOSTIC_RUNTIME__','');
|
||||
for(const [key,file] of Object.entries(modules))if(page.includes('__'+key+'_RUNTIME__'))page=page.replace('__'+key+'_RUNTIME__',()=>fs.readFileSync(path.join(__dirname,file+'.js'),'utf8'));
|
||||
page=page.replace('__RUNTIME__',()=>fs.readFileSync(path.join(__dirname,'runtime.js'),'utf8'));
|
||||
const values={BOOTSTRAP:'A'.repeat(43),HOST:'proxy.example.com',BASE_PREFIX:'',CARRIER_METHOD:'POST',NEGOTIATION_ENABLED:'true',CANDIDATE_COUNT:4,CARRIER_DEADLINES:'3,5,8,12',LONG_POLL_SECS:25,BRIDGE_REQUEST_SECS:10,BRIDGE_RETRY_SECS:90,BRIDGE_RECOVERY_SECS:15,WEBSOCKET_OPEN_SECS:15,RECONNECT_GRACE_SECS:120,CARRIER_PROBE_COALESCE_MS:0,BATCH_LIMIT:2097152,QUEUE_LIMIT:33554432,QUEUE_ITEMS:16384,MAX_STREAMS:1024,STATUS_FUNCTION:"state=>{if(port&&!closed)port.postMessage({t:'status',state})}",HELLO_TIMEOUT_CALLBACK:"()=>fail('timeout')",PAGEHIDE_CALLBACK:'()=>close(true)'};
|
||||
return page.replace(/__([A-Z_]+)__/g,(all,key)=>key.startsWith('DIAGNOSTIC_')?'':String(values[key]??''));
|
||||
}
|
||||
function frame(type,id=0,payload=[]){
|
||||
const data=new Uint8Array(8+payload.length),view=new DataView(data.buffer);
|
||||
data[0]=type;data[1]=id>>>16;data[2]=id>>>8;data[3]=id;view.setUint32(4,payload.length);data.set(payload,8);return data.buffer;
|
||||
}
|
||||
function join(...frames){const data=new Uint8Array(frames.reduce((n,f)=>n+f.byteLength,0));let offset=0;for(const f of frames){data.set(new Uint8Array(f),offset);offset+=f.byteLength}return data.buffer}
|
||||
async function flush(){for(let i=0;i<40;i++)await Promise.resolve()}
|
||||
function environment(page,android=false){
|
||||
let now=1000,nextTimer=1;const timers=new Map(),events=new Map(),requests=[],received=[];
|
||||
const port={onmessage:null,start(){},close(){},postMessage(value,transfer){received.push(structuredClone(value,{transfer:transfer||[]}))}};
|
||||
const context={ArrayBuffer,Uint8Array,DataView,TextDecoder,TextEncoder,URL,Headers,AbortController,ReadableStream,structuredClone,console,
|
||||
location:{hash:android?'#android='+'A'.repeat(43):'',pathname:'/',search:'?bridge=test'},history:{replaceState(){}},parent:{},
|
||||
performance:{now:()=>now},Date:class extends Date{static now(){return now}},document:{visibilityState:'visible',addEventListener(){}},
|
||||
addEventListener:(name,fn)=>events.set(name,fn),setTimeout:(fn,delay)=>{const id=nextTimer++;timers.set(id,{fn,at:now+delay});return id},clearTimeout:id=>timers.delete(id),
|
||||
fetch:(url,options)=>new Promise((resolve,reject)=>{const request={url,options,resolve,reject};requests.push(request);options.signal?.addEventListener('abort',()=>reject(new Error('aborted')),{once:true})}),
|
||||
WebSocket:class {static OPEN=1;static CLOSING=2;constructor(){throw new Error('unexpected websocket')}}
|
||||
};
|
||||
if(android)context.TelegramWebProxy={onmessage:null,postMessage(value){received.push(typeof value==='string'?JSON.parse(value):structuredClone(value))}};
|
||||
vm.createContext(context);
|
||||
for(const match of page.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g))vm.runInContext(match[1],context);
|
||||
if(!android)events.get('message')({source:context.parent,origin:'http://127.0.0.1:12345',data:{t:'tproxy-init',v:1},ports:[port]});
|
||||
const send=data=>android?context.TelegramWebProxy.onmessage({data}):port.onmessage({data});
|
||||
const pending=suffix=>requests.filter(r=>r.url.endsWith(suffix)&&!r.answered);
|
||||
const answer=(request,status,body=null,headers={})=>{request.answered=true;request.resolve({status,headers:new Headers(headers),body:body===null?null:new ReadableStream({start(c){c.enqueue(new Uint8Array(body));c.close()}})})};
|
||||
return {context,send,pending,answer,received,requests,async tick(ms){const until=now+ms;for(;;){const entry=[...timers].filter(([,t])=>t.at<=until).sort((a,b)=>a[1].at-b[1].at)[0];if(!entry)break;now=entry[1].at;timers.delete(entry[0]);entry[1].fn();await flush()}now=until;await flush()},close(){events.get('pagehide')();}};
|
||||
}
|
||||
async function session(page,carrier='https',window=1,android=false){
|
||||
const env=environment(page,android);env.send(frame(16,0,[1]));await flush();
|
||||
const request=env.pending('/api/v1/session')[0];assert.ok(request,'session requested');
|
||||
const headers={'X-Session-Token':'B'.repeat(43),'X-Down-Cursor':'0','X-Carrier-Mode':carrier,'X-Carrier-Attempt':'1','X-Carrier-Candidate-Count':'4','X-Carrier-Deadline':'12','X-Carrier-State':'provisional'};
|
||||
if(window!==null)headers['X-Telemt-Up-Window']=String(window);
|
||||
env.answer(request,200,frame(17),headers);await flush();return env;
|
||||
}
|
||||
const tests=[];
|
||||
function test(name,run){tests.push({name,run})}
|
||||
function ack(env,request){env.answer(request,204,null,{'X-Up-Ack':request.options.headers['X-Up-Seq']})}
|
||||
function binary(env){return env.received.filter(value=>value instanceof ArrayBuffer&&new Uint8Array(value)[0]!==17)}
|
||||
function streaming(env,request,next='1',length=null){
|
||||
let controller;const body=new ReadableStream({start(value){controller=value}}),headers={'X-Down-Cursor':next};if(length!==null)headers['Content-Length']=String(length);
|
||||
request.answered=true;request.resolve({status:200,headers:new Headers(headers),body});return controller;
|
||||
}
|
||||
test('already queued OPEN and DATA share the zero-wait startup request',async page=>{
|
||||
const env=await session(page);const body=join(frame(1,1),frame(2,1,[7,8]));env.send(body);await flush();
|
||||
assert.equal(env.pending('/api/v1/up')[0].options.body.byteLength,body.byteLength);env.close();await flush();
|
||||
});
|
||||
test('negotiated conveyor sends DATA before the OPEN acknowledgement',async page=>{
|
||||
for(const android of [false,true])for(const carrier of ['https','https-lanes']){
|
||||
const env=await session(page,carrier,4,android);env.send(frame(1,1));await flush();env.send(frame(2,1,[9]));await flush();
|
||||
assert.equal(env.pending('/api/v1/up').length,2,carrier+' startup must not wait for ACK1');env.close();await flush();
|
||||
}
|
||||
});
|
||||
test('unsupported server and disabled conveyor remain serialized',async page=>{
|
||||
for(const window of [null,1]){
|
||||
const env=await session(page,'https',window);env.send(frame(1,1));await flush();env.send(frame(2,1,[9]));await flush();
|
||||
assert.equal(env.pending('/api/v1/up').length,1);env.close();await flush();
|
||||
}
|
||||
});
|
||||
test('lost ACK1 cannot slide the window after ACK2 through ACK4',async page=>{
|
||||
const env=await session(page,'https',4);env.send(frame(1,1));await flush();
|
||||
for(let i=0;i<6;i++){env.send(frame(2,1,[i]));await flush()}
|
||||
const up=env.pending('/api/v1/up');assert.equal(up.length,4);
|
||||
for(const request of up.slice(1)){ack(env,request);await flush()}
|
||||
assert.equal(env.requests.filter(r=>r.url.endsWith('/api/v1/up')).length,4);
|
||||
ack(env,up[0]);await flush();
|
||||
const next=env.pending('/api/v1/up')[0];assert.equal(next.options.headers['X-Up-Seq'],'5');assert.equal(next.options.headers['X-Telemt-Up-Confirmed'],'4');
|
||||
env.close();await flush();
|
||||
});
|
||||
test('early downlink cannot deliver native data before a verified commit',async page=>{
|
||||
const env=await session(page,'https',4);env.send(frame(1,1));await flush();
|
||||
const down=env.pending('/api/v1/down')[0];assert.ok(down,'down starts before ACK');const data=frame(2,1,[4]);
|
||||
const stream=streaming(env,down,'1',data.byteLength);stream.enqueue(new Uint8Array(data));await flush();assert.equal(binary(env).length,0);
|
||||
ack(env,env.pending('/api/v1/up')[0]);await flush();assert.equal(binary(env).length,1);
|
||||
assert.equal(env.requests.filter(r=>r.url.endsWith('/api/v1/down')).length,1,'cursor cannot advance before EOF');
|
||||
stream.close();await flush();assert.equal(env.pending('/api/v1/down')[0].options.headers['X-Down-Cursor'],'1');env.close();await flush();
|
||||
});
|
||||
test('DATA and WINDOW prefixes are delivered once across a mid-body network failure',async page=>{
|
||||
for(const carrier of ['https','https-lanes']){
|
||||
const env=await session(page,carrier,4);env.send(frame(1,1));await flush();ack(env,env.pending('/api/v1/up')[0]);await flush();
|
||||
const first=frame(2,1,[1,2]),credit=frame(4,1,[0,0,0,2]),last=frame(3,1),body=join(first,credit,last);
|
||||
const stream=streaming(env,env.pending('/api/v1/down')[0],'1',body.byteLength);
|
||||
stream.enqueue(new Uint8Array(join(first,credit)));await flush();assert.equal(binary(env).length,2);
|
||||
stream.error(new Error('connection lost'));await flush();
|
||||
const replay=env.pending('/api/v1/down')[0];assert.ok(replay);assert.equal(replay.options.headers['X-Down-Cursor'],'0');
|
||||
env.answer(replay,200,body,{'X-Down-Cursor':'1','Content-Length':String(body.byteLength)});await flush();
|
||||
assert.deepEqual(binary(env).map(data=>new Uint8Array(data)[0]),[2,4,3]);
|
||||
assert.equal(env.pending('/api/v1/down')[0].options.headers['X-Down-Cursor'],'1');
|
||||
assert.equal(env.requests.filter(r=>r.url.endsWith('/api/v1/down')).length,3,'successful replay is consumed without another old-cursor request');env.close();await flush();
|
||||
}
|
||||
});
|
||||
test('changed replay prefix fails closed without duplicate native frames',async page=>{
|
||||
const env=await session(page,'https',4);env.send(frame(1,1));await flush();ack(env,env.pending('/api/v1/up')[0]);await flush();
|
||||
const data=frame(2,1,[7]),tail=frame(2,1,[8]),body=join(data,tail),stream=streaming(env,env.pending('/api/v1/down')[0],'1',body.byteLength);
|
||||
stream.enqueue(new Uint8Array(data));await flush();stream.error(new Error('lost'));await flush();
|
||||
const changed=join(frame(2,1,[9]),tail);env.answer(env.pending('/api/v1/down')[0],200,changed,{'X-Down-Cursor':'1','Content-Length':String(changed.byteLength)});await flush();
|
||||
assert.equal(binary(env).length,1);assert.ok(env.received.some(value=>value&&value.t==='close'));env.close();await flush();
|
||||
});
|
||||
test('partial frame boundaries never escape to native and cancellation owns every lease',async page=>{
|
||||
const env=await session(page,'https-lanes',4,true);env.send(frame(1,1));await flush();ack(env,env.pending('/api/v1/up')[0]);await flush();
|
||||
const data=frame(2,1,[1,2,3]),stream=streaming(env,env.pending('/api/v1/down')[0],'1',data.byteLength);
|
||||
stream.enqueue(new Uint8Array(data,0,5));await flush();assert.equal(binary(env).length,0);
|
||||
stream.enqueue(new Uint8Array(data,5));await flush();assert.equal(binary(env).length,1);
|
||||
env.close();await flush();assert.equal(binary(env).length,1);
|
||||
});
|
||||
test('post-commit lane batching keeps OPEN and DATA from one native message together',async page=>{
|
||||
const env=await session(page,'https-lanes',1);env.send(frame(1,1));await flush();ack(env,env.pending('/api/v1/up')[0]);await flush();
|
||||
const body=join(frame(1,2),frame(2,2,[7]));env.send(body);await flush();
|
||||
assert.equal(env.pending('/api/v1/up')[0].options.body.byteLength,body.byteLength);env.close();await flush();
|
||||
});
|
||||
|
||||
test('page-owned HTTP method and identity survive frozen retries',async page=>{
|
||||
const expected=/const carrierMethod='(POST|PUT)'/.exec(page)[1],env=await session(page,'https',4);
|
||||
assert.equal(env.requests.find(r=>r.url.endsWith('/api/v1/session')).options.method,'POST');
|
||||
env.send(frame(1,1));await flush();const first=env.pending('/api/v1/up')[0];ack(env,first);await flush();
|
||||
env.send(frame(2,1,[7]));await flush();const original=env.pending('/api/v1/up')[0];
|
||||
original.answered=true;original.reject(new Error('lost'));await flush();const replay=env.pending('/api/v1/up')[0];
|
||||
assert.equal(replay.options.body,original.options.body);assert.deepEqual(replay.options.headers,original.options.headers);
|
||||
assert.equal(replay.options.method,expected);ack(env,replay);await flush();
|
||||
for(const request of env.requests.filter(r=>/\/api\/v1\/(up|down)$/.test(r.url)))assert.equal(request.options.method,expected);
|
||||
env.close();await flush();assert.equal(env.requests.find(r=>r.options.method==='DELETE').options.method,'DELETE');
|
||||
});
|
||||
|
||||
test('RTT scales time but adds no OPEN ACK barrier or coalescing timer',async page=>{
|
||||
for(const rtt of [40,100,400]){
|
||||
const env=await session(page,'https',4);env.send(frame(1,1));await flush();await env.tick(1);
|
||||
env.send(frame(2,1,[5]));await flush();assert.equal(env.pending('/api/v1/up').length,2);
|
||||
await env.tick(rtt-1);for(const request of env.pending('/api/v1/up'))ack(env,request);await flush();
|
||||
assert.equal(env.received.filter(value=>value instanceof ArrayBuffer&&new Uint8Array(value)[0]===17).length,1);
|
||||
assert.ok(env.received.filter(value=>value&&value.t==='status').every(value=>Object.keys(value).sort().join(',')==='state,t'));
|
||||
env.close();await flush();
|
||||
}
|
||||
});
|
||||
|
||||
test('simultaneous failed lane polls each consume their own exact replay',async page=>{
|
||||
const env=await session(page,'https-lanes',4);env.send(frame(1,1));await flush();ack(env,env.pending('/api/v1/up')[0]);await flush();
|
||||
env.send(frame(1,2));await flush();ack(env,env.pending('/api/v1/up')[0]);await flush();
|
||||
const streams=[];
|
||||
for(const request of env.pending('/api/v1/down')){
|
||||
const id=Number(request.options.headers['X-Lane-ID']),data=frame(2,id,[id]),body=join(data,frame(4,id,[0,0,0,1]));
|
||||
const response=streaming(env,request,'1',body.byteLength);response.enqueue(new Uint8Array(data));streams.push({id,response,body});
|
||||
}
|
||||
await flush();assert.equal(binary(env).length,2);for(const item of streams)item.response.error(new Error('lost'));await flush();
|
||||
for(let round=0;round<3;round++){
|
||||
for(const request of env.pending('/api/v1/down').filter(r=>r.options.headers['X-Down-Cursor']==='0')){
|
||||
const body=streams.find(item=>String(item.id)===request.options.headers['X-Lane-ID']).body;
|
||||
env.answer(request,200,body,{'X-Down-Cursor':'1','Content-Length':String(body.byteLength)});
|
||||
}await flush();
|
||||
}
|
||||
assert.equal(binary(env).length,4);assert.equal(env.pending('/api/v1/down').length,2);
|
||||
assert.ok(env.pending('/api/v1/down').every(r=>r.options.headers['X-Down-Cursor']==='1'));env.close();await flush();
|
||||
});
|
||||
|
||||
test('incremental decoder rejects changed cursor, invalid WINDOW and cross-lane frames',async page=>{
|
||||
for(const invalid of ['cursor','credit','lane']){
|
||||
const env=await session(page,'https-lanes',4);env.send(frame(1,1));await flush();ack(env,env.pending('/api/v1/up')[0]);await flush();
|
||||
const body=invalid==='credit'?frame(4,1,[0,0,0,0]):frame(2,invalid==='lane'?2:1,[1]);
|
||||
env.answer(env.pending('/api/v1/down')[0],200,body,{'X-Down-Cursor':invalid==='cursor'?'2':'1','Content-Length':String(body.byteLength)});await flush();
|
||||
assert.equal(binary(env).length,0);assert.ok(env.received.some(value=>value&&value.t==='close'));env.close();await flush();
|
||||
}
|
||||
});
|
||||
|
||||
test('a replay without Content-Length cannot truncate an already known batch',async page=>{
|
||||
const env=await session(page,'https',4);env.send(frame(1,1));await flush();ack(env,env.pending('/api/v1/up')[0]);await flush();
|
||||
const prefix=frame(2,1,[1]),tail=frame(2,1,[2]),body=join(prefix,tail),response=streaming(env,env.pending('/api/v1/down')[0],'1',body.byteLength);
|
||||
response.enqueue(new Uint8Array(prefix));await flush();response.error(new Error('lost'));await flush();
|
||||
env.answer(env.pending('/api/v1/down')[0],200,prefix,{'X-Down-Cursor':'1'});await flush();
|
||||
assert.equal(binary(env).length,1);assert.ok(env.received.some(value=>value&&value.t==='close'));
|
||||
assert.ok(!env.requests.some(r=>r.url.endsWith('/api/v1/down')&&r.options.headers['X-Down-Cursor']==='1'));env.close();await flush();
|
||||
});
|
||||
|
||||
test('a pipelined startup flight adopts the recovery budget once another ACK commits',async page=>{
|
||||
const env=await session(page,'https',4);env.send(frame(1,1));await flush();env.send(frame(2,1,[7]));await flush();
|
||||
const [head,tail]=env.pending('/api/v1/up');ack(env,head);await flush();tail.answered=true;tail.reject(new Error('lost'));await flush();
|
||||
const replay=env.pending('/api/v1/up')[0];assert.ok(replay,'post-commit recovery replays immediately instead of staying in the startup backoff');
|
||||
assert.equal(replay.options.body,tail.options.body);ack(env,replay);await flush();env.close();await flush();
|
||||
});
|
||||
|
||||
test('candidate rollback restores every unacknowledged OPEN and DATA without a second WELCOME',async page=>{
|
||||
const env=await session(page,'https',4);env.send(frame(1,1));await flush();env.send(frame(2,1,[7]));await flush();
|
||||
const retired=env.requests.filter(r=>/\/api\/v1\/(up|down)$/.test(r.url));await env.tick(3001);
|
||||
const next=env.pending('/api/v1/session')[0];assert.ok(next,'candidate fallback creates a new session');
|
||||
env.answer(next,200,frame(17),{'X-Session-Token':'C'.repeat(43),'X-Down-Cursor':'0','X-Carrier-Mode':'https-lanes','X-Carrier-Attempt':'2','X-Carrier-Candidate-Count':'4','X-Carrier-Deadline':'12','X-Carrier-State':'provisional','X-Telemt-Up-Window':'4'});await flush();
|
||||
const first=env.requests.find(r=>r.url.endsWith('/api/v1/up')&&r.options.headers.Authorization==='Bearer '+'C'.repeat(43));
|
||||
assert.ok(first);assert.deepEqual(new Uint8Array(first.options.body),new Uint8Array(join(frame(1,1),frame(2,1,[7]))));
|
||||
assert.ok(retired.every(r=>r.options.signal.aborted));assert.equal(env.received.filter(value=>value instanceof ArrayBuffer&&new Uint8Array(value)[0]===17).length,1);
|
||||
ack(env,first);await flush();env.close();await flush();
|
||||
});
|
||||
|
||||
test('downlink prefix reservations are bounded, cancellable and released on retirement',async page=>{
|
||||
const env=environment(page),budget=env.context.TelemtBridgeDownlink.budget(()=>16),first=await budget.acquire(16);
|
||||
const controller=new AbortController();let acquired=false;
|
||||
const parked=budget.acquire(8,controller.signal).then(release=>{acquired=true;release()},()=>{});await flush();assert.equal(acquired,false);
|
||||
controller.abort();await parked;first();assert.equal(budget.used(),0);
|
||||
const release=await budget.acquire(16);release();release();assert.equal(budget.used(),0);env.close();await flush();
|
||||
});
|
||||
|
||||
test('two interrupted and differently chunked replays never duplicate DATA WINDOW or CLOSE',async page=>{
|
||||
const env=await session(page,'https',4);env.send(frame(1,1));await flush();ack(env,env.pending('/api/v1/up')[0]);await flush();
|
||||
const data=frame(2,1,[1]),credit=frame(4,1,[0,0,0,1]),close=frame(3,1),body=join(data,credit,close);
|
||||
const first=streaming(env,env.pending('/api/v1/down')[0],'1',body.byteLength);
|
||||
first.enqueue(new Uint8Array(data));await flush();first.error(new Error('lost once'));await flush();
|
||||
const second=streaming(env,env.pending('/api/v1/down')[0],'1',body.byteLength);
|
||||
const prefix=new Uint8Array(join(data,credit));second.enqueue(prefix.subarray(0,3));second.enqueue(prefix.subarray(3));await flush();
|
||||
assert.deepEqual(binary(env).map(value=>new Uint8Array(value)[0]),[2,4]);second.error(new Error('lost twice'));await flush();await env.tick(1000);
|
||||
const last=streaming(env,env.pending('/api/v1/down')[0],'1',body.byteLength);
|
||||
for(const byte of new Uint8Array(body))last.enqueue(new Uint8Array([byte]));last.close();
|
||||
for(let i=0;i<20&&(!env.pending('/api/v1/down').length);i++)await flush();
|
||||
assert.deepEqual(binary(env).map(value=>new Uint8Array(value)[0]),[2,4,3]);assert.equal(env.pending('/api/v1/down')[0].options.headers['X-Down-Cursor'],'1');
|
||||
env.close();await flush();
|
||||
});
|
||||
|
||||
test('multiple precommit lanes survive fallback without losing stream ordering',async page=>{
|
||||
const env=await session(page,'https-lanes',4);env.send(join(frame(1,1),frame(1,2)));await flush();
|
||||
env.send(join(frame(2,2,[2]),frame(2,1,[1])));await flush();await env.tick(3001);
|
||||
const next=env.pending('/api/v1/session')[0];env.answer(next,200,frame(17),{'X-Session-Token':'C'.repeat(43),'X-Down-Cursor':'0','X-Carrier-Mode':'https','X-Carrier-Attempt':'2','X-Carrier-Candidate-Count':'4','X-Carrier-Deadline':'12','X-Carrier-State':'provisional','X-Telemt-Up-Window':'4'});await flush();
|
||||
const requests=env.requests.filter(r=>r.url.endsWith('/api/v1/up')&&r.options.headers.Authorization==='Bearer '+'C'.repeat(43));
|
||||
const frames=[];for(const request of requests){const bytes=new Uint8Array(request.options.body);for(let offset=0;offset<bytes.length;){const view=new DataView(bytes.buffer,offset);frames.push([bytes[offset],bytes[offset+3]]);offset+=8+view.getUint32(4)}}
|
||||
for(const id of [1,2])assert.deepEqual(frames.filter(value=>value[1]===id),[[1,id],[2,id]]);
|
||||
assert.equal(frames.length,4);env.close();await flush();
|
||||
});
|
||||
|
||||
test('receiver retirement releases shared prefix capacity without exposing a partial frame',async page=>{
|
||||
const env=environment(page),module=env.context.TelemtBridgeDownlink,budget=module.budget(()=>18),received=[];
|
||||
function receiver(id){
|
||||
let cursor='0',stream;const controller=new AbortController(),response={status:200,headers:new Headers({'X-Down-Cursor':'1','Content-Length':'18'}),body:new ReadableStream({start(value){stream=value}})};
|
||||
const owner=module.create({failure:(reason,message)=>Object.assign(new Error(message),{telemtReason:reason}),alive:()=>true,cursor:()=>cursor,lane:id,limit:()=>18,budget,cancel(){},ready:()=>Promise.resolve(),deliver:data=>received.push(new Uint8Array(data)),advance:value=>{cursor=value},finished(){}});
|
||||
const result=owner.read(response,controller.signal).catch(()=>{});return {owner,result,stream,controller,cursor:()=>cursor};
|
||||
}
|
||||
const a=receiver(1);a.stream.enqueue(new Uint8Array(frame(2,1,[1])));await flush();
|
||||
const b=receiver(2);b.stream.enqueue(new Uint8Array(join(frame(2,2,[2]),frame(2,2,[3]))));b.stream.close();await flush();
|
||||
assert.equal(budget.used(),18);assert.equal(received.length,1);assert.equal(b.cursor(),'0');
|
||||
a.owner.close();a.controller.abort();a.stream.error(new Error('retired'));await a.result;await b.result;
|
||||
assert.equal(received.length,3);assert.equal(b.cursor(),'1');assert.equal(budget.used(),0);
|
||||
b.owner.close();env.close();await flush();
|
||||
});
|
||||
|
||||
(async()=>{const page=renderedPage();let failed=0;for(const {name,run} of tests){let timer;try{await Promise.race([run(page),new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('test made no bounded progress')),5000)})]);console.log('ok - '+name)}catch(error){failed++;console.error('not ok - '+name+'\n'+error.stack)}finally{clearTimeout(timer)}}if(failed)process.exitCode=1})();
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Exercises the actual generated page with deterministic network and native-boundary doubles.
|
||||
pub(super) fn run(page: &super::BridgePage) {
|
||||
let mut child = Command::new("node")
|
||||
.args(["-e", include_str!("behavior_tests.js"), "--", "--stdin"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Node.js 22+ is required for executable WEB bridge regressions");
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(page.body.as_bytes())
|
||||
.unwrap();
|
||||
let output = child.wait_with_output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"bridge regressions failed:\n{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
(()=>{'use strict';
|
||||
// HTTP request ownership is separate from the native WINDOW credit protocol.
|
||||
function create(settings){
|
||||
const channels=new Map(),window=settings.window,token=settings.token,downBudget=globalThis.TelemtBridgeDownlink.budget(settings.queueLimit);
|
||||
let closed=false,order=0,ready=settings.committed(),resolveReady;
|
||||
const readyPromise=new Promise(resolve=>{resolveReady=resolve});if(ready)resolveReady();
|
||||
const alive=channel=>!closed&&!channel.closed&&settings.alive(channel.lane);
|
||||
const markReady=()=>{if(!ready){ready=true;resolveReady();settings.commit()}};
|
||||
function waitReady(signal){
|
||||
if(ready)return Promise.resolve();
|
||||
return new Promise((resolve,reject)=>{const abort=()=>reject(new Error('carrier retired'));if(signal&&signal.aborted){abort();return}if(signal)signal.addEventListener('abort',abort,{once:true});readyPromise.then(()=>{if(signal)signal.removeEventListener('abort',abort);resolve()})});
|
||||
}
|
||||
function get(id,type){
|
||||
let channel=channels.get(id);if(channel)return channel;
|
||||
const lane=id===null?null:settings.lane(id,type);if(id!==null&&!lane)return null;
|
||||
channel={id,lane,pending:[],flights:new Map(),acks:new Set(),next:1,confirmed:0,cursor:'0',controller:null,polling:false,closed:false};
|
||||
channel.receiver=globalThis.TelemtBridgeDownlink.create({
|
||||
failure:settings.failure,alive:()=>alive(channel),cursor:()=>channel.cursor,lane:id,limit:settings.batchLimit,budget:downBudget,
|
||||
cancel:settings.cancel,ready:waitReady,deliver:settings.deliver,advance:next=>{channel.cursor=next},finished:()=>{if(lane)settings.finish(lane,false)}
|
||||
});channels.set(id,channel);return channel;
|
||||
}
|
||||
function enqueue(data,defer){
|
||||
if(closed)return;
|
||||
const entries=settings.lanes?settings.buffers.splitFrames(data):[{id:null,type:0,data}],touched=new Set();
|
||||
for(const entry of entries){
|
||||
const channel=get(entry.id,entry.type);if(!channel)continue;
|
||||
if(!settings.buffers.reserve(entry.data,channel.lane))throw settings.failure('capacity','carrier queue full');
|
||||
channel.pending.push(entry.data);touched.add(channel);
|
||||
}
|
||||
if(!defer)for(const channel of touched)pump(channel);
|
||||
}
|
||||
function checkAck(response,sequence){
|
||||
if(response.status!==204)throw settings.failure('http','uplink rejected');
|
||||
if(response.headers.get('X-Up-Ack')!==String(sequence))throw settings.failure('protocol','uplink acknowledgement rejected');
|
||||
}
|
||||
function acknowledge(channel,sequence,lease){
|
||||
if(!alive(channel)||channel.flights.get(sequence)!==lease)return;
|
||||
settings.buffers.settleBatch(lease);channel.flights.delete(sequence);channel.acks.add(sequence);
|
||||
while(channel.acks.delete(channel.confirmed+1))channel.confirmed++;
|
||||
markReady();settings.traffic(lease.total,0);pump(channel);
|
||||
}
|
||||
async function send(channel,sequence,lease){
|
||||
const headers={'X-Up-Seq':String(sequence)};if(channel.id!==null)headers['X-Lane-ID']=String(channel.id);
|
||||
if(window>1)headers['X-Telemt-Up-Confirmed']=String(channel.confirmed);
|
||||
const frozen=settings.options(settings.method,token,lease.body,headers,lease.controller.signal);
|
||||
try{
|
||||
while(alive(channel)&&!lease.cancelled){
|
||||
try{const response=await settings.request('/api/v1/up',frozen,null,()=>ready?1:9);if(!alive(channel))return;checkAck(response,sequence);acknowledge(channel,sequence,lease);return}
|
||||
catch(error){
|
||||
if(!alive(channel)||lease.cancelled)return;
|
||||
if(!ready){settings.failed(error);return}
|
||||
let replayed=false;
|
||||
const recovered=await settings.recover(error,async(signal,remaining)=>{
|
||||
const response=await settings.request('/api/v1/up',Object.assign({},frozen,{signal}),remaining,2);
|
||||
if(!alive(channel)||lease.cancelled)return;checkAck(response,sequence);replayed=true;acknowledge(channel,sequence,lease);
|
||||
});
|
||||
if(!recovered||!alive(channel)||replayed)return;
|
||||
}
|
||||
}
|
||||
}catch(error){if(alive(channel)&&!lease.cancelled)settings.failed(error)}
|
||||
}
|
||||
function pump(channel){
|
||||
while(alive(channel)&&channel.pending.length&&channel.next<=channel.confirmed+window){
|
||||
const lease=settings.buffers.takeBatch(channel.pending,channel.lane),sequence=channel.next++;
|
||||
lease.controller=new AbortController();lease.order=order++;channel.flights.set(sequence,lease);send(channel,sequence,lease);
|
||||
if(!channel.polling)poll(channel);
|
||||
}
|
||||
}
|
||||
async function poll(channel){
|
||||
if(channel.polling||!alive(channel))return;channel.polling=true;
|
||||
try{
|
||||
while(alive(channel)){
|
||||
const cursor=channel.cursor,headers={'X-Down-Cursor':cursor};if(channel.id!==null)headers['X-Lane-ID']=String(channel.id);
|
||||
channel.controller=new AbortController();const frozen=settings.options(settings.method,token,null,headers,channel.controller.signal);
|
||||
try{
|
||||
const response=await settings.request('/api/v1/down',frozen,null,1,channel.receiver.read);
|
||||
if(!alive(channel))return;if(response.status!==200&&response.status!==204)throw settings.failure('http','downlink rejected');
|
||||
if(ready)settings.connected();
|
||||
}catch(error){
|
||||
if(!alive(channel))return;
|
||||
if(!ready){settings.failed(error);return}
|
||||
const recovered=await settings.recover(error,async(signal,remaining)=>{
|
||||
const response=await settings.request('/api/v1/down',Object.assign({},frozen,{signal}),remaining,2,channel.receiver.read);
|
||||
if(response.status!==200&&response.status!==204)throw settings.failure('http','downlink replay rejected');
|
||||
});
|
||||
if(!recovered||!alive(channel))return;
|
||||
}
|
||||
}
|
||||
}catch(error){if(alive(channel))settings.failed(error)}
|
||||
finally{channel.polling=false;channel.controller=null}
|
||||
}
|
||||
function retire(channel,restore){
|
||||
channel.closed=true;if(channel.controller)channel.controller.abort();channel.receiver.close();
|
||||
const saved=[];
|
||||
for(const lease of channel.flights.values()){if(restore)saved.push({order:lease.order,data:lease.body});settings.buffers.cancelBatch(lease)}
|
||||
channel.flights.clear();channel.acks.clear();
|
||||
for(const data of channel.pending)if(restore)saved.push({order:order++,data});
|
||||
settings.buffers.releasePending(channel.pending,channel.lane);return saved;
|
||||
}
|
||||
return {enqueue,flush(){for(const channel of channels.values())pump(channel)},finish(lane){const channel=channels.get(lane.id);if(channel){channels.delete(lane.id);retire(channel,false)}},close(restore){
|
||||
if(closed)return [];closed=true;const saved=[];for(const channel of channels.values())saved.push(...retire(channel,restore));channels.clear();
|
||||
return saved.sort((a,b)=>a.order-b.order).map(entry=>entry.data);
|
||||
}};
|
||||
}
|
||||
globalThis.TelemtBridgeConveyor=Object.freeze({create});
|
||||
})();
|
||||
@@ -20,6 +20,12 @@ __BUFFER_RUNTIME__
|
||||
__RECOVERY_RUNTIME__
|
||||
</script>
|
||||
<script nonce="__NONCE__">
|
||||
__DOWNLINK_RUNTIME__
|
||||
</script>
|
||||
<script nonce="__NONCE__">
|
||||
__CONVEYOR_RUNTIME__
|
||||
</script>
|
||||
<script nonce="__NONCE__">
|
||||
__RUNTIME__
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
(()=>{'use strict';
|
||||
// Prefix ownership survives HTTP retries, but never a session or lane replacement.
|
||||
function budget(limit){
|
||||
let used=0;const waiters=[];
|
||||
function wake(){for(const next of waiters.slice())next()}
|
||||
function acquire(size,signal){
|
||||
if(size>limit())return Promise.reject(new Error('downlink budget exceeded'));
|
||||
return new Promise((resolve,reject)=>{
|
||||
const remove=()=>{const at=waiters.indexOf(tryAcquire);if(at>=0)waiters.splice(at,1);if(signal)signal.removeEventListener('abort',abort)};
|
||||
const abort=()=>{remove();reject(new Error('downlink aborted'))};
|
||||
const tryAcquire=()=>{if(signal&&signal.aborted){abort();return}if(size<=limit()-used){remove();used+=size;let released=false;resolve(()=>{if(!released){released=true;used-=size;wake()}})}};
|
||||
waiters.push(tryAcquire);if(signal)signal.addEventListener('abort',abort,{once:true});tryAcquire();
|
||||
});
|
||||
}
|
||||
return {acquire,used:()=>used};
|
||||
}
|
||||
function create(settings){
|
||||
let transaction=null,closed=false;
|
||||
const protocol=message=>settings.failure('protocol',message);
|
||||
const current=signal=>!closed&&settings.alive()&&!(signal&&signal.aborted);
|
||||
function dispose(){if(transaction){transaction.release();transaction=null}}
|
||||
function shape(type,id,size){
|
||||
if(type===2)return id!==0&&size>0&&size<=1048576;
|
||||
if(type===3)return id!==0&&size===0;
|
||||
if(type===4)return id!==0&&size===4;
|
||||
if(type===5)return id===0&&size<=64;
|
||||
return type===31&&id===0&&size===0;
|
||||
}
|
||||
async function read(response,signal){
|
||||
if(!current(signal))throw new Error('downlink retired');
|
||||
if(response.status===204){
|
||||
settings.cancel(response);if(transaction)throw protocol('missing replay batch');
|
||||
const cursor=response.headers.get('X-Down-Cursor');
|
||||
if(cursor!==null&&cursor!==settings.cursor())throw protocol('changed empty cursor');
|
||||
if(response.headers.get('X-Lane-Closed')==='1')settings.finished();
|
||||
return new ArrayBuffer(0);
|
||||
}
|
||||
const next=response.headers.get('X-Down-Cursor')||'',base=settings.cursor();
|
||||
if(!/^[1-9]\d*$/.test(next)||BigInt(next)!==BigInt(base)+1n||BigInt(next)>18446744073709551615n)throw protocol('invalid downlink cursor');
|
||||
const length=response.headers.get('Content-Length');let declared=null;
|
||||
if(length!==null){if(!/^(0|[1-9]\d*)$/.test(length))throw protocol('invalid response length');declared=Number(length);if(!Number.isSafeInteger(declared)||declared===0||declared>settings.limit())throw protocol('response body overflow')}
|
||||
if(transaction&&(transaction.base!==base||transaction.next!==next||(transaction.declared!==null&&declared!==null&&transaction.declared!==declared)))throw protocol('changed replay metadata');
|
||||
if(!transaction){
|
||||
const capacity=declared===null?settings.limit():declared,release=await settings.budget.acquire(capacity,signal);
|
||||
if(!current(signal)){release();throw new Error('downlink retired')}
|
||||
transaction={base,next,declared,bytes:new Uint8Array(capacity),retained:0,delivered:0,release};
|
||||
}
|
||||
const owner=transaction;
|
||||
if(owner.declared===null&&declared!==null)owner.declared=declared;
|
||||
await settings.ready(signal);
|
||||
if(!current(signal)||transaction!==owner)throw new Error('downlink retired');
|
||||
if(!response.body)throw protocol('missing response body');
|
||||
const reader=response.body.getReader();let position=0,offset=0,frames=0,chunks=0,complete=false;
|
||||
try{
|
||||
for(;;){
|
||||
let part;try{part=await reader.read()}catch(error){throw settings.failure('network','response stream interrupted')}
|
||||
if(!current(signal)||transaction!==owner)throw new Error('downlink retired');
|
||||
if(part.done)break;
|
||||
const data=part.value;if(!(data instanceof Uint8Array)||++chunks>65536||data.byteLength>owner.bytes.byteLength-position)throw protocol('response body overflow');
|
||||
const compared=Math.min(data.byteLength,Math.max(0,owner.retained-position));
|
||||
for(let i=0;i<compared;i++)if(data[i]!==owner.bytes[position+i])throw protocol('changed replay prefix');
|
||||
owner.bytes.set(data.subarray(compared),position+compared);position+=data.byteLength;owner.retained=Math.max(owner.retained,position);
|
||||
while(position-offset>=8){
|
||||
const view=new DataView(owner.bytes.buffer,offset),type=view.getUint8(0),id=(view.getUint8(1)<<16)|(view.getUint8(2)<<8)|view.getUint8(3),size=view.getUint32(4),end=offset+8+size;
|
||||
if(!shape(type,id,size)||(settings.lane!==null&&id!==settings.lane)||end>owner.bytes.byteLength)throw protocol('invalid downlink frame');
|
||||
if(end>position)break;if(++frames>4096)throw protocol('too many downlink frames');
|
||||
if(type===4&&view.getUint32(8)===0)throw protocol('invalid WINDOW credit');
|
||||
if(end>owner.delivered){
|
||||
if(offset!==owner.delivered)throw protocol('invalid replay boundary');
|
||||
const frame=owner.bytes.slice(offset,end).buffer;
|
||||
settings.deliver(frame);owner.delivered=end;
|
||||
}
|
||||
offset=end;
|
||||
}
|
||||
}
|
||||
if(!position||offset!==position||position<owner.retained||(owner.declared!==null&&position!==owner.declared))throw protocol('incomplete downlink batch');
|
||||
settings.advance(next);complete=true;dispose();return new ArrayBuffer(0);
|
||||
}finally{
|
||||
if(!complete)try{const cancelled=reader.cancel();if(cancelled)cancelled.catch(()=>{})}catch(error){}
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
return {read,close(){closed=true;dispose()}};
|
||||
}
|
||||
globalThis.TelemtBridgeDownlink=Object.freeze({create,budget});
|
||||
})();
|
||||
@@ -26,12 +26,13 @@ function create(settings){
|
||||
if(status===204&&(path==='/api/v1/up'||path==='/api/v1/down'))return {limit:0,exact:true,reason:'protocol'};
|
||||
return {limit:0,exact:true,reason:'http'};
|
||||
}
|
||||
async function send(path,frozenOptions,remainingBudget,maxAttempts){
|
||||
async function send(path,frozenOptions,remainingBudget,maxAttempts,receiver){
|
||||
let delay=250,attempt=0,lastReason='network';maxAttempts=maxAttempts||9;
|
||||
const attempts=()=>typeof maxAttempts==='function'?maxAttempts():maxAttempts;
|
||||
const initialBudget=remainingBudget?Math.min(settings.retryMs(),remainingBudget()):settings.retryMs();
|
||||
const deadline=Date.now()+Math.max(0,initialBudget),external=frozenOptions.signal;
|
||||
const attemptLimit=path==='/api/v1/down'?settings.longPollMs()+settings.requestMs():settings.requestMs();
|
||||
while(attempt<maxAttempts){
|
||||
while(attempt<attempts()){
|
||||
if(settings.closed()||(external&&external.aborted))throw new Error('request aborted');
|
||||
const remaining=Math.min(deadline-Date.now(),remainingBudget?remainingBudget():Infinity);if(remaining<=0)break;attempt++;
|
||||
const controller=new AbortController(),abort=()=>controller.abort();let timedOut=false;
|
||||
@@ -45,12 +46,12 @@ function create(settings){
|
||||
lastReason='http';wait=retryAfterMs(fetched);settings.cancel(fetched);
|
||||
}else{
|
||||
const policy=responsePolicy(path,fetched.status);let body;
|
||||
try{body=await settings.read(fetched,policy.limit,policy.exact,controller.signal)}
|
||||
try{body=receiver&&(fetched.status===200||fetched.status===204)?await receiver(fetched,controller.signal):await settings.read(fetched,policy.limit,policy.exact,controller.signal)}
|
||||
catch(error){
|
||||
controller.abort();
|
||||
if(external&&external.aborted)throw error;
|
||||
if(timedOut)throw settings.failure('timeout','response deadline exceeded');
|
||||
throw settings.failure(policy.reason,error&&error.message);
|
||||
throw settings.failure(settings.reason(error,policy.reason),error&&error.message);
|
||||
}
|
||||
response={status:fetched.status,headers:fetched.headers,body};return response;
|
||||
}
|
||||
@@ -60,7 +61,7 @@ function create(settings){
|
||||
if(timedOut)lastReason='timeout';
|
||||
if(settings.reason(error,'')==='protocol')throw error;
|
||||
}finally{clearTimeout(timer);if(external)external.removeEventListener('abort',abort)}
|
||||
const after=Math.min(deadline-Date.now(),remainingBudget?remainingBudget():Infinity);if(attempt>=maxAttempts||after<=0)break;
|
||||
const after=Math.min(deadline-Date.now(),remainingBudget?remainingBudget():Infinity);if(attempt>=attempts()||after<=0)break;
|
||||
settings.retrying();
|
||||
const backoff=wait||delay+Math.floor(Math.random()*Math.max(1,delay/4));
|
||||
await pause(Math.min(backoff,after),external);delay=Math.min(delay*2,2000);
|
||||
|
||||
+42
-141
@@ -19,7 +19,7 @@ let laneQueueLimit=Math.min(queueLimit,8388608),laneItemLimit=Math.min(queueItem
|
||||
const fragment=location.hash,androidNonce=/^#android=([A-Za-z0-9_-]{43})$/.exec(fragment)?.[1]||'',recoveryPath=location.pathname+location.search;
|
||||
history.replaceState(null,'',location.pathname);
|
||||
let initialized=false,closed=false,port=null,sessionToken='',cleanupToken='',createStarted=false,socket=null,socketReady=false,carrier='';
|
||||
let upSequence=1,downCursor='0',upRunning=false,upLease=null,pollController=null;
|
||||
let upSequence=1,downCursor='0',upRunning=false,upLease=null,pollController=null,httpCarrier=null,upWindow=1;
|
||||
let helloFrame=null,helloTimer=null,welcomeSent=false,carrierAttempt=1,carrierFailure='',carrierCommitted=false,terminalFailure='';
|
||||
let negotiationStartedAt=0,carrierTimer=null,probeTimer=null,attemptController=null,attemptEpoch=1,candidateRunning=false,switching=false,currentAttempt=null;
|
||||
let recoveryController=null,recoveryCommit=null,recoveryReplaced=false,lastSchedulerWall=Date.now(),lastSchedulerMonotonic=performance.now(),schedulerGapPending=0,schedulerTimer=null;
|
||||
@@ -44,7 +44,7 @@ const {reserve,release,releasePending,frameBound,splitFrames,acceptNativeFrames,
|
||||
function detachLease(lease){if(lease.lane){if(lease.lane.upLease===lease)lease.lane.upLease=null}else if(upLease===lease)upLease=null}
|
||||
function settleBatch(lease){if(!buffers.settleBatch(lease))return false;detachLease(lease);return true}
|
||||
function cancelBatch(lease){if(!lease||lease.settled)return;buffers.cancelBatch(lease);detachLease(lease)}
|
||||
const attemptHeaders=(attempt,failure)=>negotiationEnabled?Object.assign({'X-Carrier-Capabilities':carrierCapabilities,'X-Carrier-Attempt':String(attempt)},failure?{'X-Carrier-Failure':failure}:{}):{};
|
||||
const attemptHeaders=(attempt,failure)=>Object.assign({'X-Telemt-Up-Window':'4'},negotiationEnabled?Object.assign({'X-Carrier-Capabilities':carrierCapabilities,'X-Carrier-Attempt':String(attempt)},failure?{'X-Carrier-Failure':failure}:{}):{});
|
||||
function finishOldRecovery(){
|
||||
recoveryReplaced=false;resetScheduler();status('connected');
|
||||
while(recoveryPending.length&&!closed){const data=recoveryPending.shift();release(data.byteLength,1,null);queueCarrier(data)}
|
||||
@@ -58,6 +58,7 @@ function resolveRecoveryCommit(){
|
||||
commit.signal.removeEventListener('abort',commit.abort);commit.resolve();
|
||||
}
|
||||
function retireCarrier(policy){
|
||||
stopHttp(false);upWindow=1;
|
||||
recoveryReplaced=true;attemptEpoch++;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;clearProbeTimer();
|
||||
if(attemptController)attemptController.abort();attemptController=null;if(pollController)pollController.abort();pollController=null;
|
||||
if(socket){const previous=socket;socket=null;previous.close()}socketReady=false;cancelBatch(upLease);releasePending(upPending,null);
|
||||
@@ -118,9 +119,10 @@ function fail(reason){
|
||||
}
|
||||
function knownCarrier(value){return value==='https'||value==='https-lanes'||value==='websocket'||value==='websocket-lanes'}
|
||||
function sessionEcho(response,expectedAttempt,states,exactAttempt){
|
||||
const selected=response.headers.get('X-Carrier-Mode')||'',echo=response.headers.get('X-Carrier-Attempt')||'';
|
||||
const selected=response.headers.get('X-Carrier-Mode')||'',echo=response.headers.get('X-Carrier-Attempt')||'',windowHeader=response.headers.get('X-Telemt-Up-Window');
|
||||
if(windowHeader!==null&&!/^[1-4]$/.test(windowHeader))throw new Error('invalid conveyor window');const upWindow=windowHeader===null?1:Number(windowHeader);
|
||||
if(!knownCarrier(selected))throw new Error('invalid carrier mode');
|
||||
if(!negotiationEnabled){if(echo!=='')throw new Error('unexpected carrier attempt');return {selected,state:''}}
|
||||
if(!negotiationEnabled){if(echo!=='')throw new Error('unexpected carrier attempt');return {selected,state:'',upWindow}}
|
||||
const count=response.headers.get('X-Carrier-Candidate-Count')||'',deadline=response.headers.get('X-Carrier-Deadline')||'',state=response.headers.get('X-Carrier-State')||'';
|
||||
if(!/^[1-4]$/.test(count)||!/^[1-9]\d*$/.test(deadline)||!states.includes(state))throw new Error('invalid carrier state');
|
||||
const echoedAttempt=Number(echo),parsedCount=Number(count),parsedDeadline=Number(deadline);
|
||||
@@ -129,7 +131,7 @@ function sessionEcho(response,expectedAttempt,states,exactAttempt){
|
||||
if(!negotiatedFrozen){negotiatedCandidateCount=parsedCount;negotiatedFinalDeadline=parsedDeadline;negotiatedFrozen=true}
|
||||
else if(parsedCount!==negotiatedCandidateCount||parsedDeadline!==negotiatedFinalDeadline)throw new Error('changed carrier bounds');
|
||||
if(echoedAttempt>negotiatedCandidateCount)throw new Error('carrier attempt exceeds candidates');
|
||||
return {selected,state};
|
||||
return {selected,state,upWindow};
|
||||
}
|
||||
function armCarrierDeadline(epoch){
|
||||
if(!negotiationStartedAt||epoch!==attemptEpoch)return;
|
||||
@@ -140,6 +142,7 @@ function armCarrierDeadline(epoch){
|
||||
}
|
||||
function clearProbeTimer(){if(probeTimer){clearTimeout(probeTimer.timer);probeTimer=null}}
|
||||
function resetCandidate(){
|
||||
stopHttp(true);upWindow=1;
|
||||
clearProbeTimer();
|
||||
if(pollController)pollController.abort();pollController=null;
|
||||
if(socket){const previous=socket;socket=null;previous.close()}socketReady=false;
|
||||
@@ -198,8 +201,8 @@ function startCandidate(probe,epoch){
|
||||
else advanceCarrier('protocol',epoch);
|
||||
}
|
||||
function maybeStartCandidate(){
|
||||
if(closed||carrierCommitted||!sessionToken||candidateRunning)return;const epoch=attemptEpoch;
|
||||
let probe;try{probe=findProbe(probeCoalesceMs>0)}catch(error){fail('protocol');return}if(!probe)return;
|
||||
if(closed||carrierCommitted||!sessionToken)return;if(candidateRunning){try{flushHttpPending()}catch(error){fail(failureReason(error,'protocol'))}return}const epoch=attemptEpoch;
|
||||
let probe;try{probe=findProbe(true)}catch(error){fail('protocol');return}if(!probe)return;
|
||||
if(!probeCoalesceMs||probe.hasData){startCandidate(probe,epoch);return}
|
||||
if(probeTimer)return;const owner={epoch,timer:null};
|
||||
owner.timer=setTimeout(()=>{if(probeTimer!==owner||closed||owner.epoch!==attemptEpoch)return;probeTimer=null;let current;try{current=findProbe(false)}catch(error){fail('protocol');return}startCandidate(current,owner.epoch)},probeCoalesceMs);
|
||||
@@ -219,74 +222,58 @@ async function createSession(epoch){
|
||||
const token=response.headers.get('X-Session-Token')||'',cursor=response.headers.get('X-Down-Cursor')||'';
|
||||
if(!token||cursor!=='0'){advanceCarrier('protocol',epoch);return}
|
||||
const welcome=response.body;if(closed||epoch!==attemptEpoch)return;
|
||||
carrier=selected;sessionToken=token;cleanupToken=token;downCursor=cursor;
|
||||
carrier=selected;sessionToken=token;cleanupToken=token;downCursor=cursor;upWindow=echo.upWindow;
|
||||
if(!welcomeSent){welcomeSent=true;port.postMessage(welcome,[welcome]);status('connecting')}
|
||||
if(carrier==='websocket')openCandidateSocket(null,null,epoch);
|
||||
maybeStartCandidate();
|
||||
}catch(error){if(closed||epoch!==attemptEpoch)return;advanceCarrier(failureReason(error,'network'),epoch)}
|
||||
}
|
||||
async function probeHttp(probe,laneID,epoch){
|
||||
try{
|
||||
const headers={'X-Up-Seq':'1'},token=sessionToken,controller=attemptController,body=probe.data;if(laneID!==null)headers['X-Lane-ID']=String(laneID);
|
||||
const response=await request('/api/v1/up',options(carrierMethod,token,body,headers,controller.signal));
|
||||
if(closed||epoch!==attemptEpoch)return
|
||||
if(response.status!==204){advanceCarrier('http',epoch);return}
|
||||
if(response.headers.get('X-Up-Ack')!=='1'){advanceCarrier('protocol',epoch);return}
|
||||
if(laneID===null)upSequence=2;else ensureLane(laneID).sequence=2;
|
||||
commitCarrier(probe,epoch);
|
||||
}catch(error){if(!closed&&epoch===attemptEpoch)advanceCarrier(failureReason(error,'network'),epoch)}
|
||||
function stopHttp(restore){
|
||||
if(!httpCarrier)return;const saved=httpCarrier.close(restore);httpCarrier=null;
|
||||
for(const data of saved){if(!reserve(data,null)){fail('capacity');return}pending.push(data)}
|
||||
}
|
||||
function flushHttpPending(){
|
||||
while(httpCarrier&&pending.length&&!closed){const data=pending.shift();release(data.byteLength,1,null);httpCarrier.enqueue(data,true)}
|
||||
if(httpCarrier&&!closed)httpCarrier.flush();
|
||||
}
|
||||
function probeHttp(probe,laneID,epoch){
|
||||
const token=sessionToken;
|
||||
httpCarrier=globalThis.TelemtBridgeConveyor.create({
|
||||
token,window:upWindow,method:carrierMethod,lanes:carrier==='https-lanes',buffers,options,request,
|
||||
queueLimit:()=>queueLimit,batchLimit:()=>batchLimit,committed:()=>carrierCommitted,failure,cancel:responseBody.cancel,
|
||||
alive:lane=>!closed&&epoch===attemptEpoch&&token===sessionToken&&(!lane||lanes.get(lane.id)===lane),
|
||||
commit:()=>commitCarrier(null,epoch),recover:recoverTransport,connected:()=>status('connected'),
|
||||
traffic:(up,down)=>port.postMessage({t:'traffic',up,down}),
|
||||
deliver:data=>{observeServerFrames(data);port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data])},
|
||||
lane:(id,type)=>{
|
||||
let lane=lanes.get(id);if(!lane&&(type===2||type===3||type===4))return null;
|
||||
if(!lane&&closedLanes.has(id))throw failure('protocol','closed lane reused');
|
||||
if(!lane&&id!==0&&type!==1)throw failure('protocol','lane did not begin with OPEN');
|
||||
return lane||ensureLane(id);
|
||||
},
|
||||
finish:finishLane,failed:error=>{if(closed||epoch!==attemptEpoch)return;if(carrierCommitted)fail(failureReason(error,'network'));else advanceCarrier(failureReason(error,'network'),epoch)}
|
||||
});
|
||||
try{flushHttpPending()}catch(error){fail(failureReason(error,'protocol'))}
|
||||
}
|
||||
function commitCarrier(probe,epoch){
|
||||
if(closed||carrierCommitted||epoch!==attemptEpoch)return;
|
||||
if(switching){fail('protocol');return}
|
||||
clearProbeTimer();try{consumeProbe(probe)}catch(error){fail('protocol');return}
|
||||
clearProbeTimer();try{if(probe)consumeProbe(probe)}catch(error){fail('protocol');return}
|
||||
carrierCommitted=true;candidateRunning=false;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
|
||||
resetScheduler();
|
||||
attemptController=null;currentAttempt=null;
|
||||
status('connected');
|
||||
if(carrier==='https')poll();
|
||||
else if(carrier==='https-lanes'){const lane=lanes.get(probe.id);if(lane&&!lane.polling)pollLane(lane)}
|
||||
while(pending.length&&!closed){const data=pending.shift();release(data.byteLength,1,null);queueCarrier(data)}
|
||||
resolveRecoveryCommit();
|
||||
}
|
||||
function queueCarrier(data){
|
||||
if(closed)return;
|
||||
try{
|
||||
if(carrier==='https')queueUp(data);
|
||||
if(carrier==='https'||carrier==='https-lanes')httpCarrier.enqueue(data);
|
||||
else if(carrier==='websocket')queueSocket(data);
|
||||
else for(const value of splitFrames(data)){if(closed)break;queueLane(value)}
|
||||
}catch(error){fail('protocol')}
|
||||
}
|
||||
function queueUp(data){if(!reserve(data,null)){fail('capacity');return}upPending.push(data);runUp()}
|
||||
async function runUp(){
|
||||
if(upRunning)return;upRunning=true;const ownerEpoch=attemptEpoch;let lease=null;
|
||||
try{
|
||||
while(!closed&&sessionToken&&upPending.length){
|
||||
lease=takeBatch(upPending,null);upLease=lease;lease.controller=new AbortController();const sequence=String(upSequence),token=sessionToken;
|
||||
for(;;){
|
||||
try{
|
||||
const response=await request('/api/v1/up',options(carrierMethod,token,lease.body,{'X-Up-Seq':sequence},lease.controller.signal),null,1);
|
||||
if(response.status!==204)throw failure('http','uplink rejected');
|
||||
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','uplink acknowledgement rejected');
|
||||
break;
|
||||
}catch(error){
|
||||
let replayed=false;
|
||||
const recovered=await recoverTransport(error,async(signal,remaining)=>{
|
||||
const response=await request('/api/v1/up',options(carrierMethod,token,lease.body,{'X-Up-Seq':sequence},signal),remaining,2);
|
||||
if(response.status!==204)throw failure('http','uplink replay rejected');
|
||||
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','uplink replay acknowledgement rejected');
|
||||
replayed=true;
|
||||
});
|
||||
if(!recovered||closed||lease.cancelled||sessionToken!==token)return;
|
||||
if(!replayed)continue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(!settleBatch(lease))return;port.postMessage({t:'traffic',up:lease.total,down:0});upSequence++;lease=null;
|
||||
}
|
||||
}catch(error){if(!closed&&!(lease&&lease.cancelled))fail(failureReason(error,'network'))}
|
||||
finally{if(ownerEpoch===attemptEpoch){upRunning=false;if(!closed&&sessionToken&&upPending.length)runUp()}}
|
||||
}
|
||||
function sendCandidateSocket(next){
|
||||
const state=next.telemt;if(!state||state.sent||next.readyState!==WebSocket.OPEN||!state.probe)return;
|
||||
let probe=state.probe;
|
||||
@@ -340,30 +327,6 @@ async function runSocketUp(){
|
||||
}catch(error){if(!closed&&!(lease&&lease.cancelled))recoverTransport(error,null)}
|
||||
finally{if(ownerEpoch===attemptEpoch){upRunning=false;if(!closed&&socketReady&&upPending.length)runSocketUp()}}
|
||||
}
|
||||
async function poll(){
|
||||
while(!closed&&sessionToken){
|
||||
const token=sessionToken,cursor=downCursor;
|
||||
try{
|
||||
pollController=new AbortController();
|
||||
const response=await request('/api/v1/down',options(carrierMethod,token,null,{'X-Down-Cursor':cursor},pollController.signal),null,1);
|
||||
if(closed||sessionToken!==token)return;
|
||||
if(response.status===204){status('connected');continue}
|
||||
if(response.status!==200)throw failure('http','downlink rejected');
|
||||
const next=response.headers.get('X-Down-Cursor')||'',data=response.body;
|
||||
if(!next||!data.byteLength)throw failure('protocol','invalid downlink response');
|
||||
if(closed)return;
|
||||
observeServerFrames(data);port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);downCursor=next;status('connected');
|
||||
}catch(error){
|
||||
if(closed)return;
|
||||
const recovered=await recoverTransport(error,async(signal,remaining)=>{
|
||||
const response=await request('/api/v1/down',options(carrierMethod,token,null,{'X-Down-Cursor':cursor},signal),remaining,2);
|
||||
if(response.status===204)return;
|
||||
if(response.status!==200||!response.body.byteLength||!response.headers.get('X-Down-Cursor'))throw failure('http','downlink replay rejected');
|
||||
});
|
||||
if(!recovered||closed||sessionToken!==token)return;
|
||||
}
|
||||
}
|
||||
}
|
||||
function ensureLane(id){
|
||||
let lane=lanes.get(id);
|
||||
if(!lane){lane={id,sequence:1,cursor:'0',pending:[],bytes:0,items:0,running:false,upLease:null,polling:false,controller:null,socket:null,ready:false,remoteClosed:false};lanes.set(id,lane)}
|
||||
@@ -375,7 +338,7 @@ function rememberLaneClosed(id){
|
||||
closedLanes.add(id);closedLaneOrder.push(id);
|
||||
}
|
||||
function finishLane(lane,notifyClient){
|
||||
if(lanes.get(lane.id)!==lane)return;
|
||||
if(lanes.get(lane.id)!==lane)return;if(httpCarrier)httpCarrier.finish(lane);
|
||||
if(lane.controller)lane.controller.abort();lane.controller=null;cancelBatch(lane.upLease);
|
||||
if(lane.socket&&lane.socket.readyState<WebSocket.CLOSING)lane.socket.close();
|
||||
releasePending(lane.pending,lane);lanes.delete(lane.id);rememberLaneClosed(lane.id);
|
||||
@@ -389,7 +352,7 @@ function queueLane(value){
|
||||
lane=lane||ensureLane(value.id);
|
||||
if(!reserve(value.data,lane)){fail('capacity');return}
|
||||
lane.pending.push(value.data);
|
||||
if(carrier==='websocket-lanes'){openLaneSocket(lane);runLaneSocketUp(lane)}else runLaneUp(lane);
|
||||
openLaneSocket(lane);runLaneSocketUp(lane);
|
||||
}
|
||||
function openLaneSocket(lane){
|
||||
if(lane.socket||closed)return;lane.socket=new WebSocket(socketURL(),'tproxy-lane-v1.'+sessionToken+'.'+String(lane.id));lane.socket.binaryType='arraybuffer';
|
||||
@@ -416,74 +379,12 @@ async function runLaneSocketUp(lane){
|
||||
}catch(error){if(!closed&&lanes.get(lane.id)===lane&&!(lease&&lease.cancelled))finishLane(lane,true)}
|
||||
finally{lane.running=false;if(!closed&&lanes.get(lane.id)===lane&&lane.ready&&lane.pending.length)runLaneSocketUp(lane)}
|
||||
}
|
||||
async function runLaneUp(lane){
|
||||
if(lane.running)return;lane.running=true;let lease=null;
|
||||
try{
|
||||
while(!closed&&sessionToken&&lane.pending.length){
|
||||
lease=takeBatch(lane.pending,lane);lane.upLease=lease;lease.controller=new AbortController();
|
||||
const sequence=String(lane.sequence),laneID=String(lane.id),token=sessionToken;
|
||||
for(;;){
|
||||
try{
|
||||
const response=await request('/api/v1/up',options(carrierMethod,token,lease.body,{'X-Up-Seq':sequence,'X-Lane-ID':laneID},lease.controller.signal),null,1);
|
||||
if(response.status!==204)throw failure('http','lane uplink rejected');
|
||||
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','lane uplink acknowledgement rejected');
|
||||
break;
|
||||
}catch(error){
|
||||
let replayed=false;
|
||||
const recovered=await recoverTransport(error,async(signal,remaining)=>{
|
||||
const response=await request('/api/v1/up',options(carrierMethod,token,lease.body,{'X-Up-Seq':sequence,'X-Lane-ID':laneID},signal),remaining,2);
|
||||
if(response.status!==204)throw failure('http','lane uplink replay rejected');
|
||||
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','lane uplink replay acknowledgement rejected');
|
||||
replayed=true;
|
||||
});
|
||||
if(!recovered||closed||lease.cancelled||sessionToken!==token||lanes.get(lane.id)!==lane)return;
|
||||
if(!replayed)continue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(!settleBatch(lease))return;port.postMessage({t:'traffic',up:lease.total,down:0});lane.sequence++;lease=null;
|
||||
if(!lane.polling)pollLane(lane);
|
||||
}
|
||||
}catch(error){if(!closed&&lanes.get(lane.id)===lane&&!(lease&&lease.cancelled))fail(failureReason(error,'network'))}
|
||||
finally{lane.running=false;if(!closed&&lanes.get(lane.id)===lane&&sessionToken&&lane.pending.length)runLaneUp(lane)}
|
||||
}
|
||||
async function pollLane(lane){
|
||||
if(!lane||lane.polling)return;lane.polling=true;let restart=false,failedToken='',failedCursor='',failedLaneID='';
|
||||
try{
|
||||
while(!closed&&sessionToken&&lanes.get(lane.id)===lane){
|
||||
const controller=new AbortController(),laneID=String(lane.id),token=sessionToken,cursor=lane.cursor;lane.controller=controller;
|
||||
failedToken=token;failedCursor=cursor;failedLaneID=laneID;
|
||||
const response=await request('/api/v1/down',options(carrierMethod,token,null,{'X-Down-Cursor':cursor,'X-Lane-ID':laneID},controller.signal),null,1);
|
||||
if(closed||sessionToken!==token||lanes.get(lane.id)!==lane)return;
|
||||
if(response.status===204){
|
||||
if(response.headers.get('X-Lane-Closed')==='1'){finishLane(lane,false);return}
|
||||
status('connected');continue;
|
||||
}
|
||||
if(response.status!==200)throw failure('http','lane downlink rejected');
|
||||
const next=response.headers.get('X-Down-Cursor')||'',data=response.body;
|
||||
if(!next||!data.byteLength)throw failure('protocol','invalid lane downlink response');
|
||||
for(const value of splitFrames(data))if(value.id!==lane.id)throw new Error('cross-lane frame');
|
||||
if(closed)return;
|
||||
observeServerFrames(data);port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);lane.cursor=next;status('connected');
|
||||
}
|
||||
}catch(error){
|
||||
if(!closed&&lanes.get(lane.id)===lane){
|
||||
const recovered=await recoverTransport(error,async(signal,remaining)=>{
|
||||
const response=await request('/api/v1/down',options(carrierMethod,failedToken,null,{'X-Down-Cursor':failedCursor,'X-Lane-ID':failedLaneID},signal),remaining,2);
|
||||
if(response.status===204)return;
|
||||
if(response.status!==200||!response.body.byteLength||!response.headers.get('X-Down-Cursor'))throw failure('http','lane downlink replay rejected');
|
||||
});
|
||||
restart=recovered&&!closed&&sessionToken===failedToken&&lanes.get(lane.id)===lane;
|
||||
}
|
||||
}
|
||||
finally{lane.polling=false;lane.controller=null;if(restart)pollLane(lane)}
|
||||
}
|
||||
function deleteSession(){
|
||||
const token=cleanupToken||sessionToken,headers=canonicalFailures.includes(terminalFailure)?{'X-Carrier-Failure':terminalFailure}:null;
|
||||
if(token)fetch(relayBase+'/api/v1/session',options('DELETE',token,null,headers,undefined,true)).catch(()=>{});
|
||||
}
|
||||
function close(notifyServer){
|
||||
if(closed)return;closed=true;if(recoveryController)recoveryController.cancel();rejectRecoveryCommit(failure('network','bridge closed'));if(helloTimer)clearTimeout(helloTimer);helloTimer=null;if(carrierTimer)clearTimeout(carrierTimer);clearProbeTimer();if(schedulerTimer)clearTimeout(schedulerTimer);schedulerTimer=null;if(attemptController)attemptController.abort();if(pollController)pollController.abort();
|
||||
if(closed)return;closed=true;stopHttp(false);if(recoveryController)recoveryController.cancel();rejectRecoveryCommit(failure('network','bridge closed'));if(helloTimer)clearTimeout(helloTimer);helloTimer=null;if(carrierTimer)clearTimeout(carrierTimer);clearProbeTimer();if(schedulerTimer)clearTimeout(schedulerTimer);schedulerTimer=null;if(attemptController)attemptController.abort();if(pollController)pollController.abort();
|
||||
if(socket)socket.close();cancelBatch(upLease);releasePending(upPending,null);
|
||||
for(const lane of lanes.values()){
|
||||
if(lane.controller)lane.controller.abort();cancelBatch(lane.upLease);releasePending(lane.pending,lane);if(lane.socket)lane.socket.close();
|
||||
|
||||
+8
-16
@@ -31,20 +31,8 @@ fn carrier_method_is_page_owned_and_used_by_every_https_request() {
|
||||
.contains(&format!("const carrierMethod='{}';", method.as_str()))
|
||||
);
|
||||
assert_eq!(page.body.matches("carrierMethod=").count(), 1);
|
||||
assert_eq!(page.body.matches("options(carrierMethod,").count(), 9);
|
||||
let requests: Vec<_> = page
|
||||
.body
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
line.contains("request('/api/v1/up'") || line.contains("request('/api/v1/down'")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(requests.len(), 9);
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.all(|line| line.contains("options(carrierMethod,"))
|
||||
);
|
||||
// Execute the rendered POST/PUT page, including retries, instead of counting call sites.
|
||||
behavior_tests::run(&page);
|
||||
assert_eq!(page.body.matches("options('POST',bootstrap,").count(), 2);
|
||||
assert!(
|
||||
page.body
|
||||
@@ -68,6 +56,9 @@ fn carrier_method_is_page_owned_and_used_by_every_https_request() {
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "behavior_tests.rs"]
|
||||
mod behavior_tests;
|
||||
|
||||
fn render_page(bootstrap: &str, candidate_count: usize) -> BridgePage {
|
||||
render(
|
||||
"proxy.example.com",
|
||||
@@ -289,8 +280,9 @@ fn disabled_negotiation_does_not_arm_a_carrier_deadline() {
|
||||
fn retry_and_attempt_state_are_frozen_before_fetch() {
|
||||
let page = render_page("EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE", 4);
|
||||
assert!(
|
||||
page.body
|
||||
.contains("async function send(path,frozenOptions,remainingBudget,maxAttempts)")
|
||||
page.body.contains(
|
||||
"async function send(path,frozenOptions,remainingBudget,maxAttempts,receiver)"
|
||||
)
|
||||
);
|
||||
assert!(!page.body.contains("makeOptions"));
|
||||
assert!(page.body.contains(
|
||||
|
||||
Reference in New Issue
Block a user