WEB Bounded Bridge Recovery added

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-09-04 19:01:56 +03:00
parent 0d044c7372
commit 9f023ff9c7
35 changed files with 407 additions and 292 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ function create(settings){
function heartbeat(owner){
if(current!==owner||owner.controller.signal.aborted)return;
const left=remaining(owner);settings.status(left);
if(left>0)owner.heartbeat=setTimeout(()=>heartbeat(owner),Math.min(heartbeatMs,left));
if(left<=0){owner.controller.abort();return}owner.heartbeat=setTimeout(()=>heartbeat(owner),Math.min(heartbeatMs,left));
}
async function load(owner){
const left=remaining(owner);if(left<=0)throw new Error('recovery deadline');
+5 -4
View File
@@ -21,9 +21,10 @@ function create(settings){
}
function retryableStatus(status){return status===408||status===429||status===502||status===503||status===504}
function responsePolicy(path,status){
if(path==='/api/v1/session'&&status===200)return {limit:8,exact:true};
if(path==='/api/v1/down'&&status===200)return {limit:settings.batchLimit(),exact:false};
return {limit:0,exact:true};
if(path==='/api/v1/session'&&status===200)return {limit:8,exact:true,reason:'protocol'};
if(path==='/api/v1/down'&&status===200)return {limit:settings.batchLimit(),exact:false,reason:'protocol'};
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){
let delay=250,attempt=0,lastReason='network';maxAttempts=maxAttempts||9;
@@ -49,7 +50,7 @@ function create(settings){
controller.abort();
if(external&&external.aborted)throw error;
if(timedOut)throw settings.failure('timeout','response deadline exceeded');
throw settings.failure('protocol',error&&error.message);
throw settings.failure(policy.reason,error&&error.message);
}
response={status:fetched.status,headers:fetched.headers,body};return response;
}
+14 -4
View File
@@ -18,7 +18,7 @@ let initialized=false,closed=false,port=null,sessionToken='',cleanupToken='',cre
let upSequence=1,downCursor='0',upRunning=false,upLease=null,pollController=null;
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();
let recoveryController=null,recoveryCommit=null,recoveryReplaced=false,lastSchedulerWall=Date.now(),lastSchedulerMonotonic=performance.now(),schedulerGapPending=0,schedulerTimer=null;
const pending=[],upPending=[],recoveryPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[];
const canonicalFailures=['timeout','network','upgrade','http','protocol'];
const failure=(reason,message)=>Object.assign(new Error(message||reason),{telemtReason:reason});
@@ -48,7 +48,7 @@ function settleBatch(lease){if(!buffers.settleBatch(lease))return false;detachLe
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}:{}):{};
function finishOldRecovery(){
recoveryReplaced=false;status('connected','committed','',0);
recoveryReplaced=false;resetScheduler();status('connected','committed','',0);
while(recoveryPending.length&&!closed){const data=recoveryPending.shift();release(data.byteLength,1,null);queueCarrier(data)}
}
function rejectRecoveryCommit(error){
@@ -74,6 +74,7 @@ function retireCarrier(policy){
longPollMs=policy.timeouts.long_poll_secs*1000;bridgeRequestMs=policy.timeouts.bridge_request_secs*1000;
bridgeRetryMs=policy.timeouts.bridge_retry_secs*1000;bridgeRecoveryMs=policy.timeouts.bridge_recovery_secs*1000;
websocketOpenMs=policy.timeouts.websocket_open_secs*1000;reconnectGraceMs=policy.timeouts.reconnect_grace_secs*1000;
armScheduler();
negotiationEnabled=policy.negotiation.enabled;candidateCount=policy.negotiation.candidate_count;
candidateDeadlines=policy.negotiation.deadlines_secs;probeCoalesceMs=policy.negotiation.carrier_probe_coalesce_ms;
negotiatedCandidateCount=candidateCount;negotiatedFinalDeadline=candidateDeadlines[3];negotiatedFrozen=false;
@@ -100,8 +101,15 @@ function schedulerGap(){
const gap=Math.max(0,wall-lastSchedulerWall,monotonic-lastSchedulerMonotonic);
lastSchedulerWall=wall;lastSchedulerMonotonic=monotonic;return gap;
}
function resetScheduler(){schedulerGapPending=0;schedulerGap()}
function armScheduler(){
if(schedulerTimer)clearTimeout(schedulerTimer);schedulerTimer=closed?null:setTimeout(sampleScheduler,Math.max(250,Math.min(30000,Math.floor(reconnectGraceMs/4))));
}
function sampleScheduler(){
schedulerTimer=null;const gap=schedulerGap();if(carrierCommitted&&!recoveryController.active())schedulerGapPending=Math.max(schedulerGapPending,gap);armScheduler();
}
function observeResumeTrigger(){
const gap=schedulerGap();if(!carrierCommitted||closed)return;
const gap=Math.max(schedulerGapPending,schedulerGap());schedulerGapPending=0;if(!carrierCommitted||closed)return;
if(gap>=2*longPollMs)status('reconnecting','retrying','',bridgeRecoveryMs);
if(gap>=reconnectGraceMs&&!recoveryController.active())recoveryController.recover('timeout',null);
}
@@ -235,6 +243,7 @@ function commitCarrier(probe,epoch){
if(switching){fail('protocol');return}
clearProbeTimer();try{consumeProbe(probe)}catch(error){fail('protocol');return}
carrierCommitted=true;candidateRunning=false;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
resetScheduler();
attemptController=null;currentAttempt=null;
status('connected','committed','',0);
if(carrier==='https')poll();
@@ -475,7 +484,7 @@ function deleteSession(){
if(token)fetch(relayOrigin+'/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(attemptController)attemptController.abort();if(pollController)pollController.abort();
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(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();
@@ -505,6 +514,7 @@ recoveryController=recoverySupport.create({
restored:finishOldRecovery,replace:replaceCarrier,replaceable:error=>failureReason(error,'network')!=='protocol',
reason:(error,fallback)=>failureReason(error,fallback),terminal:reason=>fail(recoveryController.remaining()<=0?'timeout':reason)
});
armScheduler();
addEventListener('message',event=>{
if(event.source!==parent)return;if(initialized){if(event.ports&&event.ports.length===1)event.ports[0].close();return}
if(event.data===null||typeof event.data!=='object')return;
+11 -8
View File
@@ -87,9 +87,10 @@ fn rendered_page_embeds_the_configured_bridge_timing_policy() {
assert!(page.body.contains("websocketOpenMs=11*1000"));
assert!(page.body.contains("reconnectGraceMs=119*1000"));
assert!(page.body.contains("let probeCoalesceMs=4"));
assert!(page.body.contains(
"helloTimer=setTimeout(()=>fail('timeout'),bridgeRequestMs)"
));
assert!(
page.body
.contains("helloTimer=setTimeout(()=>fail('timeout'),bridgeRequestMs)")
);
}
#[test]
@@ -143,10 +144,9 @@ fn retry_and_attempt_state_are_frozen_before_fetch() {
.contains("async function send(path,frozenOptions,remainingBudget,maxAttempts)")
);
assert!(!page.body.contains("makeOptions"));
assert!(
page.body
.contains("if(settings.closed()||(external&&external.aborted))throw new Error('request aborted')")
);
assert!(page.body.contains(
"if(settings.closed()||(external&&external.aborted))throw new Error('request aborted')"
));
assert!(page.body.contains(
"const frozen=options('POST',bootstrap,snapshot.hello,attemptHeaders(snapshot.attempt,snapshot.failure),controller.signal)"
));
@@ -169,5 +169,8 @@ fn ambiguous_commit_is_resolved_before_carrier_advance() {
);
assert!(page.body.contains("const token=cleanupToken||sessionToken"));
assert!(page.body.contains("'X-Carrier-Failure':terminalFailure"));
assert!(page.body.contains("addEventListener('pagehide',()=>fail('navigation')"));
assert!(
page.body
.contains("addEventListener('pagehide',()=>fail('navigation')")
);
}