From 34eeb2d856736e4640236f0d03fb7ca3d778984c Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:33:33 +0300 Subject: [PATCH] WEB Carriers Safe-matrix Refactored Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/config/load/runtime_web.rs | 3 +- src/config/load/validate_web.rs | 9 + src/config/load/validate_web/memory.rs | 30 +- src/config/load/validate_web/negotiation.rs | 11 +- src/config/load/validate_web/timeouts.rs | 23 +- src/config/load/validate_web/websocket.rs | 8 + .../tests/load_basic_tests/web_tests.rs | 2 + src/config/types/web.rs | 16 +- src/config/types/web/defaults.rs | 2 +- src/web/bridge.rs | 123 +++-- src/web/bridge/tests.rs | 38 +- src/web/http.rs | 68 ++- src/web/http/down.rs | 10 +- src/web/http/negotiation_tests.rs | 134 ++++- src/web/http/request.rs | 141 +++--- src/web/http/request/identity.rs | 111 +++++ src/web/http/websocket.rs | 12 +- src/web/http/websocket/driver.rs | 190 +++++-- src/web/http/websocket/driver/io.rs | 94 ++-- src/web/http/websocket/tests.rs | 33 +- src/web/manager.rs | 46 +- src/web/manager/admission.rs | 4 +- src/web/manager/budget.rs | 4 + src/web/manager/carrier_learning.rs | 469 ++++++++++++++++++ src/web/manager/carrier_learning/tests.rs | 222 +++++++++ src/web/manager/carrier_outcome.rs | 160 ++++++ src/web/manager/credentials.rs | 26 +- src/web/manager/lifecycle.rs | 52 +- src/web/manager/negotiation.rs | 40 ++ src/web/manager/session_admission.rs | 37 ++ src/web/manager/session_creation.rs | 323 ++++++------ src/web/manager/state.rs | 60 ++- src/web/manager/websocket.rs | 80 ++- src/web/manager/websocket/tests.rs | 13 +- src/web/session.rs | 97 ++-- src/web/session/backend.rs | 4 + src/web/session/backend_tests.rs | 3 + src/web/session/downlink.rs | 135 +---- src/web/session/downlink_tests.rs | 143 ++++++ src/web/session/lane_downlink.rs | 92 ++++ src/web/session/lane_uplink.rs | 187 +++++++ src/web/session/lanes.rs | 400 ++++----------- src/web/session/lanes/tests.rs | 153 +++++- src/web/session/lifecycle.rs | 78 ++- src/web/session/negotiation.rs | 296 +++++++++++ src/web/session/uplink.rs | 54 +- src/web/session/websocket.rs | 98 +++- src/web/session/websocket/tests.rs | 3 + src/web/trace/types.rs | 5 +- 49 files changed, 3406 insertions(+), 936 deletions(-) create mode 100644 src/web/http/request/identity.rs create mode 100644 src/web/manager/carrier_learning.rs create mode 100644 src/web/manager/carrier_learning/tests.rs create mode 100644 src/web/manager/carrier_outcome.rs create mode 100644 src/web/manager/session_admission.rs create mode 100644 src/web/session/downlink_tests.rs create mode 100644 src/web/session/lane_downlink.rs create mode 100644 src/web/session/lane_uplink.rs create mode 100644 src/web/session/negotiation.rs diff --git a/src/config/load/runtime_web.rs b/src/config/load/runtime_web.rs index 9f7eb65..ab31b6a 100644 --- a/src/config/load/runtime_web.rs +++ b/src/config/load/runtime_web.rs @@ -65,7 +65,8 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> { secret_mode: profile.secret_mode, carrier: config.web.carrier, carrier_negotiation_enabled: config.web.carrier_negotiation_enabled(), - carrier_learning: config.web.carrier_learning, + carrier_learning: config.web.carrier_negotiation_enabled() + && config.web.carrier_learning, carriers: Arc::clone(&carrier_candidates), carrier_negotiation_deadlines_secs: config .web diff --git a/src/config/load/validate_web.rs b/src/config/load/validate_web.rs index b1f429f..3ea8824 100644 --- a/src/config/load/validate_web.rs +++ b/src/config/load/validate_web.rs @@ -72,6 +72,9 @@ pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> { validate_limits(&config.web.limits)?; debug::validate(&config.web.debug, &config.web.limits)?; let carriers = negotiation::validate(&config.web)?; + if carriers.contains(&WebCarrier::Https) && config.web.limits.max_http_handlers < 2 { + return config_error("WEB https candidates require web.limits.max_http_handlers >= 2"); + } if carriers.contains(&WebCarrier::HttpsLanes) && config.web.limits.max_http_handlers < 4 { return config_error( "WEB https-lanes candidates require web.limits.max_http_handlers >= 4", @@ -143,6 +146,11 @@ fn validate_limits(limits: &WebLimitsConfig) -> Result<()> { if !(1..=MAX_WEB_TOMBSTONES_PER_SESSION).contains(&limits.max_tombstones_per_session) { return config_error("web.limits.max_tombstones_per_session must be within [1, 4096]"); } + if limits.pending_bytes_per_lane <= WEB_FRAME_HEADER_BYTES + WEB_QUEUE_ITEM_COST { + return config_error( + "web.limits.pending_bytes_per_lane must preserve one non-empty DATA frame", + ); + } if limits.carrier_batch_bytes > limits.max_body_bytes || limits.carrier_batch_bytes < limits @@ -248,6 +256,7 @@ fn validate_limits(limits: &WebLimitsConfig) -> Result<()> { || limits.max_bootstraps_per_ip > limits.max_bootstraps_global || limits.max_http_handlers > limits.max_http_connections || limits.max_body_readers > limits.max_http_handlers + || limits.max_lane_open_waits_per_session > limits.max_streams_per_session || limits.pending_bytes_per_session > limits.pending_bytes_global || limits.pending_items_per_session > limits.pending_items_global || limits.pending_bytes_per_lane > limits.pending_bytes_per_session diff --git a/src/config/load/validate_web/memory.rs b/src/config/load/validate_web/memory.rs index d8cf814..81262ac 100644 --- a/src/config/load/validate_web/memory.rs +++ b/src/config/load/validate_web/memory.rs @@ -3,7 +3,8 @@ use super::*; const WEB_DEBUG_RENDERERS: usize = 2; const WEB_DEBUG_STATUS_PAGE_BYTES: usize = 8 * 1024 * 1024; const WEB_DEBUG_GROUP_SCRATCH_BYTES: usize = 4 * 1024 * 1024; -const WEB_CARRIER_LEARNING_ENTRY_BYTES: usize = 256; +const WEB_CARRIER_LEARNING_ENTRY_BYTES: usize = 512; +const WEB_LANE_STATE_BYTES: usize = 512; /// Validates process-wide body, header, queue, static, and debug reservations. pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> { @@ -57,6 +58,15 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> { .ok_or_else(|| { ProxyError::Config("web.carrier learning reservation overflowed usize".to_string()) })?; + let lane_state_reservation = limits + .max_streams_per_session + .checked_add(limits.max_tombstones_per_session) + .and_then(|value| value.checked_add(1)) + .and_then(|value| value.checked_mul(limits.max_sessions_global)) + .and_then(|value| value.checked_mul(WEB_LANE_STATE_BYTES)) + .ok_or_else(|| { + ProxyError::Config("web.limits lane state reservation overflowed usize".to_string()) + })?; let reserved = limits .pending_bytes_global .checked_add(limits.max_body_bytes_global) @@ -65,6 +75,7 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> { .and_then(|value| value.checked_add(status_pages)) .and_then(|value| value.checked_add(debug_reservation)) .and_then(|value| value.checked_add(carrier_learning_reservation)) + .and_then(|value| value.checked_add(lane_state_reservation)) .and_then(|value| value.checked_add(http_header_reservation)) .ok_or_else(|| ProxyError::Config("web.limits byte ceilings overflow usize".to_string()))?; if reserved > limits.memory_envelope_bytes @@ -76,3 +87,20 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_envelope_includes_bounded_lane_and_learning_metadata() { + let limits = WebLimitsConfig::default(); + assert!(validate(&limits).is_ok()); + + let previous_envelope = WebLimitsConfig { + memory_envelope_bytes: 768 * 1024 * 1024, + ..limits + }; + assert!(validate(&previous_envelope).is_err()); + } +} diff --git a/src/config/load/validate_web/negotiation.rs b/src/config/load/validate_web/negotiation.rs index 3ed0798..3544ac4 100644 --- a/src/config/load/validate_web/negotiation.rs +++ b/src/config/load/validate_web/negotiation.rs @@ -33,9 +33,12 @@ pub(super) fn validate(config: &WebConfig) -> Result> { "web.timeouts.carrier_negotiation_deadlines_secs must be non-zero and strictly increasing", ); } - if deadlines[3] > config.timeouts.bootstrap_lifetime_secs { + let retained_chain_secs = deadlines[3] + .checked_add(config.timeouts.carrier_health_secs) + .and_then(|value| value.checked_add(1)); + if retained_chain_secs.is_none_or(|value| value >= config.timeouts.bootstrap_lifetime_secs) { return config_error( - "web.timeouts carrier negotiation deadline must not exceed bootstrap_lifetime_secs", + "web.timeouts final carrier deadline plus health and cleanup must be lower than bootstrap_lifetime_secs", ); } Ok(candidates) @@ -112,5 +115,9 @@ mod tests { assert!(validate(&config).is_err()); config.timeouts.carrier_negotiation_deadlines_secs = [3, 5, 8, 121]; assert!(validate(&config).is_err()); + config.timeouts.carrier_negotiation_deadlines_secs = [3, 5, 8, 89]; + assert!(validate(&config).is_err()); + config.timeouts.carrier_negotiation_deadlines_secs = [3, 5, 8, 88]; + assert!(validate(&config).is_ok()); } } diff --git a/src/config/load/validate_web/timeouts.rs b/src/config/load/validate_web/timeouts.rs index 736962a..73360a0 100644 --- a/src/config/load/validate_web/timeouts.rs +++ b/src/config/load/validate_web/timeouts.rs @@ -30,8 +30,27 @@ pub(super) fn validate(timeouts: &WebTimeoutsConfig) -> Result<()> { { return config_error(&format!("web.timeouts.{field} must be within [1, 3600]")); } - if timeouts.carrier_learning_secs == 0 { - return config_error("web.timeouts.carrier_learning_secs must be > 0"); + if !(2..=86_400).contains(&timeouts.carrier_learning_secs) { + return config_error("web.timeouts.carrier_learning_secs must be within [2, 86400]"); + } + if timeouts.stream_first_byte_secs > 300 { + return config_error("web.timeouts.stream_first_byte_secs must be within [1, 300]"); + } + if timeouts.websocket_upgrade_secs > 60 { + return config_error("web.timeouts.websocket_upgrade_secs must be within [1, 60]"); + } + if timeouts.websocket_open_secs > 300 { + return config_error("web.timeouts.websocket_open_secs must be within [1, 300]"); + } + if timeouts.lane_open_wait_secs > timeouts.long_poll_secs { + return config_error( + "web.timeouts.lane_open_wait_secs must not exceed long_poll_secs", + ); + } + if timeouts.carrier_health_secs > timeouts.reconnect_grace_secs { + return config_error( + "web.timeouts.carrier_health_secs must not exceed reconnect_grace_secs", + ); } let request_deadline = timeouts .header_secs diff --git a/src/config/load/validate_web/websocket.rs b/src/config/load/validate_web/websocket.rs index 4380f97..34ea38e 100644 --- a/src/config/load/validate_web/websocket.rs +++ b/src/config/load/validate_web/websocket.rs @@ -42,6 +42,14 @@ pub(super) fn validate( "WebSocket carriers require websocket_http_connection_reserve within [1, max_http_connections)", ); } + let websocket_capacity = limits + .max_http_connections + .saturating_sub(limits.websocket_http_connection_reserve); + if limits.max_websocket_evictions_in_flight > websocket_capacity { + return config_error( + "web.limits.max_websocket_evictions_in_flight must not exceed WebSocket connection capacity", + ); + } let socket_base = WEBSOCKET_IO_BUFFER_BYTES .checked_mul(2) .and_then(|value| value.checked_add(WEBSOCKET_DRIVER_OVERHEAD_BYTES)) diff --git a/src/config/tests/load_basic_tests/web_tests.rs b/src/config/tests/load_basic_tests/web_tests.rs index d39049e..28200bb 100644 --- a/src/config/tests/load_basic_tests/web_tests.rs +++ b/src/config/tests/load_basic_tests/web_tests.rs @@ -57,6 +57,7 @@ fn web_config_builds_canonical_runtime_snapshot() { fn web_carriers_missing_or_false_disable_negotiation() { let missing = load_config_from_temp_toml(WEB_CONFIG); assert!(!missing.web.carrier_negotiation_enabled()); + assert!(!missing.web.runtime.unwrap().profiles[0].carrier_learning); let disabled = WEB_CONFIG.replace( "carrier = \"https-lanes\"", @@ -64,6 +65,7 @@ fn web_carriers_missing_or_false_disable_negotiation() { ); let disabled = load_config_from_temp_toml(&disabled); assert!(!disabled.web.carrier_negotiation_enabled()); + assert!(!disabled.web.runtime.as_ref().unwrap().profiles[0].carrier_learning); assert_eq!( disabled.web.runtime.unwrap().profiles[0].carriers.as_ref(), [WebCarrier::HttpsLanes] diff --git a/src/config/types/web.rs b/src/config/types/web.rs index 85f1b72..2a8fc58 100644 --- a/src/config/types/web.rs +++ b/src/config/types/web.rs @@ -101,10 +101,10 @@ pub struct WebLimitsConfig { /// Per-session ceiling for downlink polls waiting for a lane OPEN. #[serde(default = "default_web_max_lane_open_waits_per_session")] pub max_lane_open_waits_per_session: usize, - /// Queued and in-flight downlink bytes allowed for one independent lane. + /// Queued and resident DATA bytes allowed for one independent lane. #[serde(default = "default_web_pending_bytes_per_lane")] pub pending_bytes_per_lane: usize, - /// Queued and in-flight downlink items allowed for one independent lane. + /// Queued and resident DATA items allowed for one independent lane. #[serde(default = "default_web_pending_items_per_lane")] pub pending_items_per_lane: usize, /// Process-wide transient WebSocket byte sub-budget inside pending bytes. @@ -194,7 +194,7 @@ pub struct WebLimitsConfig { /// Process-wide retained and in-flight WEB debug byte ceiling. #[serde(default = "default_web_debug_bytes_global")] pub debug_bytes_global: usize, - /// Declared process envelope for HTTP heads, bodies, queues, and static snapshots. + /// Declared process envelope for HTTP, queues, lane state, learning, and static snapshots. #[serde(default = "default_web_memory_envelope_bytes")] pub memory_envelope_bytes: usize, /// Sustained process-wide bootstrap issuance rate. @@ -414,10 +414,12 @@ impl WebConfig { let Some(configured) = self.carriers.enabled() else { return vec![self.carrier]; }; - let mut candidates = configured.to_vec(); - if !candidates.contains(&self.carrier) { - candidates.push(self.carrier); - } + let mut candidates = configured + .iter() + .copied() + .filter(|carrier| *carrier != self.carrier) + .collect::>(); + candidates.push(self.carrier); candidates } diff --git a/src/config/types/web/defaults.rs b/src/config/types/web/defaults.rs index ff424fe..f85b44d 100644 --- a/src/config/types/web/defaults.rs +++ b/src/config/types/web/defaults.rs @@ -73,7 +73,7 @@ usize_default!(default_web_max_static_file_bytes, 8 * 1024 * 1024); usize_default!(default_web_max_static_bytes, 64 * 1024 * 1024); usize_default!(default_web_debug_records_capacity, 65_536); usize_default!(default_web_debug_bytes_global, 64 * 1024 * 1024); -usize_default!(default_web_memory_envelope_bytes, 768 * 1024 * 1024); +usize_default!(default_web_memory_envelope_bytes, 1280 * 1024 * 1024); u32_default!(default_web_new_bootstraps_per_minute, 1200); u32_default!(default_web_new_bootstraps_burst, 256); u32_default!(default_web_new_sessions_per_minute, 600); diff --git a/src/web/bridge.rs b/src/web/bridge.rs index 76aff87..c262351 100644 --- a/src/web/bridge.rs +++ b/src/web/bridge.rs @@ -71,24 +71,29 @@ const DOCUMENT: &str = r##" const bootstrap="__BOOTSTRAP__"; const relayOrigin='https://__HOST__',carrierCapabilities='https,https-lanes,websocket,websocket-lanes'; const negotiationEnabled=__NEGOTIATION_ENABLED__,candidateCount=__CANDIDATE_COUNT__,candidateDeadlines=[__CARRIER_DEADLINES__]; -const effectiveDeadlines=candidateDeadlines.slice(0,candidateCount-1).concat(candidateDeadlines[3]); +let negotiatedCandidateCount=candidateCount,negotiatedFinalDeadline=candidateDeadlines[3],negotiatedFrozen=false; const batchLimit=__BATCH_LIMIT__,queueLimit=__QUEUE_LIMIT__,queueItemLimit=__QUEUE_ITEMS__; const laneQueueLimit=Math.min(queueLimit,8388608),laneItemLimit=Math.min(queueItemLimit,1024),closedLaneLimit=4096; const fragment=location.hash,androidNonce=/^#android=([A-Za-z0-9_-]{43})$/.exec(fragment)?.[1]||''; history.replaceState(null,'',location.pathname); -let initialized=false,closed=false,port=null,sessionToken='',createStarted=false,socket=null,socketReady=false,carrier=''; +let initialized=false,closed=false,port=null,sessionToken='',cleanupToken='',createStarted=false,socket=null,socketReady=false,carrier=''; let queuedBytes=0,queuedItems=0,upSequence=1,downCursor='0',upRunning=false,pollController=null; let helloFrame=null,welcomeSent=false,carrierAttempt=1,carrierFailure='',carrierCommitted=false; -let negotiationStartedAt=0,carrierTimer=null,attemptController=null,attemptEpoch=1,candidateRunning=false,switching=false; +let negotiationStartedAt=0,carrierTimer=null,attemptController=null,attemptEpoch=1,candidateRunning=false,switching=false,currentAttempt=null; const pending=[],upPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[]; const status=state=>{if(port&&!closed)port.postMessage({t:'status',state})}; -const pause=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds)); +const pause=(milliseconds,signal)=>new Promise((resolve,reject)=>{ + if(signal&&signal.aborted){reject(new Error('request aborted'));return} + const timer=setTimeout(done,milliseconds);function done(){if(signal)signal.removeEventListener('abort',abort);resolve()} + function abort(){clearTimeout(timer);signal.removeEventListener('abort',abort);reject(new Error('request aborted'))} + if(signal)signal.addEventListener('abort',abort,{once:true}); +}); const socketURL=()=>relayOrigin.replace(/^https:/,'wss:')+'/api/v1/ws'; const options=(method,token,body,headers,signal,keepalive)=>({ method,body,signal,keepalive:!!keepalive,mode:'same-origin',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer', headers:Object.assign(token?{Authorization:'Bearer '+token}:{},body?{'Content-Type':'application/octet-stream'}:{},headers||{}) }); -const attemptHeaders=()=>negotiationEnabled?Object.assign({'X-Carrier-Capabilities':carrierCapabilities,'X-Carrier-Attempt':String(carrierAttempt)},carrierFailure?{'X-Carrier-Failure':carrierFailure}:{}):{}; +const attemptHeaders=(attempt,failure)=>negotiationEnabled?Object.assign({'X-Carrier-Capabilities':carrierCapabilities,'X-Carrier-Attempt':String(attempt)},failure?{'X-Carrier-Failure':failure}:{}):{}; function reserve(data,lane){ let buffered=socket?socket.bufferedAmount:0;for(const value of lanes.values())if(value.socket)buffered+=value.socket.bufferedAmount; if(!data.byteLength||data.byteLength>queueLimit-queuedBytes-buffered||queuedItems>=queueItemLimit)return false; @@ -165,12 +170,13 @@ function retryAfterMs(response){ if(Number.isFinite(when)){const delta=when-Date.now();return delta>0?Math.min(delta,30000):0} return 0; } -async function request(path,makeOptions){ +async function request(path,frozenOptions){ let delay=250,attempt=0;const deadline=Date.now()+90000; while(true){ - const requestOptions=makeOptions(),controller=new AbortController(),external=requestOptions.signal; + const controller=new AbortController(),external=frozenOptions.signal; + if(closed||(external&&external.aborted))throw new Error('request aborted'); const abort=()=>controller.abort();if(external)external.addEventListener('abort',abort,{once:true}); - requestOptions.signal=controller.signal;const timer=setTimeout(abort,90000); + const requestOptions=Object.assign({},frozenOptions,{signal:controller.signal});const timer=setTimeout(abort,90000); let serviceUnavailable=false,wait=0; try{ const response=await fetch(relayOrigin+path,requestOptions); @@ -181,16 +187,32 @@ async function request(path,makeOptions){ if(++attempt===9)throw new Error('carrier retry limit reached'); }finally{clearTimeout(timer);if(external)external.removeEventListener('abort',abort)} if(serviceUnavailable&&Date.now()>=deadline)throw new Error('carrier retry limit reached'); - status('reconnecting');await pause(wait||(delay+Math.floor(Math.random()*Math.max(1,delay/4)))); + status('reconnecting');await pause(wait||(delay+Math.floor(Math.random()*Math.max(1,delay/4))),external); + if(closed||(external&&external.aborted))throw new Error('request aborted'); if(!serviceUnavailable)delay=Math.min(delay*2,5000); } } function fail(){if(closed)return;status('failed');if(port)port.postMessage({t:'close'});close(true)} 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')||''; + if(!knownCarrier(selected))throw new Error('invalid carrier mode'); + if(!negotiationEnabled){if(echo!=='')throw new Error('unexpected carrier attempt');return {selected,state:''}} + 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); + if(!Number.isInteger(echoedAttempt)||echoedAttempt<1||(exactAttempt?echoedAttempt!==expectedAttempt:echoedAttempt>expectedAttempt))throw new Error('invalid carrier attempt'); + if(parsedCount>candidateCount||parsedDeadline>candidateDeadlines[3])throw new Error('invalid carrier bounds'); + 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}; +} function armCarrierDeadline(epoch){ if(!negotiationStartedAt||epoch!==attemptEpoch)return; if(carrierTimer)clearTimeout(carrierTimer); - const remaining=negotiationStartedAt+effectiveDeadlines[carrierAttempt-1]*1000-Date.now(); + const deadline=carrierAttempt>=negotiatedCandidateCount?negotiatedFinalDeadline:candidateDeadlines[carrierAttempt-1]; + const remaining=negotiationStartedAt+deadline*1000-Date.now(); carrierTimer=setTimeout(()=>advanceCarrier('timeout',epoch),Math.max(0,remaining)); } function resetCandidate(){ @@ -198,21 +220,48 @@ function resetCandidate(){ if(socket){const previous=socket;socket=null;previous.close()}socketReady=false; for(const lane of lanes.values()){if(lane.controller)lane.controller.abort();if(lane.socket)lane.socket.close()} lanes.clear();closedLanes.clear();closedLaneOrder.length=0;upPending.length=0;upSequence=1;downCursor='0';upRunning=false; - sessionToken='';carrier='';candidateRunning=false; + sessionToken='';carrier='';candidateRunning=false;currentAttempt=null; +} +function advanceConfirmed(reason,epoch){ + if(closed||carrierCommitted||epoch!==attemptEpoch)return; + resetCandidate(); + if(carrierAttempt>=negotiatedCandidateCount||Date.now()>=negotiationStartedAt+negotiatedFinalDeadline*1000){switching=false;fail();return} + carrierAttempt++;carrierFailure=reason;attemptEpoch++;const nextEpoch=attemptEpoch;switching=false; + status('reconnecting');armCarrierDeadline(nextEpoch);createSession(nextEpoch); } function advanceCarrier(reason,epoch){ if(closed||carrierCommitted||epoch!==attemptEpoch||switching)return; + if(!negotiationEnabled){fail();return} switching=true;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null; - if(attemptController)attemptController.abort();attemptController=null; - resetCandidate(); - if(carrierAttempt>=candidateCount||Date.now()>=negotiationStartedAt+effectiveDeadlines[effectiveDeadlines.length-1]*1000){switching=false;fail();return} - carrierAttempt++;carrierFailure=reason;attemptEpoch++;const nextEpoch=attemptEpoch;switching=false; - status('reconnecting');armCarrierDeadline(nextEpoch);createSession(nextEpoch); + const snapshot=currentAttempt;if(attemptController)attemptController.abort();attemptController=null; + if(!snapshot||snapshot.epoch!==epoch){switching=false;fail();return} + resolveAttempt(reason,epoch,snapshot); +} +async function resolveAttempt(reason,epoch,snapshot){ + const controller=new AbortController();attemptController=controller; + const remaining=negotiationStartedAt+negotiatedFinalDeadline*1000-Date.now(); + if(remaining<=0){switching=false;fail();return} + const timer=setTimeout(()=>controller.abort(),remaining); + try{ + const frozen=options('POST',bootstrap,snapshot.hello,attemptHeaders(snapshot.attempt,snapshot.failure),controller.signal); + const response=await request('/api/v1/session',frozen); + if(closed||epoch!==attemptEpoch){await response.arrayBuffer();return} + if(response.status===409){sessionEcho(response,snapshot.attempt,['committed','healthy'],false);await response.arrayBuffer();switching=false;fail();return} + if(response.status!==200){await response.arrayBuffer();switching=false;fail();return} + const echo=sessionEcho(response,snapshot.attempt,['provisional','committed','healthy'],true); + const token=response.headers.get('X-Session-Token')||'',cursor=response.headers.get('X-Down-Cursor')||''; + if(!token||cursor!=='0'||(snapshot.selected&&echo.selected!==snapshot.selected))throw new Error('changed carrier replay'); + const welcome=await response.arrayBuffer();if(closed||epoch!==attemptEpoch)return; + cleanupToken=token; + if(!welcomeSent){welcomeSent=true;port.postMessage(welcome,[welcome])} + if(echo.state!=='provisional'){switching=false;fail();return} + advanceConfirmed(reason,epoch); + }catch(error){if(!closed&&epoch===attemptEpoch){switching=false;fail()}} + finally{clearTimeout(timer);if(attemptController===controller)attemptController=null} } function maybeStartCandidate(){ let probe;try{probe=findProbe()}catch(error){fail();return} if(!probe||closed||carrierCommitted)return; - if(negotiationEnabled&&!negotiationStartedAt){negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch)} if(!sessionToken||candidateRunning)return; candidateRunning=true;const epoch=attemptEpoch; if(carrier==='https')probeHttp(probe,null,epoch); @@ -222,26 +271,28 @@ function maybeStartCandidate(){ else advanceCarrier('protocol',epoch); } async function createSession(epoch){ - attemptController=new AbortController(); + const controller=new AbortController(),attempt=carrierAttempt,failure=carrierFailure; + const snapshot={epoch,attempt,failure,hello:helloFrame,selected:''};currentAttempt=snapshot;attemptController=controller; try{ status('connecting'); - const response=await request('/api/v1/session',()=>options('POST',bootstrap,helloFrame,attemptHeaders(),attemptController.signal)); + const frozen=options('POST',bootstrap,snapshot.hello,attemptHeaders(attempt,failure),controller.signal); + const response=await request('/api/v1/session',frozen); if(closed||epoch!==attemptEpoch){await response.arrayBuffer();return} - if(response.status!==200){await response.arrayBuffer();if(negotiationStartedAt)advanceCarrier('http',epoch);else fail();return} - const selected=response.headers.get('X-Carrier-Mode')||'',echo=response.headers.get('X-Carrier-Attempt')||''; - if(!knownCarrier(selected)||(negotiationEnabled?echo!==String(carrierAttempt):echo!=='')){await response.arrayBuffer();if(negotiationStartedAt)advanceCarrier('protocol',epoch);else fail();return} + if(response.status===409){sessionEcho(response,attempt,['committed','healthy'],false);await response.arrayBuffer();fail();return} + if(response.status!==200){await response.arrayBuffer();advanceCarrier('http',epoch);return} + const echo=sessionEcho(response,attempt,['provisional'],true),selected=echo.selected;snapshot.selected=selected; const token=response.headers.get('X-Session-Token')||'',cursor=response.headers.get('X-Down-Cursor')||''; - if(!token||cursor!=='0'){await response.arrayBuffer();if(negotiationStartedAt)advanceCarrier('protocol',epoch);else fail();return} + if(!token||cursor!=='0'){await response.arrayBuffer();advanceCarrier('protocol',epoch);return} const welcome=await response.arrayBuffer();if(closed||epoch!==attemptEpoch)return; - carrier=selected;sessionToken=token;downCursor=cursor; + carrier=selected;sessionToken=token;cleanupToken=token;downCursor=cursor; if(!welcomeSent){welcomeSent=true;port.postMessage(welcome,[welcome])} maybeStartCandidate(); - }catch(error){if(closed||epoch!==attemptEpoch)return;if(negotiationStartedAt)advanceCarrier('network',epoch);else fail()} + }catch(error){if(closed||epoch!==attemptEpoch)return;advanceCarrier('network',epoch)} } async function probeHttp(probe,laneID,epoch){ try{ - const headers={'X-Up-Seq':'1'};if(laneID!==null)headers['X-Lane-ID']=String(laneID); - const response=await request('/api/v1/up',()=>options('POST',sessionToken,probe.data,headers,attemptController.signal)); + 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('POST',token,body,headers,controller.signal)); if(closed||epoch!==attemptEpoch){await response.arrayBuffer();return} if(response.status!==204){await response.arrayBuffer();advanceCarrier('http',epoch);return} if(response.headers.get('X-Up-Ack')!=='1'){advanceCarrier('protocol',epoch);return} @@ -251,11 +302,13 @@ async function probeHttp(probe,laneID,epoch){ } function commitCarrier(probe,epoch){ if(closed||carrierCommitted||epoch!==attemptEpoch)return; + if(switching){fail();return} try{consumeProbe(probe)}catch(error){fail();return} carrierCommitted=true;candidateRunning=false;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null; + attemptController=null;currentAttempt=null; status('connected'); if(carrier==='https')poll(); - else if(carrier==='https-lanes'){const control=ensureLane(0);pollLane(control);const lane=lanes.get(probe.id);if(lane&&!lane.polling)pollLane(lane)} + else if(carrier==='https-lanes'){const lane=lanes.get(probe.id);if(lane&&!lane.polling)pollLane(lane)} for(const data of pending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)} } function queueCarrier(data){ @@ -271,7 +324,7 @@ async function runUp(){ try{ while(!closed&&sessionToken&&upPending.length){ const batch=joinPending(upPending,null),sequence=String(upSequence); - const response=await request('/api/v1/up',()=>options('POST',sessionToken,batch.body,{'X-Up-Seq':sequence})); + const response=await request('/api/v1/up',options('POST',sessionToken,batch.body,{'X-Up-Seq':sequence})); if(response.status!==204||response.headers.get('X-Up-Ack')!==sequence)throw new Error('uplink rejected'); release(batch.total,batch.count,null);port.postMessage({t:'traffic',up:batch.total,down:0});upSequence++; } @@ -279,7 +332,7 @@ async function runUp(){ finally{upRunning=false;if(!closed&&sessionToken&&upPending.length)runUp()} } function openCandidateSocket(probe,laneID,epoch){ - const protocol=laneID===null?(negotiationEnabled?'tproxy-auto-v1.':'tproxy-v1.')+sessionToken:(negotiationEnabled?'tproxy-auto-lane-v1.':'tproxy-lane-v1.')+sessionToken+'.'+String(laneID); + const token=sessionToken,protocol=laneID===null?(negotiationEnabled?'tproxy-auto-v1.':'tproxy-v1.')+token:(negotiationEnabled?'tproxy-auto-lane-v1.':'tproxy-lane-v1.')+token+'.'+String(laneID); const next=new WebSocket(socketURL(),protocol);next.binaryType='arraybuffer';let opened=false,lane=null; if(laneID===null)socket=next;else{lane=ensureLane(laneID);lane.socket=next} next.onopen=()=>{ @@ -322,7 +375,7 @@ async function poll(){ while(!closed&&sessionToken){ try{ pollController=new AbortController(); - const response=await request('/api/v1/down',()=>options('POST',sessionToken,null,{'X-Down-Cursor':downCursor},pollController.signal)); + const response=await request('/api/v1/down',options('POST',sessionToken,null,{'X-Down-Cursor':downCursor},pollController.signal)); if(response.status===204){status('connected');continue} if(response.status!==200)throw new Error('downlink rejected'); const next=response.headers.get('X-Down-Cursor')||'',data=await response.arrayBuffer(); @@ -386,7 +439,7 @@ async function runLaneUp(lane){ try{ while(!closed&&sessionToken&&lane.pending.length){ const batch=joinPending(lane.pending,lane),sequence=String(lane.sequence),laneID=String(lane.id); - const response=await request('/api/v1/up',()=>options('POST',sessionToken,batch.body,{'X-Up-Seq':sequence,'X-Lane-ID':laneID})); + const response=await request('/api/v1/up',options('POST',sessionToken,batch.body,{'X-Up-Seq':sequence,'X-Lane-ID':laneID})); if(response.status!==204||response.headers.get('X-Up-Ack')!==sequence)throw new Error('lane uplink rejected'); release(batch.total,batch.count,lane);port.postMessage({t:'traffic',up:batch.total,down:0});lane.sequence++; if(!lane.polling)pollLane(lane); @@ -399,7 +452,7 @@ async function pollLane(lane){ try{ while(!closed&&sessionToken&&lanes.get(lane.id)===lane){ const controller=new AbortController(),laneID=String(lane.id);lane.controller=controller; - const response=await request('/api/v1/down',()=>options('POST',sessionToken,null,{'X-Down-Cursor':lane.cursor,'X-Lane-ID':laneID},controller.signal)); + const response=await request('/api/v1/down',options('POST',sessionToken,null,{'X-Down-Cursor':lane.cursor,'X-Lane-ID':laneID},controller.signal)); if(response.status===204){ if(response.headers.get('X-Lane-Closed')==='1'){finishLane(lane,false);return} status('connected');continue; @@ -415,7 +468,7 @@ async function pollLane(lane){ finally{lane.polling=false;lane.controller=null} } function deleteSession(){ - if(sessionToken)fetch(relayOrigin+'/api/v1/session',options('DELETE',sessionToken,null,null,undefined,true)).catch(()=>{}); + const token=cleanupToken||sessionToken;if(token)fetch(relayOrigin+'/api/v1/session',options('DELETE',token,null,null,undefined,true)).catch(()=>{}); } function close(notifyServer){ if(closed)return;closed=true;if(carrierTimer)clearTimeout(carrierTimer);if(attemptController)attemptController.abort();if(pollController)pollController.abort(); @@ -427,7 +480,7 @@ function activatePort(nextPort){ initialized=true;port=nextPort; port.onmessage=message=>{ if(message.data instanceof ArrayBuffer){ - if(!createStarted){createStarted=true;helloFrame=message.data;createSession(attemptEpoch)} + if(!createStarted){createStarted=true;helloFrame=message.data;if(negotiationEnabled){negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch)}createSession(attemptEpoch)} else if(!carrierCommitted){if(!reserve(message.data,null)){fail();return}pending.push(message.data);maybeStartCandidate()} else queueCarrier(message.data); }else if(message.data&&message.data.t==='close')close(true); diff --git a/src/web/bridge/tests.rs b/src/web/bridge/tests.rs index fb25e52..aa674d2 100644 --- a/src/web/bridge/tests.rs +++ b/src/web/bridge/tests.rs @@ -49,9 +49,8 @@ fn effective_deadline_formula_uses_the_final_checkpoint() { "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", 3, ); - assert!(page.body.contains( - "candidateDeadlines.slice(0,candidateCount-1).concat(candidateDeadlines[3])" - )); + assert!(page.body.contains("negotiatedFinalDeadline=candidateDeadlines[3]")); + assert!(page.body.contains("carrierAttempt>=negotiatedCandidateCount?negotiatedFinalDeadline")); } #[test] @@ -68,9 +67,40 @@ fn disabled_negotiation_does_not_arm_a_carrier_deadline() { &SecureRandom::new(), ); assert!(page.body.contains( - "if(negotiationEnabled&&!negotiationStartedAt)" + "if(negotiationEnabled){negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch)}" )); assert!(page.body.contains( "negotiationEnabled?'tproxy-auto-v1.':'tproxy-v1.'" )); } + +#[test] +fn retry_and_attempt_state_are_frozen_before_fetch() { + let page = render_page( + "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE", + 4, + ); + assert!(page.body.contains("async function request(path,frozenOptions)")); + assert!(!page.body.contains("makeOptions")); + assert!( + page.body + .contains("if(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)" + )); +} + +#[test] +fn ambiguous_commit_is_resolved_before_carrier_advance() { + let page = render_page( + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + 4, + ); + assert!(page.body.contains("resolveAttempt(reason,epoch,snapshot)")); + assert!(page.body.contains( + "sessionEcho(response,snapshot.attempt,['provisional','committed','healthy'],true)" + )); + assert!(page.body.contains("if(echo.state!=='provisional'){switching=false;fail();return}")); + assert!(page.body.contains("const token=cleanupToken||sessionToken")); +} diff --git a/src/web/http.rs b/src/web/http.rs index c118213..1472026 100644 --- a/src/web/http.rs +++ b/src/web/http.rs @@ -50,7 +50,8 @@ use decoy::serve_decoy; use down::handle_down; use request::{ bearer_token_hash, binary_content_type, bridge_candidate, canonical_request_host, - canonical_u64_header, carrier_request, client_ip, compatible_cookie_header, match_profile, + canonical_u64_header, carrier_ip_learning_eligible, carrier_request, client_ip, + compatible_cookie_header, match_profile, }; use response::{ bad_gateway, carrier_empty, carrier_headers, carrier_lane, full_response, generic_not_found, @@ -358,6 +359,7 @@ async fn handle_session( let Some(carrier_request) = carrier_request(&request, &vhost.host) else { return serve_decoy(request, vhost, true, &runtime).await; }; + let ip_learning_eligible = carrier_ip_learning_eligible(&request, client_ip); let Some((trace_session_id, profile)) = runtime.bootstrap_trace_identity(token_hash, &vhost.host) else { @@ -391,6 +393,7 @@ async fn handle_session( client_ip, &body, carrier_request, + ip_learning_eligible, ) { Ok(result) => { let welcome = frame::encode(FrameType::Welcome, 0, &[]); @@ -424,6 +427,60 @@ async fn handle_session( &attempt.to_string(), ); } + if let Some(candidate_count) = result.candidate_count { + insert_header( + &mut response, + HeaderName::from_static("x-carrier-candidate-count"), + &candidate_count.to_string(), + ); + insert_header( + &mut response, + HeaderName::from_static("x-carrier-deadline"), + &result.deadline_secs.unwrap_or_default().to_string(), + ); + if let Some(state) = result.carrier_state { + insert_header( + &mut response, + HeaderName::from_static("x-carrier-state"), + state, + ); + } + } + response + } + Err(ManagerError::Committed) => { + let mut response = carrier_empty(StatusCode::CONFLICT); + if let Some(echo) = runtime.carrier_echo( + token_hash, + &vhost.host, + client_ip, + carrier_request, + ) { + response.headers_mut().insert( + HeaderName::from_static("x-carrier-mode"), + HeaderValue::from_static(echo.carrier.as_str()), + ); + insert_header( + &mut response, + HeaderName::from_static("x-carrier-attempt"), + &echo.attempt.to_string(), + ); + insert_header( + &mut response, + HeaderName::from_static("x-carrier-candidate-count"), + &echo.candidate_count.to_string(), + ); + insert_header( + &mut response, + HeaderName::from_static("x-carrier-deadline"), + &echo.deadline_secs.to_string(), + ); + insert_header( + &mut response, + HeaderName::from_static("x-carrier-state"), + echo.state, + ); + } response } Err( @@ -479,12 +536,7 @@ async fn handle_up( let Some(lane_id) = carrier_lane(&request, session.carrier()) else { return serve_decoy(request, vhost, true, &runtime).await; }; - let limit = runtime - .active_generation() - .config() - .web - .limits - .max_body_bytes; + let limit = session.limits().max_body_bytes; let CollectedBody { request, body, @@ -500,7 +552,7 @@ async fn handle_up( trace.record_frames( TraceDirection::Request, &body, - &runtime.active_generation().config().web.limits, + session.limits(), ); } let result = match lane_id { diff --git a/src/web/http/down.rs b/src/web/http/down.rs index 7e45ec4..a60fb44 100644 --- a/src/web/http/down.rs +++ b/src/web/http/down.rs @@ -56,9 +56,11 @@ pub(super) async fn handle_down( if !body.is_empty() { return serve_decoy(request, vhost, true, &runtime).await; } - let _lane_poll = if lane_id.is_some() { - let auxiliary = lane_id.is_some_and(|lane_id| session.lane_poll_is_auxiliary(lane_id)); - let Some(permit) = runtime.try_lane_poll(auxiliary) else { + let Some(_down_poll) = runtime.try_lane_poll(false) else { + return service_unavailable(); + }; + let _control_lane_poll = if lane_id == Some(0) { + let Some(permit) = runtime.try_lane_poll(true) else { return service_unavailable(); }; Some(permit) @@ -90,7 +92,7 @@ pub(super) async fn handle_down( trace.record_frames( TraceDirection::Response, &result.body, - &runtime.active_generation().config().web.limits, + session.limits(), ); } let mut response = full_response(StatusCode::OK, result.body); diff --git a/src/web/http/negotiation_tests.rs b/src/web/http/negotiation_tests.rs index a534f5b..0c9df21 100644 --- a/src/web/http/negotiation_tests.rs +++ b/src/web/http/negotiation_tests.rs @@ -1,5 +1,7 @@ use super::*; +use std::time::Duration; + use sha2::{Digest, Sha256}; const CAPABILITIES: &str = "https,https-lanes,websocket,websocket-lanes"; @@ -95,7 +97,7 @@ async fn absent_carriers_reject_negotiation_and_preserve_legacy_creation() { #[tokio::test] async fn negotiation_replays_replaces_and_freezes_after_carrier_commit() { let capability = [42; 32]; - let config = negotiation_runtime_config( + let mut config = negotiation_runtime_config( capability, WebCarrier::Websocket, false, @@ -105,6 +107,7 @@ async fn negotiation_replays_replaces_and_freezes_after_carrier_commit() { WebCarrier::Websocket, ]), ); + config.web.timeouts.long_poll_secs = 1; let generation = test_runtime_generation(1, config); let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation)))); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -121,6 +124,8 @@ async fn negotiation_replays_replaces_and_freezes_after_carrier_commit() { let replay = request(&listener, &runtime, first_request).await; let (replay_headers, _) = split_response(&replay); assert_eq!(response_header(replay_headers, "x-session-token"), first_token); + assert_eq!(response_header(replay_headers, "x-carrier-candidate-count"), "3"); + assert_eq!(response_header(replay_headers, "x-carrier-state"), "provisional"); let second_request = create_request(&bootstrap, &hello, Some(2), Some("timeout")); let second = request(&listener, &runtime, second_request.clone()).await; @@ -140,20 +145,59 @@ async fn negotiation_replays_replaces_and_freezes_after_carrier_commit() { let second_replay = request(&listener, &runtime, second_request).await; let (second_replay_headers, _) = split_response(&second_replay); - assert_eq!( - response_header(second_replay_headers, "x-session-token"), - second_token - ); + for header in [ + "x-session-token", + "x-carrier-mode", + "x-carrier-attempt", + "x-carrier-candidate-count", + "x-carrier-deadline", + "x-carrier-state", + ] { + assert_eq!( + response_header(second_replay_headers, header), + response_header(second_headers, header), + ); + } + + let changed_failure = request( + &listener, + &runtime, + create_request(&bootstrap, &hello, Some(2), Some("network")), + ) + .await; + let (changed_failure_headers, _) = split_response(&changed_failure); + assert!(optional_response_header(changed_failure_headers, "x-session-token").is_none()); let open = frame::encode(FrameType::Open, 7, &[]); + let data = frame::encode(FrameType::Data, 7, &[0]); + let mut body = Vec::with_capacity(open.len() + data.len()); + body.extend_from_slice(&open); + body.extend_from_slice(&data); let mut uplink = format!( "POST /api/v1/up HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {second_token}\r\nContent-Type: application/octet-stream\r\nX-Up-Seq: 1\r\nX-Lane-ID: 7\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - open.len() + body.len() ) .into_bytes(); - uplink.extend_from_slice(&open); - let committed = request(&listener, &runtime, uplink).await; - assert!(committed.starts_with(b"HTTP/1.1 204")); + uplink.extend_from_slice(&body); + let accepted = request(&listener, &runtime, uplink).await; + assert!(accepted.starts_with(b"HTTP/1.1 204")); + + let first_down = format!( + "POST /api/v1/down HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {second_token}\r\nX-Down-Cursor: 0\r\nX-Lane-ID: 7\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .into_bytes(); + let first_down = request(&listener, &runtime, first_down).await; + let (first_down_headers, first_down_body) = split_response(&first_down); + assert!(first_down_headers.starts_with(b"HTTP/1.1 200")); + assert!(!first_down_body.is_empty()); + assert_eq!(response_header(first_down_headers, "x-down-cursor"), "1"); + + let acknowledgement = format!( + "POST /api/v1/down HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {second_token}\r\nX-Down-Cursor: 1\r\nX-Lane-ID: 7\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .into_bytes(); + let acknowledgement = request(&listener, &runtime, acknowledgement).await; + assert!(acknowledgement.starts_with(b"HTTP/1.1 204")); let third = request( &listener, @@ -162,7 +206,13 @@ async fn negotiation_replays_replaces_and_freezes_after_carrier_commit() { ) .await; let (third_headers, _) = split_response(&third); + assert!(third_headers.starts_with(b"HTTP/1.1 409")); assert!(optional_response_header(third_headers, "x-session-token").is_none()); + assert_eq!(response_header(third_headers, "x-carrier-mode"), "https-lanes"); + assert_eq!(response_header(third_headers, "x-carrier-attempt"), "2"); + assert_eq!(response_header(third_headers, "x-carrier-candidate-count"), "3"); + assert_eq!(response_header(third_headers, "x-carrier-deadline"), "12"); + assert_eq!(response_header(third_headers, "x-carrier-state"), "committed"); assert!( runtime .get_session(token_hash(&second_token), "proxy.example.com") @@ -174,3 +224,69 @@ async fn negotiation_replays_replaces_and_freezes_after_carrier_commit() { generation.stop_sessions().await; generation.stop_background_tasks().await; } + +#[tokio::test] +async fn https_lane_downlink_can_arrive_before_its_uplink_open() { + let capability = [43; 32]; + let mut config = negotiation_runtime_config( + capability, + WebCarrier::HttpsLanes, + false, + Arc::from([WebCarrier::HttpsLanes]), + ); + config.web.timeouts.lane_open_wait_secs = 1; + config.web.timeouts.long_poll_secs = 2; + let generation = test_runtime_generation(1, config); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation)))); + let listener = Arc::new(TcpListener::bind("127.0.0.1:0").await.unwrap()); + let bootstrap = issue_bootstrap(&runtime, "192.0.2.10"); + let hello = frame::encode(FrameType::Hello, 0, &[1]); + let created = request( + &listener, + &runtime, + create_request(&bootstrap, &hello, Some(1), None), + ) + .await; + let (created_headers, _) = split_response(&created); + let token = response_header(created_headers, "x-session-token").to_string(); + + let down_request = format!( + "POST /api/v1/down HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {token}\r\nX-Down-Cursor: 0\r\nX-Lane-ID: 7\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .into_bytes(); + let down_listener = Arc::clone(&listener); + let down_runtime = Arc::clone(&runtime); + let down = tokio::spawn(async move { + request(&down_listener, &down_runtime, down_request).await + }); + tokio::task::yield_now().await; + + let open = frame::encode(FrameType::Open, 7, &[]); + let data = frame::encode(FrameType::Data, 7, &[1]); + let mut body = Vec::with_capacity(open.len() + data.len()); + body.extend_from_slice(&open); + body.extend_from_slice(&data); + let mut uplink = format!( + "POST /api/v1/up HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {token}\r\nContent-Type: application/octet-stream\r\nX-Up-Seq: 1\r\nX-Lane-ID: 7\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .into_bytes(); + uplink.extend_from_slice(&body); + let accepted = request(&listener, &runtime, uplink).await; + assert!(accepted.starts_with(b"HTTP/1.1 204")); + + let down = tokio::time::timeout(Duration::from_secs(3), down) + .await + .unwrap() + .unwrap(); + let (down_headers, _) = split_response(&down); + assert!( + down_headers.starts_with(b"HTTP/1.1 200") + || down_headers.starts_with(b"HTTP/1.1 204") + ); + assert!(optional_response_header(down_headers, "x-down-cursor").is_some()); + + runtime.shutdown().await; + generation.stop_sessions().await; + generation.stop_background_tasks().await; +} diff --git a/src/web/http/request.rs b/src/web/http/request.rs index 5480fd7..75d34f6 100644 --- a/src/web/http/request.rs +++ b/src/web/http/request.rs @@ -1,69 +1,23 @@ -use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; use base64::Engine as _; use hyper::Request; use hyper::header; -use ipnetwork::IpNetwork; use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; -use crate::config::{WebClientIpSource, WebRuntimeProfile, WebRuntimeVhost}; +use crate::config::{WebRuntimeProfile, WebRuntimeVhost}; use crate::web::manager::{ CarrierCapabilities, CarrierClientClass, CarrierFailure, CarrierRequest, TokenHash, }; const USER_AGENT_CONTEXT: &[u8] = b"telemt-web-carrier-user-agent-v1\0"; -/// Parses one lowercase canonical Host value restricted to the public HTTPS port. -pub(super) fn canonical_request_host(request: &Request) -> Option<&str> { - let values = request.headers().get_all(header::HOST); - let mut values = values.iter(); - let value = values.next()?.to_str().ok()?; - if values.next().is_some() { - return None; - } - let authority = value.parse::().ok()?; - if authority.port_u16().is_some_and(|port| port != 443) { - return None; - } - let host = value.strip_suffix(":443").unwrap_or(value); - if authority.host() != host || host.bytes().any(|byte| byte.is_ascii_uppercase()) { - return None; - } - Some(host) -} - -/// Accepts one forwarded client address or the direct address of a trusted peer. -pub(super) fn client_ip( - request: &Request, - peer: SocketAddr, - source: WebClientIpSource, - trusted_proxy_cidrs: &[IpNetwork], -) -> Option { - if !trusted_proxy_cidrs - .iter() - .any(|network| network.contains(peer.ip())) - { - return None; - } - let header_name = match source { - WebClientIpSource::XForwardedFor => "x-forwarded-for", - }; - let values = request.headers().get_all(header_name); - let mut values = values.iter(); - let Some(value) = values.next() else { - return Some(peer.ip()); - }; - let value = value.to_str().ok()?; - if values.next().is_some() || value.trim() != value || value.contains(',') { - return None; - } - if value.is_empty() { - return Some(peer.ip()); - } - value.parse::().ok() -} +// Canonical host and forwarded-address provenance remain isolated from credentials. +mod identity; +pub(super) use identity::{ + canonical_request_host, carrier_ip_learning_eligible, client_ip, +}; /// Decodes an exact canonical bridge query without allocating credential strings. pub(super) fn bridge_candidate(query: Option<&str>) -> ([u8; 32], bool) { @@ -177,16 +131,31 @@ pub(super) fn carrier_request(request: &Request, host: &str) -> Option Some(CarrierRequest::legacy(user_agent_hash)), + (None, None) if failure.is_none() => { + if native_ios { + Some(CarrierRequest::ios(user_agent_hash)) + } else { + Some(CarrierRequest::legacy(user_agent_hash)) + } + } (Some(capabilities), Some(attempt)) => { let capabilities = parse_capabilities(capabilities)?; if (attempt == 1) != failure.is_none() { return None; } Some(CarrierRequest::automatic( - CarrierClientClass::Bridge, - capabilities, + if native_ios { + CarrierClientClass::Ios + } else { + CarrierClientClass::Bridge + }, + if native_ios { + CarrierCapabilities::ios() + } else { + capabilities + }, attempt, failure, user_agent_hash, @@ -197,8 +166,16 @@ pub(super) fn carrier_request(request: &Request, host: &str) -> Option(request: &Request, host: &str) -> Option(request: &Request) -> bool { + single_header(request, header::USER_AGENT).is_some_and(|value| { + let value = value.to_ascii_lowercase(); + value.contains("cfnetwork/") && value.contains("darwin/") + }) +} + fn parse_capabilities(value: &str) -> Option { let mut bits = 0u8; let mut previous = None; @@ -292,6 +276,9 @@ fn single_header(request: &Request, name: impl header::AsHeaderName) -> Op #[cfg(test)] mod tests { use super::*; + use ipnetwork::IpNetwork; + + use crate::config::{WebCarrier, WebClientIpSource}; #[test] fn canonical_bridge_query_rejects_aliases() { @@ -485,6 +472,46 @@ mod tests { assert!(carrier_request(&reordered, "proxy.example.com").is_none()); } + #[test] + fn native_ios_capability_claims_cannot_enable_parallel_carriers() { + let request = Request::builder() + .header( + "x-carrier-capabilities", + "https,https-lanes,websocket,websocket-lanes", + ) + .header("x-carrier-attempt", "1") + .header( + header::USER_AGENT, + "Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0", + ) + .body(()) + .unwrap(); + let parsed = carrier_request(&request, "proxy.example.com").unwrap(); + assert_eq!(parsed.class(), CarrierClientClass::Ios); + assert!(parsed.supports(WebCarrier::Https)); + assert!(!parsed.supports(WebCarrier::HttpsLanes)); + assert!(!parsed.supports(WebCarrier::Websocket)); + assert!(!parsed.supports(WebCarrier::WebsocketLanes)); + } + + #[test] + fn mapped_private_addresses_are_not_learning_evidence() { + for address in ["::ffff:127.0.0.1", "::ffff:10.0.0.1"] { + let effective_ip = address.parse().unwrap(); + let request = Request::builder() + .header("x-forwarded-for", address) + .body(()) + .unwrap(); + assert!(!carrier_ip_learning_eligible(&request, effective_ip)); + } + let effective_ip = "::ffff:8.8.8.8".parse().unwrap(); + let request = Request::builder() + .header("x-forwarded-for", "::ffff:8.8.8.8") + .body(()) + .unwrap(); + assert!(carrier_ip_learning_eligible(&request, effective_ip)); + } + #[test] fn strict_browser_metadata_recovers_a_stripped_capability_marker() { let request = Request::builder() diff --git a/src/web/http/request/identity.rs b/src/web/http/request/identity.rs new file mode 100644 index 0000000..822946b --- /dev/null +++ b/src/web/http/request/identity.rs @@ -0,0 +1,111 @@ +use std::net::{IpAddr, SocketAddr}; + +use hyper::Request; +use hyper::header; +use ipnetwork::IpNetwork; + +use crate::config::WebClientIpSource; + +/// Parses one lowercase canonical Host value restricted to the public HTTPS port. +pub(in crate::web::http) fn canonical_request_host(request: &Request) -> Option<&str> { + let values = request.headers().get_all(header::HOST); + let mut values = values.iter(); + let value = values.next()?.to_str().ok()?; + if values.next().is_some() { + return None; + } + let authority = value.parse::().ok()?; + if authority.port_u16().is_some_and(|port| port != 443) { + return None; + } + let host = value.strip_suffix(":443").unwrap_or(value); + if authority.host() != host || host.bytes().any(|byte| byte.is_ascii_uppercase()) { + return None; + } + Some(host) +} + +/// Accepts one forwarded client address or the direct address of a trusted peer. +pub(in crate::web::http) fn client_ip( + request: &Request, + peer: SocketAddr, + source: WebClientIpSource, + trusted_proxy_cidrs: &[IpNetwork], +) -> Option { + if !trusted_proxy_cidrs + .iter() + .any(|network| network.contains(peer.ip())) + { + return None; + } + let header_name = match source { + WebClientIpSource::XForwardedFor => "x-forwarded-for", + }; + let values = request.headers().get_all(header_name); + let mut values = values.iter(); + let Some(value) = values.next() else { + return Some(peer.ip()); + }; + let value = value.to_str().ok()?; + if values.next().is_some() || value.trim() != value || value.contains(',') { + return None; + } + if value.is_empty() { + return Some(peer.ip()); + } + value.parse::().ok() +} + +/// Allows IP learning only for one explicit globally routable forwarded address. +pub(in crate::web::http) fn carrier_ip_learning_eligible( + request: &Request, + effective_ip: IpAddr, +) -> bool { + let mut values = request.headers().get_all("x-forwarded-for").iter(); + let Some(value) = values.next().and_then(|value| value.to_str().ok()) else { + return false; + }; + if values.next().is_some() + || value.trim() != value + || value.contains(',') + || value.parse::().ok() != Some(effective_ip) + { + return false; + } + globally_routable(effective_ip) +} + +fn globally_routable(address: IpAddr) -> bool { + match address { + IpAddr::V4(address) => globally_routable_v4(address), + IpAddr::V6(address) => { + if let Some(address) = address.to_ipv4_mapped() { + return globally_routable_v4(address); + } + let segments = address.segments(); + !address.is_unspecified() + && !address.is_loopback() + && segments[0] & 0xfe00 != 0xfc00 + && segments[0] & 0xffc0 != 0xfe80 + && segments[0] & 0xff00 != 0xff00 + && !(segments[0] == 0x2001 && segments[1] == 0x0db8) + } + } +} + +fn globally_routable_v4(address: std::net::Ipv4Addr) -> bool { + let [a, b, c, _] = address.octets(); + !(a == 0 + || a == 10 + || a == 127 + || (a == 100 && (64..=127).contains(&b)) + || (a == 169 && b == 254) + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 0 && c == 0) + || (a == 192 && b == 0 && c == 2) + || (a == 192 && b == 168) + || (a == 198 && (b == 18 || b == 19)) + || (a == 198 && b == 51 && c == 100) + || (a == 203 && b == 0 && c == 113) + || a >= 224) +} diff --git a/src/web/http/websocket.rs b/src/web/http/websocket.rs index df90792..ff0fea6 100644 --- a/src/web/http/websocket.rs +++ b/src/web/http/websocket.rs @@ -272,6 +272,10 @@ pub(super) async fn handle( (ParsedCarrier::Lane(lane_id), WebCarrier::WebsocketLanes) => WebSocketKind::Lane(lane_id), _ => return serve_decoy(request, vhost, true, &runtime).await, }; + let mut probe_reservation = match session.reserve_websocket_probe(parsed.acknowledge_commit) { + Ok(reservation) => reservation, + Err(_) => return serve_decoy(request, vhost, true, &runtime).await, + }; let mut lane_reservation = match kind { WebSocketKind::Multiplex => None, WebSocketKind::Lane(lane_id) => match session.reserve_websocket_lane(lane_id) { @@ -279,7 +283,7 @@ pub(super) async fn handle( Err(_) => return serve_decoy(request, vhost, true, &runtime).await, }, }; - let timeouts = runtime.active_generation().config().web.timeouts.clone(); + let timeouts = session.timeouts().clone(); let connection = match runtime .admit_websocket( session.profile_key(), @@ -297,6 +301,11 @@ pub(super) async fn handle( Ok(connection) => connection, Err(_) => return serve_decoy(request, vhost, true, &runtime).await, }; + if let Some(reservation) = probe_reservation.as_mut() + && reservation.bind(connection.id()).is_err() + { + return serve_decoy(request, vhost, true, &runtime).await; + } let trace_context = runtime.trace().websocket_context( &request, peer.ip(), @@ -326,6 +335,7 @@ pub(super) async fn handle( driver_session, connection, lane_reservation.take(), + probe_reservation.take(), trace_context, parsed.acknowledge_commit, ) diff --git a/src/web/http/websocket/driver.rs b/src/web/http/websocket/driver.rs index 989ceab..2d4e918 100644 --- a/src/web/http/websocket/driver.rs +++ b/src/web/http/websocket/driver.rs @@ -11,7 +11,9 @@ use super::ConnectionIo; use crate::web::manager::{ WebProcessRuntime, WebSocketBudgetLease, WebSocketConnection, }; -use crate::web::session::{WebSession, WebSocketLaneReservation}; +use crate::web::session::{ + WebSession, WebSocketLaneReservation, WebSocketProbeReservation, +}; use crate::web::trace::{TraceDirection, TraceWebSocketContext}; const READ_BUFFER_BYTES: usize = 64 * 1024; @@ -27,11 +29,12 @@ pub(super) async fn run_upgraded( session: Arc, connection: WebSocketConnection, mut lane_reservation: Option, + _probe_reservation: Option, trace: Option, acknowledge_commit: bool, ) { let cancellation = connection.cancellation(); - let timeouts = runtime.active_generation().config().web.timeouts.clone(); + let timeouts = session.timeouts().clone(); let upgraded = tokio::select! { _ = cancellation.cancelled() => return, result = tokio::time::timeout( @@ -47,7 +50,7 @@ pub(super) async fn run_upgraded( }; let mut io = parts.io.into_inner(); io.enable_websocket(parts.read_buf); - let limits = runtime.active_generation().config().web.limits.clone(); + let limits = session.limits().clone(); let config = WebSocketConfig::default() .read_buffer_size(READ_BUFFER_BYTES) .write_buffer_size(WRITE_BUFFER_BYTES) @@ -59,7 +62,9 @@ pub(super) async fn run_upgraded( .max_message_size(Some(limits.carrier_batch_bytes)) .max_frame_size(Some(limits.carrier_batch_bytes)); let mut socket = WebSocketStream::from_raw_socket(io, Role::Server, Some(config)).await; - connection.mark_opened(); + if !connection.mark_opened() { + return; + } if let Some(reservation) = lane_reservation.as_mut() { let _ = run_lane( &mut socket, @@ -84,16 +89,11 @@ pub(super) async fn run_upgraded( ) .await; } - let eviction = Duration::from_secs( - runtime - .active_generation() - .config() - .web - .timeouts - .websocket_eviction_secs, - ); - if !cancellation.is_cancelled() { - let _ = tokio::time::timeout(eviction, socket.close(None)).await; + let eviction = Duration::from_secs(timeouts.websocket_eviction_secs); + tokio::select! { + biased; + _ = cancellation.cancelled() => {} + _ = tokio::time::timeout(eviction, socket.close(None)) => {} } if let Some(reservation) = lane_reservation { session.close_websocket_lane(reservation.lane_id()); @@ -121,15 +121,12 @@ async fn run_multiplex( let mut read_budget = None; let liveness_interval = connection.liveness_interval(); let mut next_ping = Instant::now() + liveness_interval; - let open_deadline = Instant::now() - + Duration::from_secs( - runtime - .active_generation() - .config() - .web - .timeouts - .websocket_open_secs, - ); + let open_deadline = + Instant::now() + Duration::from_secs(session.timeouts().websocket_open_secs); + let backpressure_timeout = + Duration::from_secs(session.timeouts().websocket_backpressure_secs); + let write_timeout = Duration::from_secs(session.timeouts().websocket_write_secs); + let maximum_message = session.limits().carrier_batch_bytes; let mut active = false; loop { let down = session.poll_down(cursor); @@ -144,6 +141,8 @@ async fn run_multiplex( session.profile_key(), &cancellation, &mut read_budget, + maximum_message, + backpressure_timeout, ) => { DriverEvent::Incoming(incoming?) } @@ -153,8 +152,15 @@ async fn run_multiplex( DriverEvent::Incoming((message, _budget)) => match message { Message::Binary(body) => { let started = Instant::now(); - let result = - process_multiplex(runtime, session, sequence, &body, &cancellation).await; + let result = process_multiplex( + runtime, + session, + sequence, + &body, + &cancellation, + backpressure_timeout, + ) + .await; record_message( runtime, trace, @@ -163,14 +169,17 @@ async fn run_multiplex( &body, started, ); - result?; - if acknowledge_commit && sequence == 1 && session.is_carrier_committed() { + let progressed = result?; + if acknowledge_commit && sequence == 1 { + if !session.needs_websocket_commit_ack(connection.id()) { + return Err(()); + } let started = Instant::now(); send( socket, - runtime, Message::Binary(Bytes::new()), &cancellation, + write_timeout, ) .await?; record_message( @@ -181,9 +190,19 @@ async fn run_multiplex( &[], started, ); + if !session.websocket_commit_ack_written(connection.id()) { + session.close(); + return Err(()); + } + } else if acknowledge_commit && sequence > 1 && progressed { + if !session.websocket_peer_after_commit_ack(connection.id()) { + return Err(()); + } } - if !active { - connection.mark_active(); + if !active && progressed { + if !connection.mark_active() { + return Err(()); + } active = true; } sequence = sequence.checked_add(1).ok_or(())?; @@ -204,7 +223,7 @@ async fn run_multiplex( } Message::Ping(payload) => { let started = Instant::now(); - flush(socket, runtime, &cancellation).await?; + flush(socket, &cancellation, write_timeout).await?; record_message( runtime, trace, @@ -251,7 +270,13 @@ async fn run_multiplex( DriverEvent::Down(result) => { if result.body.is_empty() { let started = Instant::now(); - send(socket, runtime, Message::Ping(Bytes::new()), &cancellation).await?; + send( + socket, + Message::Ping(Bytes::new()), + &cancellation, + write_timeout, + ) + .await?; record_message( runtime, trace, @@ -267,12 +292,19 @@ async fn run_multiplex( session.profile_key(), result.body.len(), &cancellation, + backpressure_timeout, ) .await?; let body = result.body; let started = Instant::now(); if trace.is_some() { - send(socket, runtime, Message::Binary(body.clone()), &cancellation).await?; + send( + socket, + Message::Binary(body.clone()), + &cancellation, + write_timeout, + ) + .await?; record_message( runtime, trace, @@ -282,7 +314,13 @@ async fn run_multiplex( started, ); } else { - send(socket, runtime, Message::Binary(body), &cancellation).await?; + send( + socket, + Message::Binary(body), + &cancellation, + write_timeout, + ) + .await?; } connection.mark_progress(); } @@ -290,7 +328,13 @@ async fn run_multiplex( } DriverEvent::Liveness => { let started = Instant::now(); - send(socket, runtime, Message::Ping(Bytes::new()), &cancellation).await?; + send( + socket, + Message::Ping(Bytes::new()), + &cancellation, + write_timeout, + ) + .await?; record_message( runtime, trace, @@ -322,15 +366,12 @@ async fn run_lane( let mut read_budget = None; let liveness_interval = connection.liveness_interval(); let mut next_ping = Instant::now() + liveness_interval; - let open_deadline = Instant::now() - + Duration::from_secs( - runtime - .active_generation() - .config() - .web - .timeouts - .websocket_open_secs, - ); + let open_deadline = + Instant::now() + Duration::from_secs(session.timeouts().websocket_open_secs); + let backpressure_timeout = + Duration::from_secs(session.timeouts().websocket_backpressure_secs); + let write_timeout = Duration::from_secs(session.timeouts().websocket_write_secs); + let maximum_message = session.limits().carrier_batch_bytes; let mut active = false; loop { let down = session.poll_down_lane(reservation.lane_id(), cursor); @@ -345,6 +386,8 @@ async fn run_lane( session.profile_key(), &cancellation, &mut read_budget, + maximum_message, + backpressure_timeout, ) => { DriverEvent::Incoming(incoming?) } @@ -361,6 +404,7 @@ async fn run_lane( sequence, &body, &cancellation, + backpressure_timeout, ) .await; record_message( @@ -371,14 +415,17 @@ async fn run_lane( &body, started, ); - result?; - if acknowledge_commit && sequence == 1 && session.is_carrier_committed() { + let progressed = result?; + if acknowledge_commit && sequence == 1 { + if !session.needs_websocket_commit_ack(connection.id()) { + return Err(()); + } let started = Instant::now(); if send( socket, - runtime, Message::Binary(Bytes::new()), &cancellation, + write_timeout, ) .await .is_err() @@ -394,9 +441,19 @@ async fn run_lane( &[], started, ); + if !session.websocket_commit_ack_written(connection.id()) { + session.close(); + return Err(()); + } + } else if acknowledge_commit && sequence > 1 && progressed { + if !session.websocket_peer_after_commit_ack(connection.id()) { + return Err(()); + } } - if !active { - connection.mark_active(); + if !active && progressed { + if !connection.mark_active() { + return Err(()); + } active = true; } sequence = sequence.checked_add(1).ok_or(())?; @@ -417,7 +474,7 @@ async fn run_lane( } Message::Ping(payload) => { let started = Instant::now(); - flush(socket, runtime, &cancellation).await?; + flush(socket, &cancellation, write_timeout).await?; record_message( runtime, trace, @@ -467,7 +524,13 @@ async fn run_lane( } if result.body.is_empty() { let started = Instant::now(); - send(socket, runtime, Message::Ping(Bytes::new()), &cancellation).await?; + send( + socket, + Message::Ping(Bytes::new()), + &cancellation, + write_timeout, + ) + .await?; record_message( runtime, trace, @@ -483,12 +546,19 @@ async fn run_lane( session.profile_key(), result.body.len(), &cancellation, + backpressure_timeout, ) .await?; let body = result.body; let started = Instant::now(); if trace.is_some() { - send(socket, runtime, Message::Binary(body.clone()), &cancellation).await?; + send( + socket, + Message::Binary(body.clone()), + &cancellation, + write_timeout, + ) + .await?; record_message( runtime, trace, @@ -498,7 +568,13 @@ async fn run_lane( started, ); } else { - send(socket, runtime, Message::Binary(body), &cancellation).await?; + send( + socket, + Message::Binary(body), + &cancellation, + write_timeout, + ) + .await?; } connection.mark_progress(); } @@ -506,7 +582,13 @@ async fn run_lane( } DriverEvent::Liveness => { let started = Instant::now(); - send(socket, runtime, Message::Ping(Bytes::new()), &cancellation).await?; + send( + socket, + Message::Ping(Bytes::new()), + &cancellation, + write_timeout, + ) + .await?; record_message( runtime, trace, diff --git a/src/web/http/websocket/driver/io.rs b/src/web/http/websocket/driver/io.rs index ddc4941..132c040 100644 --- a/src/web/http/websocket/driver/io.rs +++ b/src/web/http/websocket/driver/io.rs @@ -16,19 +16,24 @@ pub(super) async fn read_message( owner: crate::web::manager::ProfileKey, cancellation: &CancellationToken, retained_budget: &mut Option, + maximum: usize, + backpressure_timeout: Duration, ) -> Result<(Message, Option), ()> { tokio::select! { _ = cancellation.cancelled() => return Err(()), ready = socket.get_ref().readable() => ready.map_err(|_| ())?, } if retained_budget.is_none() { - let maximum = runtime - .active_generation() - .config() - .web - .limits - .carrier_batch_bytes; - *retained_budget = Some(reserve_data(runtime, owner, maximum, cancellation).await?); + *retained_budget = Some( + reserve_data( + runtime, + owner, + maximum, + cancellation, + backpressure_timeout, + ) + .await?, + ); } let message = tokio::select! { _ = cancellation.cancelled() => return Err(()), @@ -47,17 +52,13 @@ pub(super) async fn reserve_data( owner: crate::web::manager::ProfileKey, bytes: usize, cancellation: &CancellationToken, + timeout: Duration, ) -> Result { - let timeout = Duration::from_secs( - runtime - .active_generation() - .config() - .web - .timeouts - .websocket_backpressure_secs, - ); tokio::time::timeout(timeout, async { loop { + if cancellation.is_cancelled() { + return Err(()); + } let notify = runtime.budget_notify(); let notified = notify.notified(); if let Some(budget) = runtime.try_websocket_data_budget(owner, bytes.max(1)) { @@ -79,9 +80,10 @@ pub(super) async fn process_multiplex( sequence: u64, body: &[u8], cancellation: &CancellationToken, -) -> Result<(), ()> { - retry_backpressure(runtime, cancellation, || { - session.process_up(sequence, body).map(|_| ()) + timeout: Duration, +) -> Result { + retry_backpressure(runtime, cancellation, timeout, || { + session.process_websocket_multiplex(sequence, body) }) .await } @@ -93,21 +95,17 @@ pub(super) async fn process_lane( sequence: u64, body: &[u8], cancellation: &CancellationToken, -) -> Result<(), ()> { - let timeout = Duration::from_secs( - runtime - .active_generation() - .config() - .web - .timeouts - .websocket_backpressure_secs, - ); + timeout: Duration, +) -> Result { tokio::time::timeout(timeout, async { loop { + if cancellation.is_cancelled() { + return Err(()); + } let notify = runtime.budget_notify(); let notified = notify.notified(); match session.process_websocket_lane(reservation, sequence, body) { - Ok(()) => return Ok(()), + Ok(progressed) => return Ok(progressed), Err(ManagerError::Backpressure) => {} Err(_) => return Err(()), } @@ -121,28 +119,24 @@ pub(super) async fn process_lane( .map_err(|_| ())? } -async fn retry_backpressure( +async fn retry_backpressure( runtime: &Arc, cancellation: &CancellationToken, + timeout: Duration, mut operation: F, -) -> Result<(), ()> +) -> Result where - F: FnMut() -> Result<(), ManagerError>, + F: FnMut() -> Result, { - let timeout = Duration::from_secs( - runtime - .active_generation() - .config() - .web - .timeouts - .websocket_backpressure_secs, - ); tokio::time::timeout(timeout, async { loop { + if cancellation.is_cancelled() { + return Err(()); + } let notify = runtime.budget_notify(); let notified = notify.notified(); match operation() { - Ok(()) => return Ok(()), + Ok(value) => return Ok(value), Err(ManagerError::Backpressure) => {} Err(_) => return Err(()), } @@ -158,18 +152,10 @@ where pub(super) async fn send( socket: &mut CarrierSocket, - runtime: &WebProcessRuntime, message: Message, cancellation: &CancellationToken, + timeout: Duration, ) -> Result<(), ()> { - let timeout = Duration::from_secs( - runtime - .active_generation() - .config() - .web - .timeouts - .websocket_write_secs, - ); tokio::select! { _ = cancellation.cancelled() => Err(()), result = tokio::time::timeout(timeout, socket.send(message)) => { @@ -180,17 +166,9 @@ pub(super) async fn send( pub(super) async fn flush( socket: &mut CarrierSocket, - runtime: &WebProcessRuntime, cancellation: &CancellationToken, + timeout: Duration, ) -> Result<(), ()> { - let timeout = Duration::from_secs( - runtime - .active_generation() - .config() - .web - .timeouts - .websocket_write_secs, - ); tokio::select! { _ = cancellation.cancelled() => Err(()), result = tokio::time::timeout(timeout, socket.flush()) => { diff --git a/src/web/http/websocket/tests.rs b/src/web/http/websocket/tests.rs index dab6430..fac2c98 100644 --- a/src/web/http/websocket/tests.rs +++ b/src/web/http/websocket/tests.rs @@ -160,6 +160,7 @@ fn create_automatic_session( None, [9; 32], ), + false, ) .unwrap() .token; @@ -194,6 +195,7 @@ fn create_session(runtime: &Arc) -> (String, TokenHash) { client_ip, &hello, CarrierRequest::legacy([0; 32]), + false, ) .unwrap() .token; @@ -382,7 +384,7 @@ async fn malformed_websocket_lane_closes_only_that_lane() { } #[tokio::test] -async fn automatic_websocket_carriers_ack_the_first_committing_message() { +async fn automatic_websocket_carriers_commit_after_acknowledged_peer_progress() { for carrier in [WebCarrier::Websocket, WebCarrier::WebsocketLanes] { let live = live_negotiation_runtime(carrier, Arc::from([carrier])); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -409,6 +411,34 @@ async fn automatic_websocket_carriers_ack_the_first_committing_message() { .unwrap() .is_carrier_committed() ); + socket + .send(Message::Binary(frame::encode( + FrameType::Window, + 7, + &frame::window_payload(1), + ))) + .await + .unwrap(); + socket + .send(Message::Ping(Bytes::from_static(b"commit"))) + .await + .unwrap(); + loop { + let message = tokio::time::timeout(Duration::from_secs(2), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + if message == Message::Pong(Bytes::from_static(b"commit")) { + break; + } + } + assert!( + live.runtime + .get_session(session_hash, "proxy.example.com") + .unwrap() + .is_carrier_committed() + ); let _ = socket.close(None).await; live.shutdown().await; @@ -448,6 +478,7 @@ async fn failed_automatic_multiplex_socket_remains_supersedable() { Some(CarrierFailure::Upgrade), [9; 32], ), + false, ) .unwrap(); assert_eq!(replacement.carrier, WebCarrier::Https); diff --git a/src/web/manager.rs b/src/web/manager.rs index e8ea123..4456304 100644 --- a/src/web/manager.rs +++ b/src/web/manager.rs @@ -19,11 +19,16 @@ mod state; // Carrier attempt metadata remains explicit and independent from HTTP parsing. mod negotiation; // Bounded process-local carrier evidence is isolated from session registries. +#[path = "manager/carrier_learning.rs"] mod learning; // Bootstrap credentials and idempotent session creation are isolated from queue accounting. mod credentials; // First-session admission and bounded carrier replacement share one state machine. mod session_creation; +// Session admission remains separate from stream tuple ownership. +mod session_admission; +// Carrier commit, health, and conflict echoes share one outcome publication path. +mod carrier_outcome; // Stream admission and synthetic tuple ownership are process-scoped. mod admission; // Shutdown and expiry work remain outside request-path coordination. @@ -34,7 +39,7 @@ mod budget; mod websocket; pub(crate) use budget::WebSocketBudgetLease; use budget::{WebDataBudget, WebSocketBudgetClass}; -use state::ManagerState; +use state::{ManagerState, StreamAdmissionState}; pub(crate) use negotiation::{ CarrierCapabilities, CarrierClientClass, CarrierFailure, CarrierLearningContext, CarrierRequest, }; @@ -61,6 +66,8 @@ pub(crate) enum ManagerError { Protocol, /// The operation conflicts with another in-flight operation. Concurrent, + /// An authenticated attempt chain is already committed. + Committed, /// The process or session has stopped accepting work. Closed, } @@ -74,6 +81,7 @@ impl ManagerError { Self::Limit => "limit", Self::Protocol => "protocol", Self::Concurrent => "concurrent", + Self::Committed => "committed", Self::Closed => "closed", } } @@ -87,6 +95,26 @@ pub(crate) struct CreateResult { pub(crate) carrier: WebCarrier, /// One-based carrier attempt echoed only for negotiated sessions. pub(crate) attempt: Option, + /// Effective frozen candidate count on the first automatic response. + pub(crate) candidate_count: Option, + /// Cumulative final chain deadline on the first automatic response. + pub(crate) deadline_secs: Option, + /// Actual attempt-chain phase echoed for automatic sessions. + pub(crate) carrier_state: Option<&'static str>, +} + +/// Authenticated non-secret attempt-chain metadata returned with a conflict. +pub(crate) struct CarrierEcho { + /// Carrier frozen into the current committed attempt. + pub(crate) carrier: WebCarrier, + /// One-based current attempt. + pub(crate) attempt: u8, + /// Frozen supported candidate count. + pub(crate) candidate_count: u8, + /// Frozen cumulative final deadline. + pub(crate) deadline_secs: u64, + /// Actual current chain phase. + pub(crate) state: &'static str, } /// Successful bridge bootstrap issuance result. @@ -103,6 +131,7 @@ pub(crate) struct WebProcessRuntime { trace: Arc, limits: WebLimitsConfig, state: Mutex, + stream_admission: Mutex, learning: Mutex, http_connections: Arc, http_handlers: Arc, @@ -142,8 +171,16 @@ impl WebProcessRuntime { active_runtime: Arc>, trace: Arc, ) -> Arc { - let limits = active_runtime.load().config().web.limits.clone(); + let config = active_runtime.load().config(); + let limits = config.web.limits.clone(); let learning_capacity = limits.max_carrier_learning_entries; + let mut carrier_learning = learning::CarrierLearning::new(learning_capacity); + let _ = carrier_learning.apply_policy( + std::time::Instant::now(), + config.web.carrier_negotiation_enabled() && config.web.carrier_learning, + config.web.carrier_negotiation_aggressiveness, + Duration::from_secs(config.web.timeouts.carrier_learning_secs), + ); let websocket_connections = limits .max_http_connections .saturating_sub(limits.websocket_http_connection_reserve); @@ -155,7 +192,7 @@ impl WebProcessRuntime { http_connections: Arc::new(Semaphore::new(limits.max_http_connections)), http_handlers: Arc::new(Semaphore::new(limits.max_http_handlers)), lane_polls: Arc::new(Semaphore::new( - lane_poll_limit.saturating_sub(lane_aux_poll_limit), + lane_poll_limit, )), lane_aux_polls: Arc::new(Semaphore::new(lane_aux_poll_limit)), body_readers: Arc::new(Semaphore::new(limits.max_body_readers)), @@ -169,7 +206,8 @@ impl WebProcessRuntime { data_budget: WebDataBudget::new(limits.clone()), limits, state: Mutex::new(ManagerState::default()), - learning: Mutex::new(learning::CarrierLearning::new(learning_capacity)), + stream_admission: Mutex::new(StreamAdmissionState::default()), + learning: Mutex::new(carrier_learning), shutdown: CancellationToken::new(), tasks: TaskTracker::new(), sessions_created: AtomicU64::new(0), diff --git a/src/web/manager/admission.rs b/src/web/manager/admission.rs index be94f7e..3ad3988 100644 --- a/src/web/manager/admission.rs +++ b/src/web/manager/admission.rs @@ -15,7 +15,7 @@ impl WebProcessRuntime { public_addr: SocketAddr, ) -> Option { let now = Instant::now(); - let mut state = self.state.lock(); + let mut state = self.stream_admission.lock(); if state.closed || state.streams_live >= self.limits.max_streams_global || state @@ -54,7 +54,7 @@ impl WebProcessRuntime { public_addr: SocketAddr, peer_port: u16, ) { - let mut state = self.state.lock(); + let mut state = self.stream_admission.lock(); if !release_stream_port(&mut state, client_ip, public_addr, peer_port) { return; } diff --git a/src/web/manager/budget.rs b/src/web/manager/budget.rs index af1712f..9012ae8 100644 --- a/src/web/manager/budget.rs +++ b/src/web/manager/budget.rs @@ -216,6 +216,10 @@ impl WebDataBudget { self.pressured.swap(false, Ordering::AcqRel) } + pub(super) fn restore_pressure(&self) { + self.pressured.store(true, Ordering::Release); + } + pub(super) fn owner_usage(&self, owner: ProfileKey) -> usize { self.state .lock() diff --git a/src/web/manager/carrier_learning.rs b/src/web/manager/carrier_learning.rs new file mode 100644 index 0000000..d36b93b --- /dev/null +++ b/src/web/manager/carrier_learning.rs @@ -0,0 +1,469 @@ +use std::collections::{HashMap, VecDeque}; +use std::net::IpAddr; +use std::time::{Duration, Instant}; + +use sha2::{Digest, Sha256}; + +use super::negotiation::{CarrierClientClass, CarrierLearningContext}; +use super::ProfileKey; +use crate::config::{WebCarrier, WebCarrierNegotiationAggressiveness}; + +const PROFILE_WEIGHT: i16 = 32; +const USER_AGENT_WEIGHT: i16 = 32; +const IP_WEIGHT: i16 = 1; +const SCORE_MIN: i8 = -8; +const SCORE_MAX: i8 = 8; +const MAX_COHORTS: usize = 4; +const PRUNE_ENTRIES_PER_TICK: usize = 64; +const COHORT_CONTEXT: &[u8] = b"telemt-web-carrier-cohort-v1\0"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum EvidenceKey { + Profile(ProfileKey), + UserAgent(ProfileKey, CarrierClientClass, [u8; 32]), + Ip(ProfileKey, IpAddr), +} + +#[derive(Clone, Copy, Default)] +struct Bucket { + slot: u64, + valid: bool, + scores: [i8; 4], + outcomes: u8, + cohorts: [Option<[u8; 32]>; MAX_COHORTS], +} + +impl Bucket { + fn reset(&mut self, slot: u64) { + *self = Self { + slot, + valid: true, + ..Self::default() + }; + } + + fn update(&mut self, deltas: [i8; 4], cohort: Option<[u8; 32]>) { + for (score, delta) in self.scores.iter_mut().zip(deltas) { + *score = score.saturating_add(delta).clamp(SCORE_MIN, SCORE_MAX); + } + self.outcomes = self.outcomes.saturating_add(1); + if let Some(cohort) = cohort + && !self.cohorts.contains(&Some(cohort)) + && let Some(slot) = self.cohorts.iter_mut().find(|slot| slot.is_none()) + { + *slot = Some(cohort); + } + } +} + +struct Evidence { + insertion_sequence: u64, + buckets: [Bucket; 2], +} + +impl Evidence { + fn new(insertion_sequence: u64) -> Self { + Self { + insertion_sequence, + buckets: [Bucket::default(), Bucket::default()], + } + } + + fn update(&mut self, slot: u64, deltas: [i8; 4], cohort: Option<[u8; 32]>) { + let index = slot as usize % self.buckets.len(); + if !self.buckets[index].valid || self.buckets[index].slot != slot { + self.buckets[index].reset(slot); + } + self.buckets[index].update(deltas, cohort); + } + + fn aggregate(&self, slot: u64) -> Aggregate { + let mut aggregate = Aggregate::default(); + for bucket in &self.buckets { + if !bucket.valid || (bucket.slot != slot && bucket.slot.saturating_add(1) != slot) { + continue; + } + aggregate.outcomes = aggregate.outcomes.saturating_add(bucket.outcomes); + for (score, value) in aggregate.scores.iter_mut().zip(bucket.scores) { + *score = score + .saturating_add(value) + .clamp(SCORE_MIN, SCORE_MAX); + } + for cohort in bucket.cohorts.iter().flatten() { + if !aggregate.cohorts.contains(&Some(*cohort)) + && let Some(target) = aggregate.cohorts.iter_mut().find(|slot| slot.is_none()) + { + *target = Some(*cohort); + } + } + } + aggregate + } + + fn is_live(&self, slot: u64) -> bool { + self.buckets.iter().any(|bucket| { + bucket.valid && (bucket.slot == slot || bucket.slot.saturating_add(1) == slot) + }) + } +} + +#[derive(Default)] +struct Aggregate { + scores: [i8; 4], + outcomes: u8, + cohorts: [Option<[u8; 32]>; MAX_COHORTS], +} + +#[derive(Clone, Copy, PartialEq, Eq)] +struct LearningPolicy { + enabled: bool, + aggressiveness: WebCarrierNegotiationAggressiveness, + lifetime: Duration, +} + +#[derive(Clone, Copy)] +struct Thresholds { + user_agent: u8, + ip: Option, + profile_outcomes: u8, + profile_cohorts: usize, +} + +impl Thresholds { + fn for_aggressiveness(value: WebCarrierNegotiationAggressiveness) -> Self { + match value { + WebCarrierNegotiationAggressiveness::Conservative => Self { + user_agent: 3, + ip: None, + profile_outcomes: 8, + profile_cohorts: 4, + }, + WebCarrierNegotiationAggressiveness::Balanced => Self { + user_agent: 2, + ip: Some(3), + profile_outcomes: 6, + profile_cohorts: 3, + }, + WebCarrierNegotiationAggressiveness::Aggressive => Self { + user_agent: 1, + ip: Some(1), + profile_outcomes: 4, + profile_cohorts: 2, + }, + } + } +} + +/// Process-local bounded two-bucket carrier evidence store. +pub(super) struct CarrierLearning { + entries: HashMap, + insertion_order: VecDeque<(EvidenceKey, u64)>, + capacity: usize, + insertion_sequence: u64, + epoch: Option, + policy: Option, + policy_started_at: Instant, +} + +impl CarrierLearning { + /// Creates an empty store under the restart-owned capacity ceiling. + pub(super) fn new(capacity: usize) -> Self { + Self { + entries: HashMap::new(), + insertion_order: VecDeque::new(), + capacity, + insertion_sequence: 1, + epoch: Some(0), + policy: None, + policy_started_at: Instant::now(), + } + } + + /// Applies hot-reloaded learning policy and returns its outcome epoch. + pub(super) fn apply_policy( + &mut self, + now: Instant, + enabled: bool, + aggressiveness: WebCarrierNegotiationAggressiveness, + lifetime: Duration, + ) -> Option { + let policy = LearningPolicy { + enabled, + aggressiveness, + lifetime, + }; + if self.policy != Some(policy) { + self.entries.clear(); + self.insertion_order.clear(); + if !enabled { + self.entries.shrink_to_fit(); + self.insertion_order.shrink_to_fit(); + } + self.insertion_sequence = 1; + self.epoch = self.epoch.and_then(|epoch| epoch.checked_add(1)); + self.policy = Some(policy); + self.policy_started_at = now; + } + self.epoch + } + + /// Returns the current epoch only when the request snapshot matches owner policy. + pub(super) fn epoch_for_policy( + &self, + enabled: bool, + aggressiveness: WebCarrierNegotiationAggressiveness, + lifetime: Duration, + ) -> Option { + (self.policy + == Some(LearningPolicy { + enabled, + aggressiveness, + lifetime, + })) + .then_some(self.epoch) + .flatten() + } + + /// Ranks supported configured candidates without scanning the evidence store. + pub(super) fn rank( + &self, + now: Instant, + configured: &[WebCarrier], + request: super::CarrierRequest, + profile_key: ProfileKey, + client_ip: IpAddr, + ip_learning_eligible: bool, + ) -> (Vec, [i16; 4]) { + let Some(policy) = self.policy.filter(|policy| policy.enabled) else { + return (supported(configured, request), [0; 4]); + }; + let slot = bucket_slot(self.policy_started_at, now, policy.lifetime); + let thresholds = Thresholds::for_aggressiveness(policy.aggressiveness); + let profile = self + .entries + .get(&EvidenceKey::Profile(profile_key)) + .map(|entry| entry.aggregate(slot)); + let user_agent = self + .entries + .get(&EvidenceKey::UserAgent( + profile_key, + request.class(), + request.user_agent_hash(), + )) + .map(|entry| entry.aggregate(slot)); + let ip = (ip_learning_eligible && thresholds.ip.is_some()) + .then(|| self.entries.get(&EvidenceKey::Ip(profile_key, client_ip))) + .flatten() + .map(|entry| entry.aggregate(slot)); + let profile_ready = profile.as_ref().is_some_and(|entry| { + entry.outcomes >= thresholds.profile_outcomes + && entry.cohorts.iter().flatten().count() >= thresholds.profile_cohorts + }); + let user_agent_ready = user_agent + .as_ref() + .is_some_and(|entry| entry.outcomes >= thresholds.user_agent); + let ip_ready = thresholds.ip.is_some_and(|minimum| { + ip.as_ref() + .is_some_and(|entry| entry.outcomes >= minimum) + }); + let mut scores = [0i16; 4]; + for carrier in WebCarrier::ALL { + let index = carrier.index(); + if profile_ready { + scores[index] += i16::from(profile.as_ref().map_or(0, |value| value.scores[index])) + * PROFILE_WEIGHT; + } + if user_agent_ready { + scores[index] += i16::from( + user_agent + .as_ref() + .map_or(0, |value| value.scores[index]), + ) * USER_AGENT_WEIGHT; + } + if ip_ready { + scores[index] += + i16::from(ip.as_ref().map_or(0, |value| value.scores[index])) * IP_WEIGHT; + } + } + let mut ranked = supported(configured, request); + let fallback = configured + .last() + .copied() + .filter(|carrier| request.supports(*carrier)); + if let Some(fallback) = fallback { + ranked.retain(|carrier| *carrier != fallback); + } + ranked.sort_by_key(|carrier| std::cmp::Reverse(scores[carrier.index()])); + if let Some(fallback) = fallback { + ranked.push(fallback); + } + (ranked, scores) + } + + /// Applies one complete attempt chain as one atomic evidence sample. + pub(super) fn record_chain( + &mut self, + now: Instant, + epoch: u64, + context: CarrierLearningContext, + failures: &[WebCarrier], + winner: WebCarrier, + ) { + let Some(policy) = self.policy.filter(|policy| policy.enabled) else { + return; + }; + if Some(epoch) != self.epoch { + return; + } + let mut deltas = [0i8; 4]; + let _ = failures; + deltas[winner.index()] = deltas[winner.index()].saturating_add(1); + let thresholds = Thresholds::for_aggressiveness(policy.aggressiveness); + let keys = [ + Some(EvidenceKey::Profile(context.profile_key)), + Some(EvidenceKey::UserAgent( + context.profile_key, + context.class, + context.user_agent_hash, + )), + (context.ip_learning_eligible && thresholds.ip.is_some()) + .then_some(EvidenceKey::Ip(context.profile_key, context.client_ip)), + ]; + self.make_room(&keys); + let missing = keys + .iter() + .flatten() + .filter(|key| !self.entries.contains_key(key)) + .count(); + if self.entries.len().saturating_add(missing) > self.capacity { + return; + } + let slot = bucket_slot(self.policy_started_at, now, policy.lifetime); + let cohort = cohort_hash(context); + for (index, key) in keys.into_iter().enumerate() { + let Some(key) = key else { continue }; + self.update_key(key, slot, deltas, (index == 0).then_some(cohort)); + } + } + + /// Reclaims a fixed number of entries outside both half-window buckets. + pub(super) fn prune(&mut self, now: Instant) { + let Some(policy) = self.policy else { return }; + let slot = bucket_slot(self.policy_started_at, now, policy.lifetime); + let budget = self.insertion_order.len().min(PRUNE_ENTRIES_PER_TICK); + for _ in 0..budget { + let Some((key, sequence)) = self.insertion_order.pop_front() else { + break; + }; + let current = self + .entries + .get(&key) + .is_some_and(|entry| entry.insertion_sequence == sequence); + if !current { + continue; + } + if self.entries.get(&key).is_some_and(|entry| entry.is_live(slot)) { + self.insertion_order.push_back((key, sequence)); + } else { + self.entries.remove(&key); + } + } + } + + fn make_room(&mut self, keys: &[Option; 3]) { + let missing = keys + .iter() + .flatten() + .filter(|key| !self.entries.contains_key(key)) + .count(); + let mut remaining = self.insertion_order.len(); + while self.entries.len().saturating_add(missing) > self.capacity && remaining > 0 { + remaining -= 1; + let Some((oldest, sequence)) = self.insertion_order.pop_front() else { + break; + }; + if self + .entries + .get(&oldest) + .is_none_or(|entry| entry.insertion_sequence != sequence) + { + continue; + } + if keys.contains(&Some(oldest)) { + self.insertion_order.push_back((oldest, sequence)); + continue; + } + self.entries.remove(&oldest); + } + } + + fn update_key( + &mut self, + key: EvidenceKey, + slot: u64, + deltas: [i8; 4], + cohort: Option<[u8; 32]>, + ) { + if let Some(entry) = self.entries.get_mut(&key) { + entry.update(slot, deltas, cohort); + return; + } + let Some(insertion_sequence) = self.next_insertion_sequence() else { + return; + }; + self.entries + .insert(key, Evidence::new(insertion_sequence)); + self.insertion_order.push_back((key, insertion_sequence)); + if let Some(entry) = self.entries.get_mut(&key) { + entry.update(slot, deltas, cohort); + } + } + + fn next_insertion_sequence(&mut self) -> Option { + let sequence = self.insertion_sequence; + self.insertion_sequence = sequence.checked_add(1)?; + Some(sequence) + } +} + +fn supported(configured: &[WebCarrier], request: super::CarrierRequest) -> Vec { + configured + .iter() + .copied() + .filter(|carrier| request.supports(*carrier)) + .collect() +} + +fn bucket_slot(start: Instant, now: Instant, lifetime: Duration) -> u64 { + let half = (lifetime / 2).max(Duration::from_nanos(1)); + let quotient = now.saturating_duration_since(start).as_nanos() / half.as_nanos(); + quotient.min(u128::from(u64::MAX)) as u64 +} + +fn cohort_hash(context: CarrierLearningContext) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(COHORT_CONTEXT); + digest.update(context.profile_key); + digest.update([match context.class { + CarrierClientClass::Legacy => 0, + CarrierClientClass::Bridge => 1, + CarrierClientClass::BrowserHint => 2, + CarrierClientClass::Ios => 3, + }]); + digest.update(context.user_agent_hash); + match context.client_ip { + IpAddr::V4(address) => { + digest.update([4]); + digest.update(address.octets()); + } + IpAddr::V6(address) => { + digest.update([6]); + digest.update(address.octets()); + } + } + digest.finalize().into() +} + +#[cfg(test)] +#[path = "carrier_learning/tests.rs"] +mod tests; diff --git a/src/web/manager/carrier_learning/tests.rs b/src/web/manager/carrier_learning/tests.rs new file mode 100644 index 0000000..8fb83d7 --- /dev/null +++ b/src/web/manager/carrier_learning/tests.rs @@ -0,0 +1,222 @@ +use super::*; +use crate::web::manager::{CarrierCapabilities, CarrierRequest}; + +fn request(hash: u8) -> CarrierRequest { + CarrierRequest::automatic( + CarrierClientClass::Bridge, + CarrierCapabilities::all(), + 1, + None, + [hash; 32], + ) +} + +fn context(hash: u8) -> CarrierLearningContext { + CarrierLearningContext { + profile_key: [1; 32], + client_ip: IpAddr::V4(std::net::Ipv4Addr::new(192, 0, 2, hash)), + class: CarrierClientClass::Bridge, + user_agent_hash: [hash; 32], + epoch: 1, + ip_learning_eligible: true, + } +} + +#[test] +fn policy_epoch_rejects_late_outcomes_and_clears_state() { + let now = Instant::now(); + let mut learning = CarrierLearning::new(6); + let epoch = learning + .apply_policy( + now, + true, + WebCarrierNegotiationAggressiveness::Aggressive, + Duration::from_secs(10), + ) + .unwrap(); + learning.record_chain(now, epoch, context(1), &[], WebCarrier::Websocket); + assert_eq!(learning.entries.len(), 3); + let next = learning + .apply_policy( + now, + false, + WebCarrierNegotiationAggressiveness::Aggressive, + Duration::from_secs(10), + ) + .unwrap(); + assert_ne!(epoch, next); + learning.record_chain(now, epoch, context(1), &[], WebCarrier::Https); + assert!(learning.entries.is_empty()); +} + +#[test] +fn aggressive_policy_ranks_one_atomic_chain_sample() { + let now = Instant::now(); + let mut learning = CarrierLearning::new(6); + let epoch = learning + .apply_policy( + now, + true, + WebCarrierNegotiationAggressiveness::Aggressive, + Duration::from_secs(10), + ) + .unwrap(); + learning.record_chain(now, epoch, context(2), &[], WebCarrier::Websocket); + let (ranked, scores) = learning.rank( + now, + &[ + WebCarrier::Https, + WebCarrier::Websocket, + WebCarrier::HttpsLanes, + ], + request(2), + [1; 32], + context(2).client_ip, + true, + ); + assert_eq!( + ranked, + [ + WebCarrier::Websocket, + WebCarrier::Https, + WebCarrier::HttpsLanes, + ] + ); + assert_eq!(scores[WebCarrier::Websocket.index()], 33); +} + +#[test] +fn two_half_windows_expire_without_sliding_updates() { + let start = Instant::now(); + let mut learning = CarrierLearning::new(6); + let epoch = learning + .apply_policy( + start, + true, + WebCarrierNegotiationAggressiveness::Aggressive, + Duration::from_secs(10), + ) + .unwrap(); + learning.record_chain(start, epoch, context(3), &[], WebCarrier::Websocket); + learning.record_chain( + start + Duration::from_secs(6), + epoch, + context(3), + &[], + WebCarrier::Websocket, + ); + learning.prune(start + Duration::from_secs(11)); + assert_eq!(learning.entries.len(), 3); + learning.prune(start + Duration::from_secs(16)); + assert!(learning.entries.is_empty()); +} + +#[test] +fn exhausted_epoch_and_insertion_identifiers_fail_closed() { + let now = Instant::now(); + let mut learning = CarrierLearning::new(3); + learning.epoch = Some(u64::MAX); + assert_eq!( + learning.apply_policy( + now, + true, + WebCarrierNegotiationAggressiveness::Aggressive, + Duration::from_secs(10), + ), + None + ); + learning.record_chain(now, u64::MAX, context(4), &[], WebCarrier::Https); + assert!(learning.entries.is_empty()); + + learning.epoch = Some(1); + learning.insertion_sequence = u64::MAX; + learning.record_chain(now, 1, context(4), &[], WebCarrier::Https); + assert!(learning.entries.is_empty()); +} + +#[test] +fn client_reported_failures_do_not_create_negative_evidence() { + let now = Instant::now(); + let mut learning = CarrierLearning::new(3); + let epoch = learning + .apply_policy( + now, + true, + WebCarrierNegotiationAggressiveness::Aggressive, + Duration::from_secs(10), + ) + .unwrap(); + learning.record_chain( + now, + epoch, + context(5), + &[WebCarrier::Websocket], + WebCarrier::Https, + ); + let (_, scores) = learning.rank( + now, + &[WebCarrier::Websocket, WebCarrier::HttpsLanes], + request(5), + [1; 32], + context(5).client_ip, + true, + ); + assert_eq!(scores[WebCarrier::Websocket.index()], 0); +} + +#[test] +fn old_new_old_policy_rejects_both_stale_epochs() { + let now = Instant::now(); + let mut learning = CarrierLearning::new(3); + let old = learning + .apply_policy( + now, + true, + WebCarrierNegotiationAggressiveness::Aggressive, + Duration::from_secs(10), + ) + .unwrap(); + let middle = learning + .apply_policy( + now, + true, + WebCarrierNegotiationAggressiveness::Balanced, + Duration::from_secs(10), + ) + .unwrap(); + let current = learning + .apply_policy( + now, + true, + WebCarrierNegotiationAggressiveness::Aggressive, + Duration::from_secs(10), + ) + .unwrap(); + assert_ne!(old, middle); + assert_ne!(middle, current); + assert_ne!(old, current); + learning.record_chain(now, old, context(6), &[], WebCarrier::Https); + learning.record_chain(now, middle, context(6), &[], WebCarrier::Https); + assert!(learning.entries.is_empty()); + learning.record_chain(now, current, context(6), &[], WebCarrier::Https); + assert_eq!(learning.entries.len(), 3); +} + +#[test] +fn fifo_metadata_stays_within_the_entry_capacity() { + let now = Instant::now(); + let mut learning = CarrierLearning::new(3); + let epoch = learning + .apply_policy( + now, + true, + WebCarrierNegotiationAggressiveness::Aggressive, + Duration::from_secs(10), + ) + .unwrap(); + for hash in 1..=32 { + learning.record_chain(now, epoch, context(hash), &[], WebCarrier::Https); + assert!(learning.entries.len() <= 3); + assert!(learning.insertion_order.len() <= 3); + } +} diff --git a/src/web/manager/carrier_outcome.rs b/src/web/manager/carrier_outcome.rs new file mode 100644 index 0000000..70a6c38 --- /dev/null +++ b/src/web/manager/carrier_outcome.rs @@ -0,0 +1,160 @@ +use std::net::IpAddr; +use std::sync::Arc; +use std::time::Instant; + +use super::state::CarrierChainPhase; +use super::{ + CarrierClientClass, CarrierEcho, CarrierLearningContext, CarrierRequest, TokenHash, + WebProcessRuntime, +}; +use crate::config::WebCarrier; +use crate::web::session::WebSession; +use crate::web::trace::{TraceIdentity, TraceLifecycleEvent}; + +impl WebProcessRuntime { + /// Returns authenticated current chain metadata after a committed retry conflict. + pub(crate) fn carrier_echo( + &self, + bootstrap_hash: TokenHash, + host: &str, + client_ip: IpAddr, + request: CarrierRequest, + ) -> Option { + let state = self.state.lock(); + let entry = state.bootstraps.get(&bootstrap_hash)?; + let session = entry.session.as_ref()?; + if !entry.used + || entry.profile.host != host + || entry.session_client_ip != Some(client_ip) + || entry + .carrier_request + .is_none_or(|current| !current.matches_client(request)) + || !(matches!( + entry.carrier_phase, + CarrierChainPhase::CommittedPendingHealth | CarrierChainPhase::Healthy + ) || session.is_carrier_committed()) + { + return None; + } + Some(CarrierEcho { + carrier: session.carrier(), + attempt: entry.carrier_attempt, + candidate_count: u8::try_from(entry.carrier_candidates.len()).unwrap_or(4), + deadline_secs: entry.profile.carrier_negotiation_deadlines_secs[3], + state: if entry.carrier_phase == CarrierChainPhase::Provisional + && session.is_carrier_committed() + { + CarrierChainPhase::CommittedPendingHealth.as_str() + } else { + entry.carrier_phase.as_str() + }, + }) + } + + /// Restores the exact old attempt after successor admission fails. + pub(super) fn cancel_replacement( + &self, + bootstrap_hash: TokenHash, + old_session: &Arc, + ) { + old_session.cancel_carrier_supersede(); + let mut state = self.state.lock(); + if let Some(entry) = state.bootstraps.get_mut(&bootstrap_hash) + && entry + .session + .as_ref() + .is_some_and(|session| Arc::ptr_eq(session, old_session)) + { + entry.carrier_transitioning = false; + } + } + + /// Freezes replacement immediately after accepted carrier state mutation. + #[allow(clippy::too_many_arguments)] + pub(crate) fn carrier_committed( + self: &Arc, + bootstrap_hash: TokenHash, + session_hash: TokenHash, + attempt: u8, + carrier: WebCarrier, + class: CarrierClientClass, + client_ip: IpAddr, + identity: TraceIdentity, + ) { + let mut state = self.state.lock(); + let scores = state.bootstraps.get_mut(&bootstrap_hash).and_then(|entry| { + if entry.carrier_attempt == attempt + && entry + .session + .as_ref() + .is_some_and(|session| session.token_hash() == session_hash) + && entry.carrier_phase == CarrierChainPhase::Provisional + { + entry.carrier_phase = CarrierChainPhase::CommittedPendingHealth; + Some(entry.carrier_scores) + } else { + None + } + }); + drop(state); + let Some(scores) = scores else { return }; + self.trace.record_carrier_lifecycle( + client_ip, + identity.clone(), + TraceLifecycleEvent::CarrierCommitted, + class.as_str(), + carrier, + attempt, + scores, + None, + ); + } + + /// Promotes one exact committed attempt after transport-specific health evidence. + #[allow(clippy::too_many_arguments)] + pub(crate) fn carrier_became_healthy( + &self, + bootstrap_hash: TokenHash, + session_hash: TokenHash, + attempt: u8, + carrier: WebCarrier, + class: CarrierClientClass, + learning_context: Option, + client_ip: IpAddr, + identity: TraceIdentity, + ) { + let (scores, failures) = { + let mut state = self.state.lock(); + let Some(entry) = state.bootstraps.get_mut(&bootstrap_hash) else { + return; + }; + if entry.carrier_attempt != attempt + || entry.carrier_phase != CarrierChainPhase::CommittedPendingHealth + || entry + .session + .as_ref() + .is_none_or(|session| session.token_hash() != session_hash) + { + return; + } + entry.carrier_phase = CarrierChainPhase::Healthy; + (entry.carrier_scores, entry.carrier_failures) + }; + if let Some(context) = learning_context { + let now = Instant::now(); + let mut learning = self.learning.lock(); + let failures = failures.into_iter().flatten().collect::>(); + learning.record_chain(now, context.epoch, context, &failures, carrier); + } + self.trace.record_carrier_lifecycle( + client_ip, + identity, + TraceLifecycleEvent::CarrierHealthy, + class.as_str(), + carrier, + attempt, + scores, + None, + ); + } +} diff --git a/src/web/manager/credentials.rs b/src/web/manager/credentials.rs index 01ee5f1..e5c26fc 100644 --- a/src/web/manager/credentials.rs +++ b/src/web/manager/credentials.rs @@ -6,8 +6,8 @@ use std::time::{Duration, Instant}; use zeroize::Zeroizing; use super::state::{ - Bootstrap, allow_rate, evict_oldest_unused_bootstrap, matching_profile, new_unique_token, - remove_expired_locked, + Bootstrap, CarrierChainPhase, allow_rate, evict_oldest_unused_bootstrap, matching_profile, + new_unique_token, remove_expired_locked, }; use super::{BootstrapResult, ManagerError, TOKEN_BYTES, TokenHash, WebProcessRuntime}; use crate::config::WebRuntimeProfile; @@ -78,8 +78,14 @@ impl WebProcessRuntime { carrier_scores: [0; 4], carrier_attempt: 0, carrier_transitioning: false, - carrier_committed: false, + carrier_phase: CarrierChainPhase::Provisional, + carrier_started_at: None, + carrier_deadline_at: None, + carrier_failures: [None; 3], + carrier_learning_epoch: 0, + close_requested: false, session_client_ip: None, + session_ip_learning_eligible: false, used: false, }, ); @@ -140,12 +146,24 @@ impl WebProcessRuntime { hash: TokenHash, host: &str, ) -> std::result::Result<(), ManagerError> { - let state = self.state.lock(); + let mut state = self.state.lock(); let session = state .sessions .get(&hash) .filter(|session| session.matches_host(host)) .cloned(); + if session.is_some() { + for bootstrap in state.bootstraps.values_mut() { + if bootstrap + .session + .as_ref() + .is_some_and(|current| current.token_hash() == hash) + { + bootstrap.close_requested = true; + break; + } + } + } let closed = state .closed_tokens .get(&hash) diff --git a/src/web/manager/lifecycle.rs b/src/web/manager/lifecycle.rs index f1bda6e..259f87c 100644 --- a/src/web/manager/lifecycle.rs +++ b/src/web/manager/lifecycle.rs @@ -67,6 +67,7 @@ impl WebProcessRuntime { state.bootstraps_per_ip.clear(); state.sessions.values().cloned().collect::>() }; + self.stream_admission.lock().closed = true; for session in &sessions { session.close(); } @@ -85,10 +86,8 @@ impl WebProcessRuntime { let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), waits).await; self.tasks.close(); let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), self.tasks.wait()).await; - let (sessions_live, streams_live) = { - let state = self.state.lock(); - (state.sessions.len(), state.streams_live) - }; + let sessions_live = self.state.lock().sessions.len(); + let streams_live = self.stream_admission.lock().streams_live; let budget = self.data_budget.snapshot(); info!( target: "telemt::web", @@ -113,12 +112,51 @@ impl WebProcessRuntime { pub(super) fn cleanup(&self) { self.cleanup_websockets(); let now = Instant::now(); - self.learning.lock().prune(now); - let sessions = { + let generation = self.active_generation(); + let config = &generation.config().web; + let learning_enabled = config.carrier_negotiation_enabled() && config.carrier_learning; + let mut learning = self.learning.lock(); + let _ = learning.apply_policy( + now, + learning_enabled, + config.carrier_negotiation_aggressiveness, + Duration::from_secs(config.timeouts.carrier_learning_secs), + ); + learning.prune(now); + drop(learning); + let (sessions, expired_chains) = { let mut state = self.state.lock(); + let expired = state + .bootstraps + .iter() + .filter_map(|(hash, bootstrap)| { + (bootstrap.carrier_phase == super::state::CarrierChainPhase::Provisional + && bootstrap + .carrier_deadline_at + .is_some_and(|deadline| now >= deadline) + && bootstrap + .session + .as_ref() + .is_some_and(|session| !session.is_carrier_committed())) + .then_some((*hash, bootstrap.session.clone())) + }) + .collect::>(); + let expired_chains = expired + .iter() + .filter_map(|(_, session)| session.clone()) + .collect::>(); + for (hash, _) in expired { + remove_bootstrap_locked(&mut state, hash); + } remove_expired_locked(&mut state, now); - state.sessions.values().cloned().collect::>() + ( + state.sessions.values().cloned().collect::>(), + expired_chains, + ) }; + for session in expired_chains { + session.close(); + } for session in sessions { session.close_if_due(now); } diff --git a/src/web/manager/negotiation.rs b/src/web/manager/negotiation.rs index 1443033..be6f57d 100644 --- a/src/web/manager/negotiation.rs +++ b/src/web/manager/negotiation.rs @@ -11,6 +11,8 @@ pub(crate) enum CarrierClientClass { Bridge, /// Strict same-origin browser metadata survived while the marker did not. BrowserHint, + /// A native iOS client that supports only the serialized HTTPS carrier. + Ios, } impl CarrierClientClass { @@ -20,6 +22,7 @@ impl CarrierClientClass { Self::Legacy => "legacy", Self::Bridge => "bridge", Self::BrowserHint => "browser-hint", + Self::Ios => "ios", } } } @@ -74,6 +77,11 @@ impl CarrierCapabilities { Self(0b1111) } + /// Returns the only carrier implemented by the native iOS client. + pub(crate) const fn ios() -> Self { + Self(0b0001) + } + /// Builds a set from a validated bit representation. pub(crate) const fn from_bits(bits: u8) -> Option { if bits != 0 && bits & !0b1111 == 0 { @@ -97,6 +105,7 @@ pub(crate) struct CarrierRequest { attempt: Option, failure: Option, user_agent_hash: [u8; 32], + initial_only: bool, } impl CarrierRequest { @@ -108,6 +117,19 @@ impl CarrierRequest { attempt: None, failure: None, user_agent_hash, + initial_only: false, + } + } + + /// Constructs a known fixed-capability client without retry negotiation. + pub(crate) const fn ios(user_agent_hash: [u8; 32]) -> Self { + Self { + class: CarrierClientClass::Ios, + capabilities: Some(CarrierCapabilities::ios()), + attempt: None, + failure: None, + user_agent_hash, + initial_only: true, } } @@ -125,11 +147,17 @@ impl CarrierRequest { attempt: Some(attempt), failure, user_agent_hash, + initial_only: false, } } /// Returns whether this request participates in server-side negotiation. pub(crate) const fn is_automatic(self) -> bool { + self.capabilities.is_some() && !self.initial_only + } + + /// Returns whether server capability filtering applies to this request. + pub(crate) const fn uses_capabilities(self) -> bool { self.capabilities.is_some() } @@ -166,6 +194,14 @@ impl CarrierRequest { self.class == other.class && self.capabilities_bits() == other.capabilities_bits() && self.user_agent_hash == other.user_agent_hash + && self.initial_only == other.initial_only + } + + /// Checks the complete idempotent identity of one exact attempt request. + pub(crate) fn matches_attempt(self, other: Self) -> bool { + self.matches_client(other) + && self.attempt == other.attempt + && self.failure == other.failure } fn capabilities_bits(self) -> Option { @@ -184,4 +220,8 @@ pub(crate) struct CarrierLearningContext { pub(crate) class: CarrierClientClass, /// Domain-separated normalized User-Agent digest. pub(crate) user_agent_hash: [u8; 32], + /// Hot-reload epoch that rejects late outcomes from an older policy. + pub(crate) epoch: u64, + /// Whether the authoritative client address is safe to use as learning evidence. + pub(crate) ip_learning_eligible: bool, } diff --git a/src/web/manager/session_admission.rs b/src/web/manager/session_admission.rs new file mode 100644 index 0000000..6b271c3 --- /dev/null +++ b/src/web/manager/session_admission.rs @@ -0,0 +1,37 @@ +use std::net::IpAddr; +use std::sync::atomic::Ordering; +use std::time::Instant; + +use super::state::{ManagerState, allow_rate}; +use super::{ProfileKey, WebProcessRuntime}; +use crate::config::WebRuntimeProfile; + +/// Applies process, address, profile, and rate ceilings to one initial session. +pub(super) fn admit_initial( + runtime: &WebProcessRuntime, + state: &mut ManagerState, + now: Instant, + client_ip: IpAddr, + profile_key: ProfileKey, + profile: &WebRuntimeProfile, +) -> bool { + let admitted = state.sessions.len() < runtime.limits.max_sessions_global + && state.sessions_per_ip.get(&client_ip).copied().unwrap_or(0) + < runtime.limits.max_sessions_per_ip + && state + .sessions_per_profile + .get(&profile_key) + .copied() + .unwrap_or(0) + < profile.max_sessions + && allow_rate( + &mut state.session_rate, + now, + runtime.limits.new_sessions_per_minute, + runtime.limits.new_sessions_burst, + ); + if !admitted { + runtime.limit_hits.fetch_add(1, Ordering::Relaxed); + } + admitted +} diff --git a/src/web/manager/session_creation.rs b/src/web/manager/session_creation.rs index d181b65..61bbb12 100644 --- a/src/web/manager/session_creation.rs +++ b/src/web/manager/session_creation.rs @@ -8,17 +8,17 @@ use subtle::ConstantTimeEq; use zeroize::Zeroizing; use super::state::{ - ManagerState, allow_rate, decrement_map, matching_profile, new_unique_token, profile_key, + CarrierChainPhase, decrement_map, matching_profile, new_unique_token, profile_key, remember_closed_token_locked, remove_expired_locked, }; +use super::session_admission::admit_initial; use super::{ - CarrierLearningContext, CarrierRequest, CreateResult, ManagerError, TokenHash, - WebProcessRuntime, + CarrierLearningContext, CarrierRequest, CreateResult, ManagerError, TokenHash, WebProcessRuntime, }; -use crate::config::{WebCarrier, WebRuntimeProfile, WebTimeoutsConfig}; +use crate::config::{WebCarrier, WebRuntimeProfile}; use crate::web::frame; use crate::web::session::WebSession; -use crate::web::trace::{TraceIdentity, TraceLifecycleEvent}; +use crate::web::trace::TraceLifecycleEvent; struct Replacement { old_session: Arc, @@ -29,6 +29,9 @@ struct Replacement { carrier: WebCarrier, request: CarrierRequest, scores: [i16; 4], + learning_epoch: u64, + ip_learning_eligible: bool, + carrier_deadline_at: Instant, } impl WebProcessRuntime { @@ -40,6 +43,7 @@ impl WebProcessRuntime { client_ip: IpAddr, body: &[u8], carrier_request: CarrierRequest, + ip_learning_eligible: bool, ) -> std::result::Result { if !frame::validate_hello(body, &self.limits) { return Err(ManagerError::Protocol); @@ -57,8 +61,25 @@ impl WebProcessRuntime { return Err(ManagerError::Authentication); } if entry.used { + if entry.carrier_deadline_at.is_some_and(|deadline| now >= deadline) + && entry + .session + .as_ref() + .is_some_and(|session| !session.is_carrier_committed()) + { + let session = entry.session.clone(); + drop(state); + if let Some(session) = session { + session.close(); + } + return Err(ManagerError::Closed); + } + if entry.close_requested { + return Err(ManagerError::Closed); + } let digest_matches = bool::from(entry.body_digest.ct_eq(&body_digest)); - let client_matches = entry.session_client_ip == Some(client_ip); + let client_matches = entry.session_client_ip == Some(client_ip) + && entry.session_ip_learning_eligible == ip_learning_eligible; let request_matches = entry .carrier_request .is_some_and(|current| current.matches_client(carrier_request)); @@ -71,11 +92,31 @@ impl WebProcessRuntime { if carrier_request.attempt() == Some(entry.carrier_attempt) || (!carrier_request.is_automatic() && entry.carrier_attempt == 1) { + if entry + .carrier_request + .is_none_or(|current| !current.matches_attempt(carrier_request)) + { + return Err(ManagerError::Authentication); + } let session = entry.session.as_ref().ok_or(ManagerError::Authentication)?; + let automatic = carrier_request.is_automatic(); + let carrier_state = if entry.carrier_phase == CarrierChainPhase::Provisional + && session.is_carrier_committed() + { + CarrierChainPhase::CommittedPendingHealth.as_str() + } else { + entry.carrier_phase.as_str() + }; let result = CreateResult { token: entry.session_token.as_str().to_owned(), carrier: session.carrier(), attempt: carrier_request.attempt(), + candidate_count: automatic.then(|| { + u8::try_from(entry.carrier_candidates.len()).unwrap_or(4) + }), + deadline_secs: automatic + .then_some(entry.profile.carrier_negotiation_deadlines_secs[3]), + carrier_state: automatic.then_some(carrier_state), }; let identity = session.trace_identity(); drop(state); @@ -92,9 +133,19 @@ impl WebProcessRuntime { let next_attempt = entry.carrier_attempt.saturating_add(1); if !carrier_request.is_automatic() || carrier_request.attempt() != Some(next_attempt) - || entry.carrier_committed + || matches!( + entry.carrier_phase, + CarrierChainPhase::CommittedPendingHealth | CarrierChainPhase::Healthy + ) { - return Err(ManagerError::Protocol); + return Err(if matches!( + entry.carrier_phase, + CarrierChainPhase::CommittedPendingHealth | CarrierChainPhase::Healthy + ) { + ManagerError::Committed + } else { + ManagerError::Protocol + }); } let Some(carrier) = entry .carrier_candidates @@ -103,6 +154,15 @@ impl WebProcessRuntime { else { return Err(ManagerError::Protocol); }; + let deadline_index = usize::from(next_attempt.saturating_sub(2)); + if entry.carrier_started_at.is_some_and(|started| { + now.saturating_duration_since(started) + >= Duration::from_secs( + entry.profile.carrier_negotiation_deadlines_secs[deadline_index], + ) + }) { + return Err(ManagerError::Protocol); + } let old_session = entry.session.clone().ok_or(ManagerError::Authentication)?; let replacement = Replacement { profile: Arc::clone(&entry.profile), @@ -113,6 +173,9 @@ impl WebProcessRuntime { carrier, request: carrier_request, scores: entry.carrier_scores, + learning_epoch: entry.carrier_learning_epoch, + ip_learning_eligible, + carrier_deadline_at: entry.carrier_deadline_at.ok_or(ManagerError::Protocol)?, }; state .bootstraps @@ -120,12 +183,7 @@ impl WebProcessRuntime { .ok_or(ManagerError::Authentication)? .carrier_transitioning = true; drop(state); - return self.replace_session( - bootstrap_hash, - client_ip, - replacement, - &config.web.timeouts, - ); + return self.replace_session(bootstrap_hash, client_ip, replacement); } if (carrier_request.is_automatic() && carrier_request.attempt() != Some(1)) @@ -149,15 +207,44 @@ impl WebProcessRuntime { if carrier_request.is_automatic() && !profile.carrier_negotiation_enabled { return Err(ManagerError::Protocol); } - let (candidates, scores) = if carrier_request.is_automatic() && profile.carrier_learning { - self.learning.lock().rank( - now, - &profile.carriers, - carrier_request, - profile_key, - client_ip, - ) - } else if carrier_request.is_automatic() { + let capability_selection = carrier_request.uses_capabilities() + && profile.carrier_negotiation_enabled; + let learning_policy = ( + config.web.carrier_negotiation_enabled() && config.web.carrier_learning, + config.web.carrier_negotiation_aggressiveness, + Duration::from_secs(config.web.timeouts.carrier_learning_secs), + ); + let (candidates, scores, learning_epoch) = if capability_selection + && profile.carrier_learning + { + let learning = self.learning.lock(); + if let Some(epoch) = learning.epoch_for_policy( + learning_policy.0, + learning_policy.1, + learning_policy.2, + ) { + let (candidates, scores) = learning.rank( + now, + &profile.carriers, + carrier_request, + profile_key, + client_ip, + ip_learning_eligible, + ); + (candidates, scores, Some(epoch)) + } else { + ( + profile + .carriers + .iter() + .copied() + .filter(|carrier| carrier_request.supports(*carrier)) + .collect(), + [0; 4], + None, + ) + } + } else if capability_selection { ( profile .carriers @@ -166,9 +253,14 @@ impl WebProcessRuntime { .filter(|carrier| carrier_request.supports(*carrier)) .collect(), [0; 4], + None, ) + } else if carrier_request.uses_capabilities() + && !carrier_request.supports(profile.carrier) + { + return Err(ManagerError::Protocol); } else { - (vec![profile.carrier], [0; 4]) + (vec![profile.carrier], [0; 4], None) }; let Some(carrier) = candidates.first().copied() else { return Err(ManagerError::Protocol); @@ -180,12 +272,16 @@ impl WebProcessRuntime { self.limit_hits.fetch_add(1, Ordering::Relaxed); return Err(ManagerError::Limit); }; - let learning_context = (carrier_request.is_automatic() && profile.carrier_learning) - .then_some(CarrierLearningContext { + let carrier_deadline_at = carrier_request.is_automatic().then_some( + now + Duration::from_secs(profile.carrier_negotiation_deadlines_secs[3]), + ); + let learning_context = learning_epoch.map(|epoch| CarrierLearningContext { profile_key, client_ip, class: carrier_request.class(), user_agent_hash: carrier_request.user_agent_hash(), + epoch, + ip_learning_eligible, }); let session = WebSession::new( Arc::downgrade(self), @@ -197,7 +293,10 @@ impl WebProcessRuntime { carrier, 1, bootstrap_hash, + carrier_deadline_at, + carrier_request.class(), learning_context, + carrier_request.is_automatic(), self.limits.clone(), config.web.timeouts.clone(), ); @@ -216,8 +315,16 @@ impl WebProcessRuntime { entry.carrier_candidates = candidates.into(); entry.carrier_scores = scores; entry.carrier_attempt = 1; + entry.carrier_phase = CarrierChainPhase::Provisional; + entry.carrier_started_at = carrier_request.is_automatic().then_some(now); + entry.carrier_deadline_at = carrier_deadline_at; + entry.carrier_failures = [None; 3]; + entry.carrier_learning_epoch = learning_epoch.unwrap_or(0); + entry.expires_at = now + Duration::from_secs(config.web.timeouts.bootstrap_lifetime_secs); entry.session_client_ip = Some(client_ip); + entry.session_ip_learning_eligible = ip_learning_eligible; let issuance_ip = entry.issuance_ip; + let candidate_count = u8::try_from(entry.carrier_candidates.len()).unwrap_or(4); decrement_map(&mut state.bootstraps_per_ip, &issuance_ip); self.sessions_created.fetch_add(1, Ordering::Relaxed); let identity = session.trace_identity(); @@ -225,6 +332,15 @@ impl WebProcessRuntime { token: session_token, carrier, attempt: carrier_request.attempt(), + candidate_count: carrier_request + .is_automatic() + .then_some(candidate_count), + deadline_secs: carrier_request + .is_automatic() + .then_some(profile.carrier_negotiation_deadlines_secs[3]), + carrier_state: carrier_request + .is_automatic() + .then_some(CarrierChainPhase::Provisional.as_str()), }; drop(state); self.trace.record_carrier_lifecycle( @@ -263,11 +379,15 @@ impl WebProcessRuntime { bootstrap_hash: TokenHash, client_ip: IpAddr, replacement: Replacement, - timeouts: &WebTimeoutsConfig, ) -> std::result::Result { if !replacement.old_session.begin_carrier_supersede() { + let committed = replacement.old_session.is_carrier_committed(); self.cancel_replacement(bootstrap_hash, &replacement.old_session); - return Err(ManagerError::Protocol); + return Err(if committed { + ManagerError::Committed + } else { + ManagerError::Closed + }); } let generation = self.active_generation(); let config = generation.config(); @@ -276,7 +396,10 @@ impl WebProcessRuntime { remove_expired_locked(&mut state, now); let valid = state.bootstraps.get(&bootstrap_hash).is_some_and(|entry| { entry.carrier_transitioning + && entry.carrier_phase == CarrierChainPhase::Provisional + && !entry.close_requested && entry.carrier_attempt.saturating_add(1) == replacement.attempt + && now < replacement.carrier_deadline_at && entry .session .as_ref() @@ -302,14 +425,16 @@ impl WebProcessRuntime { self.cancel_replacement(bootstrap_hash, &replacement.old_session); return Err(ManagerError::Limit); }; - let learning_context = replacement.profile.carrier_learning.then_some( - CarrierLearningContext { + let learning_context = (replacement.profile.carrier_learning + && replacement.learning_epoch != 0) + .then_some(CarrierLearningContext { profile_key: replacement.profile_key, client_ip, class: replacement.request.class(), user_agent_hash: replacement.request.user_agent_hash(), - }, - ); + epoch: replacement.learning_epoch, + ip_learning_eligible: replacement.ip_learning_eligible, + }); let session = WebSession::new( Arc::downgrade(self), session_hash, @@ -320,10 +445,19 @@ impl WebProcessRuntime { replacement.carrier, replacement.attempt, bootstrap_hash, + Some(replacement.carrier_deadline_at), + replacement.request.class(), learning_context, + true, self.limits.clone(), - timeouts.clone(), + replacement.old_session.timeouts().clone(), ); + let Some(supersede) = replacement.old_session.prepare_carrier_supersede() else { + drop(state); + self.cancel_replacement(bootstrap_hash, &replacement.old_session); + session.close(); + return Err(ManagerError::Closed); + }; let old_hash = replacement.old_session.token_hash(); state.sessions.remove(&old_hash); remember_closed_token_locked( @@ -343,23 +477,29 @@ impl WebProcessRuntime { entry.carrier_request = Some(replacement.request); entry.carrier_attempt = replacement.attempt; entry.carrier_transitioning = false; - entry.carrier_committed = false; + entry.carrier_phase = CarrierChainPhase::Provisional; + if let Some(slot) = entry + .carrier_failures + .get_mut(usize::from(replacement.attempt.saturating_sub(2))) + { + *slot = Some(replacement.old_session.carrier()); + } self.sessions_created.fetch_add(1, Ordering::Relaxed); self.sessions_closed.fetch_add(1, Ordering::Relaxed); let result = CreateResult { token: session_token, carrier: replacement.carrier, attempt: Some(replacement.attempt), + candidate_count: Some( + u8::try_from(entry.carrier_candidates.len()).unwrap_or(4), + ), + deadline_secs: Some(entry.profile.carrier_negotiation_deadlines_secs[3]), + carrier_state: Some(CarrierChainPhase::Provisional.as_str()), }; let identity = session.trace_identity(); let old_identity = replacement.old_session.trace_identity(); drop(state); - if replacement.old_session.finish_carrier_supersede() { - session.close(); - } - if let Some(context) = learning_context { - self.record_carrier_outcome(context, replacement.old_session.carrier(), false); - } + supersede.finish(); self.trace.record_carrier_lifecycle( client_ip, old_identity.clone(), @@ -401,107 +541,4 @@ impl WebProcessRuntime { Ok(result) } - fn cancel_replacement(&self, bootstrap_hash: TokenHash, old_session: &Arc) { - old_session.cancel_carrier_supersede(); - let mut state = self.state.lock(); - if let Some(entry) = state.bootstraps.get_mut(&bootstrap_hash) - && entry - .session - .as_ref() - .is_some_and(|session| Arc::ptr_eq(session, old_session)) - { - entry.carrier_transitioning = false; - } - } - - /// Commits learning only after one accepted OPEN or DATA batch. - #[allow(clippy::too_many_arguments)] - pub(crate) fn carrier_committed( - &self, - bootstrap_hash: TokenHash, - session_hash: TokenHash, - attempt: u8, - carrier: WebCarrier, - learning_context: Option, - client_ip: IpAddr, - identity: TraceIdentity, - ) { - let mut state = self.state.lock(); - let scores = state.bootstraps.get_mut(&bootstrap_hash).and_then(|entry| { - if entry.carrier_attempt == attempt - && entry - .session - .as_ref() - .is_some_and(|session| session.token_hash() == session_hash) - { - entry.carrier_committed = true; - Some(entry.carrier_scores) - } else { - None - } - }); - drop(state); - let Some(scores) = scores else { return }; - if let Some(context) = learning_context { - self.record_carrier_outcome(context, carrier, true); - } - self.trace.record_carrier_lifecycle( - client_ip, - identity, - TraceLifecycleEvent::CarrierCommitted, - learning_context - .map_or("legacy", |context| context.class.as_str()), - carrier, - attempt, - scores, - None, - ); - } - - fn record_carrier_outcome( - &self, - context: CarrierLearningContext, - carrier: WebCarrier, - success: bool, - ) { - let generation = self.active_generation(); - if !generation.config().web.carrier_learning { - return; - } - let lifetime = Duration::from_secs( - generation.config().web.timeouts.carrier_learning_secs, - ); - self.learning - .lock() - .record(Instant::now(), lifetime, context, carrier, success); - } -} - -fn admit_initial( - runtime: &WebProcessRuntime, - state: &mut ManagerState, - now: Instant, - client_ip: IpAddr, - profile_key: super::ProfileKey, - profile: &WebRuntimeProfile, -) -> bool { - let admitted = state.sessions.len() < runtime.limits.max_sessions_global - && state.sessions_per_ip.get(&client_ip).copied().unwrap_or(0) - < runtime.limits.max_sessions_per_ip - && state - .sessions_per_profile - .get(&profile_key) - .copied() - .unwrap_or(0) - < profile.max_sessions - && allow_rate( - &mut state.session_rate, - now, - runtime.limits.new_sessions_per_minute, - runtime.limits.new_sessions_burst, - ); - if !admitted { - runtime.limit_hits.fetch_add(1, Ordering::Relaxed); - } - admitted } diff --git a/src/web/manager/state.rs b/src/web/manager/state.rs index 3d36b14..29fb268 100644 --- a/src/web/manager/state.rs +++ b/src/web/manager/state.rs @@ -14,6 +14,23 @@ use crate::web::session::WebSession; const WEB_PROFILE_OWNER_CONTEXT: &[u8] = b"telemt-web-profile-owner-v1\0"; +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum CarrierChainPhase { + Provisional, + CommittedPendingHealth, + Healthy, +} + +impl CarrierChainPhase { + pub(super) const fn as_str(self) -> &'static str { + match self { + Self::Provisional => "provisional", + Self::CommittedPendingHealth => "committed", + Self::Healthy => "healthy", + } + } +} + /// One issued bootstrap and optional idempotent session-creation replay state. pub(super) struct Bootstrap { /// Credential and replay-state expiry deadline. @@ -42,10 +59,22 @@ pub(super) struct Bootstrap { pub(super) carrier_attempt: u8, /// Prevents concurrent retries from replacing the same attempt twice. pub(super) carrier_transitioning: bool, - /// Records the first accepted OPEN or DATA transition exactly once. - pub(super) carrier_committed: bool, + /// Manager-owned attempt-chain phase used for replacement linearization. + pub(super) carrier_phase: CarrierChainPhase, + /// Monotonic start of the first automatic session attempt. + pub(super) carrier_started_at: Option, + /// Absolute server-side end of the automatic attempt chain. + pub(super) carrier_deadline_at: Option, + /// Failed candidates staged until one winner becomes healthy. + pub(super) carrier_failures: [Option; 3], + /// Learning-policy epoch frozen by the first automatic attempt. + pub(super) carrier_learning_epoch: u64, + /// DELETE observed before an in-flight replacement committed its swap. + pub(super) close_requested: bool, /// Effective address frozen by the first session-creation request. pub(super) session_client_ip: Option, + /// Whether the first request carried an authoritative public forwarded address. + pub(super) session_ip_learning_eligible: bool, /// Distinguishes unused issuance quota from completed creation replay state. pub(super) used: bool, } @@ -70,6 +99,20 @@ struct StreamPortState { next: u16, } +/// Stream admission and KDF tuple ownership isolated from credential transitions. +#[derive(Default)] +pub(super) struct StreamAdmissionState { + /// Process shutdown admission latch. + pub(super) closed: bool, + /// Live stream counts by stable profile key. + pub(super) streams_per_profile: HashMap, + /// Process-wide live relay-task count. + pub(super) streams_live: usize, + stream_ports: HashMap<(IpAddr, SocketAddr), StreamPortState>, + /// Logical-stream creation rate limiter. + pub(super) stream_rate: RateState, +} + /// Process-wide WEB registries and quota accounting protected by one short lock. #[derive(Default)] pub(super) struct ManagerState { @@ -85,17 +128,10 @@ pub(super) struct ManagerState { pub(super) sessions_per_ip: HashMap, /// Live session counts by stable profile key. pub(super) sessions_per_profile: HashMap, - /// Live relay-task counts by stable profile key. - pub(super) streams_per_profile: HashMap, - /// Process-wide live relay-task count. - pub(super) streams_live: usize, - stream_ports: HashMap<(IpAddr, SocketAddr), StreamPortState>, /// Bootstrap issuance rate limiter. pub(super) bootstrap_rate: RateState, /// Session creation rate limiter. pub(super) session_rate: RateState, - /// Logical-stream creation rate limiter. - pub(super) stream_rate: RateState, /// Process shutdown admission latch. pub(super) closed: bool, } @@ -264,7 +300,7 @@ where /// Allocates a non-zero source port unique among live streams for one KDF route. pub(super) fn allocate_stream_port( - state: &mut ManagerState, + state: &mut StreamAdmissionState, client_ip: IpAddr, public_addr: SocketAddr, ) -> Option { @@ -287,7 +323,7 @@ pub(super) fn allocate_stream_port( /// Releases one source port and reclaims empty per-route allocator state. pub(super) fn release_stream_port( - state: &mut ManagerState, + state: &mut StreamAdmissionState, client_ip: IpAddr, public_addr: SocketAddr, peer_port: u16, @@ -309,7 +345,7 @@ mod tests { #[test] fn synthetic_ports_are_unique_per_live_route_and_state_is_reclaimed() { - let mut state = ManagerState::default(); + let mut state = StreamAdmissionState::default(); let client_ip = "192.0.2.10".parse().unwrap(); let public_addr = "203.0.113.10:443".parse().unwrap(); let first = allocate_stream_port(&mut state, client_ip, public_addr).unwrap(); diff --git a/src/web/manager/websocket.rs b/src/web/manager/websocket.rs index e8350d8..78042cd 100644 --- a/src/web/manager/websocket.rs +++ b/src/web/manager/websocket.rs @@ -82,19 +82,45 @@ impl WebSocketConnection { } /// Marks successful ownership transfer from HTTP to the WebSocket codec. - pub(crate) fn mark_opened(&self) { - self.entry - .phase - .store(WebSocketPhase::Upgraded as u8, Ordering::Release); + pub(crate) fn mark_opened(&self) -> bool { + if self.entry.closing.load(Ordering::Acquire) + || self + .entry + .phase + .compare_exchange( + WebSocketPhase::Claimed as u8, + WebSocketPhase::Upgraded as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_err() + || self.entry.closing.load(Ordering::Acquire) + { + return false; + } self.mark_progress(); + true } /// Marks the first validated carrier binary message as active progress. - pub(crate) fn mark_active(&self) { - self.entry - .phase - .store(WebSocketPhase::Active as u8, Ordering::Release); + pub(crate) fn mark_active(&self) -> bool { + if self.entry.closing.load(Ordering::Acquire) + || self + .entry + .phase + .compare_exchange( + WebSocketPhase::Upgraded as u8, + WebSocketPhase::Active as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_err() + || self.entry.closing.load(Ordering::Acquire) + { + return false; + } self.mark_peer_activity(); + true } /// Refreshes the peer-liveness deadline after any received WebSocket message. @@ -125,12 +151,16 @@ impl Drop for WebSocketConnection { registry.claims.remove(&self.entry.claim); } if self.entry.closing.load(Ordering::Acquire) { - registry.evictions_in_flight = registry.evictions_in_flight.saturating_sub(1); + if registry.evictions_in_flight == 0 { + registry.closed = true; + } else { + registry.evictions_in_flight -= 1; + } } drop(registry); - self.entry.released.cancel(); drop(self.base_budget.take()); drop(self.slot.take()); + self.entry.released.cancel(); runtime.websocket_notify.notify_waiters(); } } @@ -149,6 +179,9 @@ pub(super) async fn admit( eviction_timeout: Duration, parent_cancellation: CancellationToken, ) -> Result { + if parent_cancellation.is_cancelled() { + return Err(ManagerError::Closed); + } let liveness_interval_ms = liveness_interval.as_millis().min(u128::from(u64::MAX)) as u64; match try_admit( runtime, @@ -172,7 +205,13 @@ pub(super) async fn admit( }; let released = victim.released.cancelled(); victim.cancel.cancel(); - let _ = tokio::time::timeout(eviction_timeout, released).await; + tokio::select! { + _ = parent_cancellation.cancelled() => return Err(ManagerError::Closed), + _ = tokio::time::timeout(eviction_timeout, released) => {} + } + if parent_cancellation.is_cancelled() { + return Err(ManagerError::Closed); + } match try_admit( runtime, owner, @@ -211,6 +250,9 @@ fn try_admit( liveness_interval_ms: u64, parent_cancellation: &CancellationToken, ) -> Result { + if parent_cancellation.is_cancelled() { + return Err(TryAdmitError::Closed); + } let claim = WebSocketClaimKey { session_hash, kind }; { let registry = runtime.websockets.lock(); @@ -227,6 +269,9 @@ fn try_admit( let base_budget = runtime .try_websocket_base_budget(owner, base_bytes) .ok_or(TryAdmitError::Capacity)?; + if parent_cancellation.is_cancelled() { + return Err(TryAdmitError::Closed); + } let id = runtime .websocket_next_id .fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| { @@ -251,7 +296,7 @@ fn try_admit( released: CancellationToken::new(), }); let mut registry = runtime.websockets.lock(); - if registry.closed { + if registry.closed || parent_cancellation.is_cancelled() { return Err(TryAdmitError::Closed); } if registry.claims.contains_key(&claim) { @@ -276,11 +321,12 @@ impl WebProcessRuntime { pub(super) fn cleanup_websockets(&self) { let now = self.websocket_tick(); let mut victims = claim_stale_victims(self, now); - if victims.is_empty() - && self.data_budget.take_pressure() - && let Some(victim) = select_pressure_victim(self, now, true) - { - victims.push(victim); + if victims.is_empty() && self.data_budget.take_pressure() { + if let Some(victim) = select_pressure_victim(self, now, true) { + victims.push(victim); + } else { + self.data_budget.restore_pressure(); + } } for victim in victims { victim.cancel.cancel(); diff --git a/src/web/manager/websocket/tests.rs b/src/web/manager/websocket/tests.rs index 6f25a3e..35877d6 100644 --- a/src/web/manager/websocket/tests.rs +++ b/src/web/manager/websocket/tests.rs @@ -1,18 +1,29 @@ use super::*; fn entry(kind: WebSocketKind, opened: bool, peer_tick: u64) -> WebSocketEntry { + let phase = if opened { + WebSocketPhase::Active + } else { + WebSocketPhase::Claimed + }; WebSocketEntry { id: 1, owner: [0; 32], session_id: 1, + claim: WebSocketClaimKey { + session_hash: [0; 32], + kind, + }, client_ip: "192.0.2.10".parse().unwrap(), kind, liveness_interval_ms: 10, created_tick: 1, last_peer_tick: AtomicU64::new(peer_tick), last_progress_tick: AtomicU64::new(peer_tick), - opened: AtomicBool::new(opened), + phase: AtomicU8::new(phase as u8), + closing: AtomicBool::new(false), cancel: CancellationToken::new(), + released: CancellationToken::new(), } } diff --git a/src/web/session.rs b/src/web/session.rs index 0a8d83b..aab018e 100644 --- a/src/web/session.rs +++ b/src/web/session.rs @@ -14,8 +14,9 @@ use tokio_util::sync::CancellationToken; use crate::config::{WebCarrier, WebLimitsConfig, WebRuntimeProfile, WebTimeoutsConfig}; use crate::web::frame::{self, FrameType}; -use crate::web::manager::{ProfileKey, TokenHash, WebProcessRuntime}; -use crate::web::manager::CarrierLearningContext; +use crate::web::manager::{ + CarrierClientClass, CarrierLearningContext, ProfileKey, TokenHash, WebProcessRuntime, +}; // Backend tasks own generation admission and authenticated MTProxy relay lifetimes. mod backend; @@ -25,9 +26,16 @@ mod downlink; mod resident; // Lane carrier state isolates request sequencing and downlink replay per logical stream. mod lanes; +// Lane batch staging transfers queue ownership without escaping process budgets. +mod lane_downlink; +// Lane uplink creation remains transactional across validation and queue reservations. +mod lane_uplink; // WebSocket carrier state owns pre-OPEN lane reservations and failure isolation. mod websocket; pub(crate) use websocket::WebSocketLaneReservation; +pub(crate) use websocket::WebSocketProbeReservation; +// Carrier commit and health evidence share one session-locked state machine. +mod negotiation; // Session closure and carrier-attempt transitions share one cancellation boundary. mod lifecycle; // Uplink batches own exactly-once sequencing and client-frame validation. @@ -80,6 +88,7 @@ struct DownBatch { data_items: usize, control_bytes: usize, control_items: usize, + carrier_health_eligible: bool, } struct CarrierLane { @@ -142,6 +151,16 @@ struct SessionState { pending_control_items: usize, last_activity: Instant, negotiation_phase: SessionNegotiationPhase, + carrier_health_due_at: Option, + carrier_health_activity_at: Option, + carrier_health_uplink: bool, + carrier_health_downlink: bool, + carrier_health_reported: bool, + websocket_carrier_active: bool, + websocket_commit_ack_pending: bool, + websocket_commit_ack_owner: Option, + websocket_commit_ack_written: bool, + websocket_probe_claimed: bool, close_requested: bool, closed: bool, } @@ -165,7 +184,10 @@ pub(crate) struct WebSession { selected_carrier: WebCarrier, carrier_attempt: u8, bootstrap_hash: TokenHash, + carrier_deadline_at: Option, + carrier_class: CarrierClientClass, learning_context: Option, + automatic_carrier: bool, limits: WebLimitsConfig, timeouts: WebTimeoutsConfig, state: Mutex, @@ -202,7 +224,10 @@ impl WebSession { selected_carrier: WebCarrier, carrier_attempt: u8, bootstrap_hash: TokenHash, + carrier_deadline_at: Option, + carrier_class: CarrierClientClass, learning_context: Option, + automatic_carrier: bool, limits: WebLimitsConfig, timeouts: WebTimeoutsConfig, ) -> Arc { @@ -222,7 +247,10 @@ impl WebSession { selected_carrier, carrier_attempt, bootstrap_hash, + carrier_deadline_at, + carrier_class, learning_context, + automatic_carrier, limits, timeouts, state: Mutex::new(SessionState { @@ -249,6 +277,16 @@ impl WebSession { pending_control_items: 0, last_activity: Instant::now(), negotiation_phase: SessionNegotiationPhase::Uncommitted, + carrier_health_due_at: None, + carrier_health_activity_at: None, + carrier_health_uplink: false, + carrier_health_downlink: false, + carrier_health_reported: false, + websocket_carrier_active: false, + websocket_commit_ack_pending: false, + websocket_commit_ack_owner: None, + websocket_commit_ack_written: false, + websocket_probe_claimed: false, close_requested: false, closed: false, }), @@ -293,11 +331,6 @@ impl WebSession { self.cancel.child_token() } - /// Returns whether accepted carrier progress made this attempt immutable. - pub(crate) fn is_carrier_committed(&self) -> bool { - self.state.lock().negotiation_phase == SessionNegotiationPhase::Committed - } - /// Returns a cloned non-secret identity only for enabled debug capture. pub(crate) fn trace_identity(&self) -> crate::web::trace::TraceIdentity { crate::web::trace::TraceIdentity::from_profile(self.trace_session_id, &self.profile) @@ -322,45 +355,14 @@ impl WebSession { } } - fn ensure_carrier_active_locked( - &self, - state: &SessionState, - ) -> Result<(), crate::web::manager::ManagerError> { - match state.negotiation_phase { - SessionNegotiationPhase::Uncommitted | SessionNegotiationPhase::Committed => Ok(()), - SessionNegotiationPhase::Replacing | SessionNegotiationPhase::Superseded => { - Err(crate::web::manager::ManagerError::Closed) - } - } + /// Returns the immutable limits frozen when this carrier chain was created. + pub(crate) fn limits(&self) -> &WebLimitsConfig { + &self.limits } - fn commit_carrier_locked(&self, state: &mut SessionState, progress: bool) -> bool { - if !progress { - return false; - } - match state.negotiation_phase { - SessionNegotiationPhase::Uncommitted => { - state.negotiation_phase = SessionNegotiationPhase::Committed; - true - } - SessionNegotiationPhase::Committed - | SessionNegotiationPhase::Replacing - | SessionNegotiationPhase::Superseded => false, - } - } - - fn finish_carrier_commit(&self) { - if let Some(manager) = self.manager.upgrade() { - manager.carrier_committed( - self.bootstrap_hash, - self.token_hash, - self.carrier_attempt, - self.selected_carrier, - self.learning_context, - self.client_ip, - self.trace_identity(), - ); - } + /// Returns the immutable timeouts frozen when this carrier chain was created. + pub(crate) fn timeouts(&self) -> &WebTimeoutsConfig { + &self.timeouts } /// Polls client-to-server bytes and returns consumed flow-control credit. @@ -431,6 +433,13 @@ impl WebSession { .len() .min(frame::DATA_CHUNK_BYTES) .min(self.limits.max_frame_payload_bytes) + .min(if self.carrier().uses_lanes() { + self.limits + .pending_bytes_per_lane + .saturating_sub(frame::HEADER_BYTES + QUEUE_ITEM_COST) + } else { + usize::MAX + }) .min(stream_state.send_credit as usize); if count == 0 { stream_state.write_waker = Some(cx.waker().clone()); diff --git a/src/web/session/backend.rs b/src/web/session/backend.rs index 3289617..2125374 100644 --- a/src/web/session/backend.rs +++ b/src/web/session/backend.rs @@ -106,6 +106,10 @@ impl WebSession { } let queued = { let mut state = self.state.lock(); + if state.closing_streams.get(&stream.id) == Some(&stream.instance) { + state.closing_streams.remove(&stream.id); + self.remember_closed_locked(&mut state, stream.id); + } state .streams .get(&stream.id) diff --git a/src/web/session/backend_tests.rs b/src/web/session/backend_tests.rs index c4c18d7..0ebe828 100644 --- a/src/web/session/backend_tests.rs +++ b/src/web/session/backend_tests.rs @@ -128,6 +128,9 @@ fn test_runtime_with_dc( 1, [9; 32], None, + crate::web::manager::CarrierClientClass::Legacy, + None, + false, limits, timeouts, ); diff --git a/src/web/session/downlink.rs b/src/web/session/downlink.rs index 76a1207..94a3492 100644 --- a/src/web/session/downlink.rs +++ b/src/web/session/downlink.rs @@ -16,7 +16,7 @@ impl WebSession { if !self.carrier().is_multiplexed() { return Err(ManagerError::Protocol); } - let epoch = { + let (epoch, healthy) = { let mut state = self.state.lock(); if state.closed { return Err(ManagerError::Closed); @@ -35,15 +35,29 @@ impl WebSession { self.close(); return Err(ManagerError::Protocol); } + let carrier_health_eligible = unacked.carrier_health_eligible; self.release_unacked_locked(&mut state); + state.carrier_health_downlink |= carrier_health_eligible; + if carrier_health_eligible { + state.carrier_health_activity_at = Some(Instant::now()); + } } else if cursor != state.down_cursor { drop(state); self.close(); return Err(ManagerError::Protocol); } - state.down_epoch = state.down_epoch.wrapping_add(1).max(1); - state.down_epoch + let Some(epoch) = state.down_epoch.checked_add(1) else { + drop(state); + self.close(); + return Err(ManagerError::Protocol); + }; + state.down_epoch = epoch; + let healthy = self.carrier_health_ready_locked(&mut state, Instant::now()); + (state.down_epoch, healthy) }; + if healthy { + self.finish_carrier_health(); + } self.down_notify.notify_waiters(); let deadline = Duration::from_secs(self.timeouts.long_poll_secs); @@ -431,6 +445,8 @@ impl WebSession { data_items, control_bytes, control_items, + carrier_health_eligible: state.negotiation_phase + == super::SessionNegotiationPhase::Committed, }) } @@ -450,114 +466,5 @@ impl WebSession { } #[cfg(test)] -mod tests { - use super::*; - use std::net::SocketAddr; - use std::sync::Arc; - - use crate::config::{ - WebCarrier, WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig, - }; - use crate::web::manager::WebProcessRuntime; - - fn session() -> Arc { - let profile = Arc::new(WebRuntimeProfile { - host: "proxy.example.com".to_string(), - public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), - user: "alice".to_string(), - secret_mode: WebSecretMode::Plain, - carrier: WebCarrier::Https, - carrier_negotiation_enabled: false, - carrier_learning: true, - carriers: Arc::from([WebCarrier::Https]), - carrier_negotiation_deadlines_secs: [3, 5, 8, 12], - capability: [0; 32], - key_fingerprint: "0000000000000000".to_string(), - max_sessions: 1, - max_streams: 1, - max_streams_per_session: 1, - }); - WebSession::new( - std::sync::Weak::::new(), - [1; 32], - "192.0.2.10".parse().unwrap(), - 1, - profile, - [2; 32], - WebCarrier::Https, - 1, - [3; 32], - None, - WebLimitsConfig::default(), - WebTimeoutsConfig::default(), - ) - } - - fn queue_close(session: &WebSession) { - let encoded = frame::encode(FrameType::Close, 1, &[]); - session.state.lock().pending_frames.push_back(QueuedFrame { - encoded: BytesMut::from(encoded.as_ref()), - frame_type: FrameType::Close, - stream_id: 1, - control: true, - cost: frame::HEADER_BYTES + QUEUE_ITEM_COST, - }); - } - - #[tokio::test] - async fn downlink_replays_unacknowledged_batch_byte_for_byte() { - let session = session(); - queue_close(&session); - let first = session.poll_down(0).await.unwrap(); - let replay = session.poll_down(0).await.unwrap(); - assert_eq!(first.next_cursor, 1); - assert_eq!(replay.next_cursor, 1); - assert_eq!(first.body, replay.body); - } - - #[tokio::test] - async fn invalid_or_overflowing_cursor_closes_session() { - let invalid = session(); - assert!(matches!( - invalid.poll_down(1).await, - Err(ManagerError::Protocol) - )); - assert!(invalid.state.lock().closed); - - let overflow = session(); - { - let mut state = overflow.state.lock(); - state.down_cursor = u64::MAX; - } - queue_close(&overflow); - assert!(matches!( - overflow.poll_down(u64::MAX).await, - Err(ManagerError::Protocol) - )); - assert!(overflow.state.lock().closed); - } - - #[tokio::test] - async fn newer_poll_supersedes_older_poll_without_closing_session() { - let session = session(); - let first_session = Arc::clone(&session); - let first = tokio::spawn(async move { first_session.poll_down(0).await }); - while session.state.lock().down_epoch < 1 { - tokio::task::yield_now().await; - } - let second_session = Arc::clone(&session); - let second = tokio::spawn(async move { second_session.poll_down(0).await }); - while session.state.lock().down_epoch < 2 { - tokio::task::yield_now().await; - } - let superseded = tokio::time::timeout(Duration::from_secs(1), first) - .await - .unwrap() - .unwrap() - .unwrap(); - assert!(superseded.body.is_empty()); - assert_eq!(superseded.next_cursor, 0); - assert!(!session.state.lock().closed); - second.abort(); - } -} +#[path = "downlink_tests.rs"] +mod tests; diff --git a/src/web/session/downlink_tests.rs b/src/web/session/downlink_tests.rs new file mode 100644 index 0000000..6f0b3a8 --- /dev/null +++ b/src/web/session/downlink_tests.rs @@ -0,0 +1,143 @@ +use super::*; +use std::net::SocketAddr; +use std::sync::Arc; + +use arc_swap::ArcSwap; + +use crate::config::{ + ProxyConfig, WebCarrier, WebLimitsConfig, WebRuntimeProfile, WebSecretMode, + WebTimeoutsConfig, +}; +use crate::maestro::generation::test_runtime_generation; +use crate::web::manager::WebProcessRuntime; + +fn session() -> (Arc, Arc) { + let generation = test_runtime_generation(1, ProxyConfig::default()); + let manager = WebProcessRuntime::start(Arc::new(ArcSwap::from(generation))); + let profile = Arc::new(WebRuntimeProfile { + host: "proxy.example.com".to_string(), + public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), + user: "alice".to_string(), + secret_mode: WebSecretMode::Plain, + carrier: WebCarrier::Https, + carrier_negotiation_enabled: false, + carrier_learning: false, + carriers: Arc::from([WebCarrier::Https]), + carrier_negotiation_deadlines_secs: [3, 5, 8, 12], + capability: [0; 32], + key_fingerprint: "0000000000000000".to_string(), + max_sessions: 1, + max_streams: 1, + max_streams_per_session: 1, + }); + let mut timeouts = WebTimeoutsConfig::default(); + timeouts.long_poll_secs = 1; + let session = WebSession::new( + Arc::downgrade(&manager), + [1; 32], + "192.0.2.10".parse().unwrap(), + 1, + profile, + [2; 32], + WebCarrier::Https, + 1, + [3; 32], + None, + crate::web::manager::CarrierClientClass::Legacy, + None, + false, + WebLimitsConfig::default(), + timeouts, + ); + (session, manager) +} + +fn queue_close(session: &WebSession) { + let mut state = session.state.lock(); + assert!(session.queue_control_locked(&mut state, FrameType::Close, 1, &[])); +} + +#[tokio::test] +async fn downlink_replays_unacknowledged_batch_byte_for_byte() { + let (session, manager) = session(); + queue_close(&session); + let first = session.poll_down(0).await.unwrap(); + let replay = session.poll_down(0).await.unwrap(); + assert_eq!(first.next_cursor, 1); + assert_eq!(replay.next_cursor, 1); + assert_eq!(first.body, replay.body); + drop(first); + drop(replay); + session.close(); + manager.shutdown().await; +} + +#[tokio::test] +async fn acknowledged_response_stays_resident_until_the_last_body_clone_drops() { + let (session, manager) = session(); + queue_close(&session); + let response = session.poll_down(0).await.unwrap(); + let retained = response.body.clone(); + { + let mut state = session.state.lock(); + session.release_unacked_locked(&mut state); + assert_eq!(state.pending_bytes, 0); + } + assert!(session.resident.snapshot().bytes() > 0); + drop(response); + assert!(session.resident.snapshot().bytes() > 0); + drop(retained); + assert_eq!(session.resident.snapshot().bytes(), 0); + session.close(); + manager.shutdown().await; +} + +#[tokio::test] +async fn invalid_or_overflowing_cursor_closes_session() { + let (invalid, invalid_manager) = session(); + assert!(matches!( + invalid.poll_down(1).await, + Err(ManagerError::Protocol) + )); + assert!(invalid.state.lock().closed); + invalid_manager.shutdown().await; + + let (overflow, overflow_manager) = session(); + { + let mut state = overflow.state.lock(); + state.down_cursor = u64::MAX; + } + queue_close(&overflow); + assert!(matches!( + overflow.poll_down(u64::MAX).await, + Err(ManagerError::Protocol) + )); + assert!(overflow.state.lock().closed); + overflow_manager.shutdown().await; +} + +#[tokio::test] +async fn newer_poll_supersedes_older_poll_without_closing_session() { + let (session, manager) = session(); + let first_session = Arc::clone(&session); + let first = tokio::spawn(async move { first_session.poll_down(0).await }); + while session.state.lock().down_epoch < 1 { + tokio::task::yield_now().await; + } + let second_session = Arc::clone(&session); + let second = tokio::spawn(async move { second_session.poll_down(0).await }); + while session.state.lock().down_epoch < 2 { + tokio::task::yield_now().await; + } + let superseded = tokio::time::timeout(Duration::from_secs(1), first) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(superseded.body.is_empty()); + assert_eq!(superseded.next_cursor, 0); + assert!(!session.state.lock().closed); + second.abort(); + session.close(); + manager.shutdown().await; +} diff --git a/src/web/session/lane_downlink.rs b/src/web/session/lane_downlink.rs new file mode 100644 index 0000000..33d2fd8 --- /dev/null +++ b/src/web/session/lane_downlink.rs @@ -0,0 +1,92 @@ +use std::sync::Arc; + +use bytes::{Bytes, BytesMut}; + +use super::resident::{OwnedBatchBody, PendingCounts, PendingResponseLease}; +use super::{CarrierLane, DownBatch, WebSession}; +use crate::config::WebLimitsConfig; +use crate::web::frame::FrameType; +use crate::web::manager::ManagerError; + +/// Stages one bounded lane batch and transfers its accounting into response ownership. +pub(super) fn take_lane_down_batch( + session: &WebSession, + limits: &WebLimitsConfig, + lane: &mut CarrierLane, + cursor: u64, + carrier_health_eligible: bool, +) -> Result { + let next_cursor = lane + .down_cursor + .checked_add(1) + .ok_or(ManagerError::Protocol)?; + let mut count = 0usize; + let mut body_len = 0usize; + for queued in &lane.pending_frames { + if count >= limits.max_frames_per_body + || (count != 0 + && body_len.saturating_add(queued.encoded.len()) > limits.carrier_batch_bytes) + { + break; + } + body_len += queued.encoded.len(); + count += 1; + } + let Some(manager) = session.manager.upgrade() else { + return Err(ManagerError::Closed); + }; + let Some(_staging) = manager.try_downlink_staging_budget(body_len) else { + return Err(ManagerError::Backpressure); + }; + let mut body = BytesMut::with_capacity(body_len); + let mut data_bytes = 0usize; + let mut data_items = 0usize; + let mut control_bytes = 0usize; + let mut control_items = 0usize; + for index in 0..count { + let Some(queued) = lane.pending_frames.get(index) else { + break; + }; + if queued.frame_type == FrameType::Window + && lane.pending_windows.get(&queued.stream_id) == Some(&index) + { + lane.pending_windows.remove(&queued.stream_id); + } + } + for _ in 0..count { + let Some(queued) = lane.pending_frames.pop_front() else { + break; + }; + body.extend_from_slice(&queued.encoded); + if queued.control { + control_bytes += queued.cost; + control_items += 1; + } else { + data_bytes += queued.cost; + data_items += 1; + } + } + for index in lane.pending_windows.values_mut() { + *index = index.saturating_sub(count); + } + lane.down_cursor = next_cursor; + let counts = PendingCounts { + data_bytes, + data_items, + control_bytes, + control_items, + }; + let lease = PendingResponseLease::new(session, counts, Some(Arc::clone(&lane.resident))); + let body = Bytes::from_owner(OwnedBatchBody::new(body.freeze(), Arc::clone(&lease))); + Ok(DownBatch { + body, + lease, + base_cursor: cursor, + next_cursor, + data_bytes, + data_items, + control_bytes, + control_items, + carrier_health_eligible, + }) +} diff --git a/src/web/session/lane_uplink.rs b/src/web/session/lane_uplink.rs new file mode 100644 index 0000000..60d6380 --- /dev/null +++ b/src/web/session/lane_uplink.rs @@ -0,0 +1,187 @@ +use std::sync::Arc; +use std::time::Instant; + +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; + +use super::uplink::{AppliedProgress, inbound_reservation, validate_batch}; +use super::{PendingClass, WebSession, insert_carrier_lane}; +use crate::config::WebCarrier; +use crate::web::frame::{self, Frame, FrameType}; +use crate::web::manager::{ManagerError, TokenHash}; + +impl WebSession { + /// Applies one exactly-once uplink batch to an independent HTTPS lane. + pub(crate) fn process_up_lane( + self: &Arc, + lane_id: u32, + sequence: u64, + body: &[u8], + ) -> Result { + if self.carrier() != WebCarrier::HttpsLanes || lane_id > frame::MAX_STREAM_ID { + return Err(ManagerError::Protocol); + } + let frames = match frame::parse_all(body, &self.limits) { + Ok(frames) => frames, + Err(_) => { + self.close(); + return Err(ManagerError::Protocol); + } + }; + if frames + .iter() + .copied() + .any(|value| value.stream_id != lane_id || frame::validate_client_shape(value).is_err()) + { + self.close(); + return Err(ManagerError::Protocol); + } + let digest: TokenHash = Sha256::digest(body).into(); + let mut opened = Vec::new(); + let mut committed = false; + let mut healthy = false; + let result = { + let mut state = self.state.lock(); + if state.closed { + return Err(ManagerError::Closed); + } + self.ensure_carrier_active_locked(&state)?; + state.last_activity = Instant::now(); + let new_lane = !state.carrier_lanes.contains_key(&lane_id); + if new_lane { + if lane_id != 0 + && frames + .first() + .is_some_and(|value| value.frame_type != FrameType::Open) + && only_late_frames(&frames) + { + return Ok(sequence); + } + if lane_id == 0 + || frames + .first() + .is_none_or(|value| value.frame_type != FrameType::Open) + { + drop(state); + self.close(); + return Err(ManagerError::Protocol); + } + let lane_limit = self + .profile + .max_streams_per_session + .saturating_add(self.limits.max_tombstones_per_session) + .saturating_add(1); + if state.carrier_lanes.len() >= lane_limit { + return Err(ManagerError::Limit); + } + } + let (last_sequence, last_digest, up_active) = state + .carrier_lanes + .get(&lane_id) + .map_or((0, [0; 32], false), |lane| { + (lane.last_up_sequence, lane.last_up_digest, lane.up_active) + }); + if sequence == last_sequence && sequence != 0 { + return if bool::from(last_digest.ct_eq(&digest)) { + Ok(sequence) + } else { + drop(state); + self.close(); + Err(ManagerError::Protocol) + }; + } + if sequence == 0 || sequence != last_sequence.saturating_add(1) { + drop(state); + self.close(); + return Err(ManagerError::Protocol); + } + if up_active { + return Err(ManagerError::Concurrent); + } + if !validate_batch(&state, &frames) { + drop(state); + self.close(); + return Err(ManagerError::Protocol); + } + let (reserve_bytes, reserve_items) = inbound_reservation(&state, &frames); + if !self.reserve_locked( + &mut state, + reserve_bytes, + reserve_items, + PendingClass::Uplink, + ) { + if let Some(lane) = state.carrier_lanes.get_mut(&lane_id) { + lane.up_active = false; + } + return Err(ManagerError::Backpressure); + } + if new_lane && insert_carrier_lane(&mut state, lane_id).is_none() { + self.release_locked(&mut state, reserve_bytes, reserve_items, false); + drop(state); + self.close(); + return Err(ManagerError::Protocol); + } + let Some(lane) = state.carrier_lanes.get_mut(&lane_id) else { + self.release_locked(&mut state, reserve_bytes, reserve_items, false); + return Err(ManagerError::Closed); + }; + lane.up_active = true; + let mut unused_bytes = reserve_bytes; + let mut unused_items = reserve_items; + let mut progress = AppliedProgress::default(); + let applied = self.apply_batch_locked( + &mut state, + &frames, + &mut opened, + &mut None, + &mut unused_bytes, + &mut unused_items, + &mut progress, + ); + self.release_locked(&mut state, unused_bytes, unused_items, false); + if let Some(lane) = state.carrier_lanes.get_mut(&lane_id) { + lane.up_active = false; + if applied { + lane.last_up_sequence = sequence; + lane.last_up_digest = digest; + } + } + if applied { + (committed, healthy) = + self.record_uplink_progress_locked(&mut state, progress); + } + applied.then_some(sequence).ok_or(ManagerError::Closed) + }; + if matches!(result, Err(ManagerError::Backpressure)) { + return result; + } + if result.is_err() { + self.close(); + drop(opened); + return result; + } + if committed { + self.finish_carrier_commit(); + } + if healthy { + self.finish_carrier_health(); + } + self.lane_open_notify.notify_waiters(); + for completion in opened { + self.spawn_stream(completion, false); + } + if let Some(manager) = self.manager.upgrade() { + manager.record_up(body.len()); + } + result + } +} + +fn only_late_frames(frames: &[Frame<'_>]) -> bool { + frames.iter().all(|value| { + matches!( + value.frame_type, + FrameType::Data | FrameType::Window | FrameType::Close + ) + }) +} diff --git a/src/web/session/lanes.rs b/src/web/session/lanes.rs index ccf988a..c90a614 100644 --- a/src/web/session/lanes.rs +++ b/src/web/session/lanes.rs @@ -2,184 +2,16 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use bytes::{BufMut, Bytes, BytesMut}; -use sha2::{Digest, Sha256}; -use subtle::ConstantTimeEq; - -use super::uplink::{inbound_reservation, validate_batch}; -use super::resident::{OwnedBatchBody, PendingCounts, PendingResponseLease}; +use tokio::sync::OwnedSemaphorePermit; +use super::lane_downlink::take_lane_down_batch; use super::{ - CarrierLane, DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState, - WebSession, insert_carrier_lane, remember_closed, + PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState, WebSession, + remember_closed, }; -use crate::config::{WebCarrier, WebLimitsConfig}; -use crate::web::frame::{self, Frame, FrameType}; -use crate::web::manager::{ManagerError, TokenHash}; +use crate::web::frame::{self, FrameType}; +use crate::web::manager::ManagerError; impl WebSession { - /// Classifies control and pre-OPEN polls for their reserved handler pool. - pub(crate) fn lane_poll_is_auxiliary(&self, lane_id: u32) -> bool { - let state = self.state.lock(); - lane_id == 0 || !state.carrier_lanes.contains_key(&lane_id) - } - - /// Applies one exactly-once uplink batch to an independent HTTPS lane. - pub(crate) fn process_up_lane( - self: &Arc, - lane_id: u32, - sequence: u64, - body: &[u8], - ) -> Result { - if self.carrier() != WebCarrier::HttpsLanes || lane_id > frame::MAX_STREAM_ID { - return Err(ManagerError::Protocol); - } - let frames = match frame::parse_all(body, &self.limits) { - Ok(frames) => frames, - Err(_) => { - self.close(); - return Err(ManagerError::Protocol); - } - }; - if frames - .iter() - .copied() - .any(|value| value.stream_id != lane_id || frame::validate_client_shape(value).is_err()) - { - self.close(); - return Err(ManagerError::Protocol); - } - let digest: TokenHash = Sha256::digest(body).into(); - let progress = frames - .iter() - .any(|frame| matches!(frame.frame_type, FrameType::Open | FrameType::Data)); - let mut opened = Vec::new(); - let mut committed = false; - let result = { - let mut state = self.state.lock(); - if state.closed { - return Err(ManagerError::Closed); - } - self.ensure_carrier_active_locked(&state)?; - state.last_activity = Instant::now(); - let new_lane = !state.carrier_lanes.contains_key(&lane_id); - if new_lane { - if lane_id != 0 - && frames - .first() - .is_some_and(|value| value.frame_type != FrameType::Open) - && only_late_frames(&frames) - { - return Ok(sequence); - } - if lane_id == 0 - || frames - .first() - .is_none_or(|value| value.frame_type != FrameType::Open) - { - drop(state); - self.close(); - return Err(ManagerError::Protocol); - } - if state.carrier_lanes.len() - >= self.profile.max_streams_per_session.saturating_add(1) - { - return Err(ManagerError::Limit); - } - } - let (last_sequence, last_digest, up_active) = state - .carrier_lanes - .get(&lane_id) - .map_or((0, [0; 32], false), |lane| { - (lane.last_up_sequence, lane.last_up_digest, lane.up_active) - }); - if sequence == last_sequence && sequence != 0 { - return if bool::from(last_digest.ct_eq(&digest)) { - Ok(sequence) - } else { - drop(state); - self.close(); - Err(ManagerError::Protocol) - }; - } - if sequence == 0 || sequence != last_sequence.saturating_add(1) { - drop(state); - self.close(); - return Err(ManagerError::Protocol); - } - if up_active { - return Err(ManagerError::Concurrent); - } - if !validate_batch(&state, &frames) { - drop(state); - self.close(); - return Err(ManagerError::Protocol); - } - let (reserve_bytes, reserve_items) = inbound_reservation(&state, &frames); - if !self.reserve_locked( - &mut state, - reserve_bytes, - reserve_items, - PendingClass::Uplink, - ) { - if let Some(lane) = state.carrier_lanes.get_mut(&lane_id) { - lane.up_active = false; - } - return Err(ManagerError::Backpressure); - } - if new_lane && insert_carrier_lane(&mut state, lane_id).is_none() { - self.release_locked(&mut state, reserve_bytes, reserve_items, false); - drop(state); - self.close(); - return Err(ManagerError::Protocol); - } - let Some(lane) = state.carrier_lanes.get_mut(&lane_id) else { - self.release_locked(&mut state, reserve_bytes, reserve_items, false); - return Err(ManagerError::Closed); - }; - lane.up_active = true; - let mut unused_bytes = reserve_bytes; - let mut unused_items = reserve_items; - let applied = self.apply_batch_locked( - &mut state, - &frames, - &mut opened, - &mut None, - &mut unused_bytes, - &mut unused_items, - ); - self.release_locked(&mut state, unused_bytes, unused_items, false); - if let Some(lane) = state.carrier_lanes.get_mut(&lane_id) { - lane.up_active = false; - if applied { - lane.last_up_sequence = sequence; - lane.last_up_digest = digest; - } - } - if applied { - committed = self.commit_carrier_locked(&mut state, progress); - } - applied.then_some(sequence).ok_or(ManagerError::Closed) - }; - if matches!(result, Err(ManagerError::Backpressure)) { - return result; - } - if result.is_err() { - self.close(); - drop(opened); - return result; - } - if committed { - self.finish_carrier_commit(); - } - self.lane_open_notify.notify_waiters(); - for completion in opened { - self.spawn_stream(completion, false); - } - if let Some(manager) = self.manager.upgrade() { - manager.record_up(body.len()); - } - result - } - /// Polls one lane with independent cursor replay and newest-poll-wins semantics. pub(crate) async fn poll_down_lane( &self, @@ -196,7 +28,7 @@ impl WebSession { lane_closed: false, }); } - let (instance, epoch, notify) = { + let (instance, epoch, notify, healthy) = { let mut state = self.state.lock(); if state.closed { return Err(ManagerError::Closed); @@ -235,12 +67,8 @@ impl WebSession { }; if let Some(batch) = acknowledged { if let Some(lane) = state.carrier_lanes.get_mut(&lane_id) { - lane.pending_bytes = lane - .pending_bytes - .saturating_sub(batch.data_bytes.saturating_add(batch.control_bytes)); - lane.pending_items = lane - .pending_items - .saturating_sub(batch.data_items.saturating_add(batch.control_items)); + lane.pending_bytes = lane.pending_bytes.saturating_sub(batch.data_bytes); + lane.pending_items = lane.pending_items.saturating_sub(batch.data_items); } batch.lease.detach(); self.release_local_locked(&mut state, batch.data_bytes, batch.data_items, false); @@ -250,6 +78,10 @@ impl WebSession { batch.control_items, true, ); + state.carrier_health_downlink |= batch.carrier_health_eligible; + if batch.carrier_health_eligible { + state.carrier_health_activity_at = Some(Instant::now()); + } if let Some(stream) = state.streams.get_mut(&lane_id) && let Some(waker) = stream.write_waker.take() { @@ -260,9 +92,20 @@ impl WebSession { .carrier_lanes .get_mut(&lane_id) .ok_or(ManagerError::Protocol)?; - lane.down_epoch = lane.down_epoch.wrapping_add(1).max(1); - (lane.instance, lane.down_epoch, Arc::clone(&lane.notify)) + let Some(epoch) = lane.down_epoch.checked_add(1) else { + drop(state); + self.close(); + return Err(ManagerError::Protocol); + }; + lane.down_epoch = epoch; + let instance = lane.instance; + let notify = Arc::clone(&lane.notify); + let healthy = self.carrier_health_ready_locked(&mut state, Instant::now()); + (instance, epoch, notify, healthy) }; + if healthy { + self.finish_carrier_health(); + } notify.notify_waiters(); let deadline = Duration::from_secs(self.timeouts.long_poll_secs); @@ -274,6 +117,9 @@ impl WebSession { if state.closed { return Err(ManagerError::Closed); } + let carrier_health_eligible = lane_id != 0 + && state.negotiation_phase + == super::SessionNegotiationPhase::Committed; let Some(lane) = state.carrier_lanes.get_mut(&lane_id) else { return Ok(PollResult { body: Bytes::new(), @@ -281,7 +127,14 @@ impl WebSession { lane_closed: true, }); }; - if lane.instance != instance || lane.down_epoch != epoch { + if lane.instance != instance { + return Ok(PollResult { + body: Bytes::new(), + next_cursor: cursor, + lane_closed: true, + }); + } + if lane.down_epoch != epoch { return Ok(PollResult { body: Bytes::new(), next_cursor: cursor, @@ -289,7 +142,13 @@ impl WebSession { }); } if !lane.pending_frames.is_empty() { - let batch = match take_lane_down_batch(self, &self.limits, lane, cursor) { + let batch = match take_lane_down_batch( + self, + &self.limits, + lane, + cursor, + carrier_health_eligible, + ) { Ok(batch) => batch, Err(ManagerError::Backpressure) => { return Err(ManagerError::Backpressure); @@ -350,12 +209,17 @@ impl WebSession { lane_closed: true, }); } - if state - .carrier_lanes - .get(&lane_id) - .is_some_and(|lane| lane.instance == instance && lane.down_epoch == epoch) - { - state.last_activity = Instant::now(); + if let Some(lane) = state.carrier_lanes.get(&lane_id) { + if lane.instance != instance { + return Ok(PollResult { + body: Bytes::new(), + next_cursor: cursor, + lane_closed: true, + }); + } + if lane.down_epoch == epoch { + state.last_activity = Instant::now(); + } } Ok(PollResult { body: Bytes::new(), @@ -392,8 +256,17 @@ impl WebSession { if state.lane_open_waits >= self.limits.max_lane_open_waits_per_session { return Err(ManagerError::Limit); } + let Some(manager) = self.manager.upgrade() else { + return Err(ManagerError::Closed); + }; + let Some(auxiliary) = manager.try_lane_poll(true) else { + return Err(ManagerError::Limit); + }; state.lane_open_waits += 1; - LaneOpenWaitGuard { session: self } + LaneOpenWaitGuard { + session: self, + _auxiliary: auxiliary, + } }; let deadline = Duration::from_secs(self.timeouts.lane_open_wait_secs); let opened = tokio::time::timeout(deadline, async { @@ -401,23 +274,34 @@ impl WebSession { let notified = self.lane_open_notify.notified(); { let state = self.state.lock(); - if state.closed - || state.carrier_lanes.contains_key(&lane_id) + if state.closed { + return Err(ManagerError::Closed); + } + if state.carrier_lanes.contains_key(&lane_id) || state.closed_streams.contains(&lane_id) || state.closing_streams.contains_key(&lane_id) { - return state.carrier_lanes.contains_key(&lane_id) - || state.closed_streams.contains(&lane_id) - || state.closing_streams.contains_key(&lane_id); + return Ok(true); } } notified.await; } }) - .await - .unwrap_or(false); + .await; drop(wait); - Ok(opened) + match opened { + Ok(result) => result, + Err(_) => { + let state = self.state.lock(); + if state.closed { + Err(ManagerError::Closed) + } else { + Ok(state.carrier_lanes.contains_key(&lane_id) + || state.closed_streams.contains(&lane_id) + || state.closing_streams.contains_key(&lane_id)) + } + } + } } pub(super) fn queue_lane_frame_locked( @@ -468,7 +352,8 @@ impl WebSession { if can_coalesce { if state.carrier_lanes.get(&stream_id).is_none_or(|lane| { let resident = lane.resident.snapshot(); - lane.pending_bytes.saturating_add(resident.bytes()) + payload.len() > self.limits.pending_bytes_per_lane + || lane.pending_bytes.saturating_add(resident.data_bytes) > self .limits .pending_bytes_per_lane @@ -501,13 +386,16 @@ impl WebSession { } else { PendingClass::Downlink }; - if state.carrier_lanes.get(&stream_id).is_none_or(|lane| { - let resident = lane.resident.snapshot(); - lane.pending_bytes.saturating_add(resident.bytes()) - > self.limits.pending_bytes_per_lane.saturating_sub(cost) - || lane.pending_items.saturating_add(resident.items()) - >= self.limits.pending_items_per_lane - }) { + if !control + && state.carrier_lanes.get(&stream_id).is_none_or(|lane| { + let resident = lane.resident.snapshot(); + cost > self.limits.pending_bytes_per_lane + || lane.pending_bytes.saturating_add(resident.data_bytes) + > self.limits.pending_bytes_per_lane.saturating_sub(cost) + || lane.pending_items.saturating_add(resident.data_items) + >= self.limits.pending_items_per_lane + }) + { return false; } if !self.reserve_locked(state, cost, 1, class) { @@ -532,8 +420,10 @@ impl WebSession { control, cost, }); - lane.pending_bytes += cost; - lane.pending_items += 1; + if !control { + lane.pending_bytes += cost; + lane.pending_items += 1; + } if frame_type == FrameType::Window { lane.pending_windows.insert(stream_id, index); } @@ -585,6 +475,7 @@ impl WebSession { struct LaneOpenWaitGuard<'a> { session: &'a WebSession, + _auxiliary: OwnedSemaphorePermit, } impl Drop for LaneOpenWaitGuard<'_> { @@ -594,95 +485,6 @@ impl Drop for LaneOpenWaitGuard<'_> { } } -fn only_late_frames(frames: &[Frame<'_>]) -> bool { - frames.iter().all(|value| { - matches!( - value.frame_type, - FrameType::Data | FrameType::Window | FrameType::Close - ) - }) -} - -fn take_lane_down_batch( - session: &WebSession, - limits: &WebLimitsConfig, - lane: &mut CarrierLane, - cursor: u64, -) -> Result { - let next_cursor = lane - .down_cursor - .checked_add(1) - .ok_or(ManagerError::Protocol)?; - let mut count = 0usize; - let mut body_len = 0usize; - for queued in &lane.pending_frames { - if count >= limits.max_frames_per_body - || (count != 0 - && body_len.saturating_add(queued.encoded.len()) > limits.carrier_batch_bytes) - { - break; - } - body_len += queued.encoded.len(); - count += 1; - } - let Some(manager) = session.manager.upgrade() else { - return Err(ManagerError::Closed); - }; - let Some(_staging) = manager.try_downlink_staging_budget(body_len) else { - return Err(ManagerError::Backpressure); - }; - let mut body = BytesMut::with_capacity(body_len); - let mut data_bytes = 0usize; - let mut data_items = 0usize; - let mut control_bytes = 0usize; - let mut control_items = 0usize; - for index in 0..count { - let Some(queued) = lane.pending_frames.get(index) else { - break; - }; - if queued.frame_type == FrameType::Window - && lane.pending_windows.get(&queued.stream_id) == Some(&index) - { - lane.pending_windows.remove(&queued.stream_id); - } - } - for _ in 0..count { - let Some(queued) = lane.pending_frames.pop_front() else { - break; - }; - body.extend_from_slice(&queued.encoded); - if queued.control { - control_bytes += queued.cost; - control_items += 1; - } else { - data_bytes += queued.cost; - data_items += 1; - } - } - for index in lane.pending_windows.values_mut() { - *index = index.saturating_sub(count); - } - lane.down_cursor = next_cursor; - let counts = PendingCounts { - data_bytes, - data_items, - control_bytes, - control_items, - }; - let lease = PendingResponseLease::new(session, counts, Some(Arc::clone(&lane.resident))); - let body = Bytes::from_owner(OwnedBatchBody::new(body.freeze(), Arc::clone(&lease))); - Ok(DownBatch { - body, - lease, - base_cursor: cursor, - next_cursor, - data_bytes, - data_items, - control_bytes, - control_items, - }) -} - // Lane-specific protocol, replay, and lifecycle tests. #[cfg(test)] mod tests; diff --git a/src/web/session/lanes/tests.rs b/src/web/session/lanes/tests.rs index c9235c2..27aeb96 100644 --- a/src/web/session/lanes/tests.rs +++ b/src/web/session/lanes/tests.rs @@ -1,13 +1,26 @@ use std::net::SocketAddr; use std::sync::Arc; +use arc_swap::ArcSwap; use bytes::BytesMut; use super::*; -use crate::config::{WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig}; +use crate::config::{ + ProxyConfig, WebCarrier, WebLimitsConfig, WebRuntimeProfile, WebSecretMode, + WebTimeoutsConfig, +}; +use crate::maestro::generation::test_runtime_generation; use crate::web::manager::WebProcessRuntime; +use crate::web::session::{CarrierLane, insert_carrier_lane}; fn session_with_limits(limits: WebLimitsConfig) -> Arc { + new_session(limits, std::sync::Weak::new()) +} + +fn new_session( + limits: WebLimitsConfig, + manager: std::sync::Weak, +) -> Arc { let profile = Arc::new(WebRuntimeProfile { host: "proxy.example.com".to_string(), public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), @@ -25,7 +38,7 @@ fn session_with_limits(limits: WebLimitsConfig) -> Arc { max_streams_per_session: 2, }); WebSession::new( - std::sync::Weak::::new(), + manager, [1; 32], "192.0.2.10".parse().unwrap(), 1, @@ -35,22 +48,134 @@ fn session_with_limits(limits: WebLimitsConfig) -> Arc { 1, [3; 32], None, + crate::web::manager::CarrierClientClass::Legacy, + None, + false, limits, WebTimeoutsConfig::default(), ) } +fn session_with_manager() -> (Arc, Arc) { + session_with_manager_limits(WebLimitsConfig::default()) +} + +fn session_with_manager_limits( + limits: WebLimitsConfig, +) -> (Arc, Arc) { + let generation = test_runtime_generation(1, ProxyConfig::default()); + let manager = WebProcessRuntime::start(Arc::new(ArcSwap::from(generation))); + let session = new_session(limits, Arc::downgrade(&manager)); + (session, manager) +} + fn session() -> Arc { session_with_limits(WebLimitsConfig::default()) } +#[tokio::test] +async fn early_down_waits_without_creating_a_provisional_lane() { + let (session, manager) = session_with_manager(); + let polling = Arc::clone(&session); + let poll = tokio::spawn(async move { polling.poll_down_lane(7, 0).await }); + while session.state.lock().lane_open_waits == 0 { + tokio::task::yield_now().await; + } + assert!(!session.state.lock().carrier_lanes.contains_key(&7)); + { + let mut state = session.state.lock(); + assert!(insert_carrier_lane(&mut state, 7).is_some()); + state.closed_streams.insert(7); + assert!(session.queue_control_locked(&mut state, FrameType::Close, 7, &[])); + } + session.lane_open_notify.notify_waiters(); + let result = tokio::time::timeout(Duration::from_secs(1), poll) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(!result.body.is_empty()); + assert_eq!(session.state.lock().lane_open_waits, 0); + drop(result); + session.close(); + manager.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn early_down_timeout_is_empty_and_releases_its_session_slot() { + let (session, manager) = session_with_manager(); + let polling = Arc::clone(&session); + let poll = tokio::spawn(async move { polling.poll_down_lane(7, 0).await }); + while session.state.lock().lane_open_waits == 0 { + tokio::task::yield_now().await; + } + tokio::time::advance(Duration::from_secs(3)).await; + let result = poll.await.unwrap().unwrap(); + assert!(result.body.is_empty()); + assert_eq!(result.next_cursor, 0); + assert!(!result.lane_closed); + assert_eq!(session.state.lock().lane_open_waits, 0); + session.close(); + manager.shutdown().await; +} + +#[tokio::test] +async fn early_down_admission_is_bounded_and_cancellation_safe() { + let limits = WebLimitsConfig { + max_lane_open_waits_per_session: 2, + ..WebLimitsConfig::default() + }; + let (session, manager) = session_with_manager_limits(limits); + let mut waits = Vec::new(); + for lane_id in [7, 8] { + let polling = Arc::clone(&session); + waits.push(tokio::spawn(async move { + polling.poll_down_lane(lane_id, 0).await + })); + } + while session.state.lock().lane_open_waits < 2 { + tokio::task::yield_now().await; + } + assert!(matches!( + session.poll_down_lane(9, 0).await, + Err(ManagerError::Limit) + )); + for wait in waits { + wait.abort(); + let _ = wait.await; + } + assert_eq!(session.state.lock().lane_open_waits, 0); + session.close(); + manager.shutdown().await; +} + +#[tokio::test] +async fn session_close_wakes_early_down_with_closed_state() { + let (session, manager) = session_with_manager(); + let polling = Arc::clone(&session); + let poll = tokio::spawn(async move { polling.poll_down_lane(7, 0).await }); + while session.state.lock().lane_open_waits == 0 { + tokio::task::yield_now().await; + } + session.close(); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(1), poll) + .await + .unwrap() + .unwrap(), + Err(ManagerError::Closed) + )); + assert_eq!(session.state.lock().lane_open_waits, 0); + manager.shutdown().await; +} + #[test] fn lane_uplink_sequences_are_independent_and_exactly_once() { let session = session(); { let mut state = session.state.lock(); for lane_id in [51, 52] { - state.carrier_lanes.insert(lane_id, CarrierLane::new()); + state.carrier_lanes.insert(lane_id, CarrierLane::new(u64::from(lane_id))); state.closed_streams.insert(lane_id); } } @@ -75,20 +200,12 @@ fn cross_lane_frame_is_fatal_to_https_lane_session() { #[tokio::test] async fn drained_closed_lane_replays_then_signals_completion() { - let session = session(); + let (session, manager) = session_with_manager(); { let mut state = session.state.lock(); - state.carrier_lanes.insert(7, CarrierLane::new()); + state.carrier_lanes.insert(7, CarrierLane::new(7)); state.closed_streams.insert(7); - let lane = state.carrier_lanes.get_mut(&7).unwrap(); - let encoded = frame::encode(FrameType::Close, 7, &[]); - lane.pending_frames.push_back(QueuedFrame { - encoded: BytesMut::from(encoded.as_ref()), - frame_type: FrameType::Close, - stream_id: 7, - control: true, - cost: frame::HEADER_BYTES + QUEUE_ITEM_COST, - }); + assert!(session.queue_control_locked(&mut state, FrameType::Close, 7, &[])); } let first = session.poll_down_lane(7, 0).await.unwrap(); let replay = session.poll_down_lane(7, 0).await.unwrap(); @@ -97,6 +214,10 @@ async fn drained_closed_lane_replays_then_signals_completion() { let finished = session.poll_down_lane(7, 1).await.unwrap(); assert!(finished.body.is_empty()); assert!(finished.lane_closed); + drop(first); + drop(replay); + session.close(); + manager.shutdown().await; } #[test] @@ -108,7 +229,7 @@ fn tombstone_eviction_releases_lane_budget_and_accepts_late_frames() { let session = session_with_limits(limits); { let mut state = session.state.lock(); - state.carrier_lanes.insert(7, CarrierLane::new()); + state.carrier_lanes.insert(7, CarrierLane::new(7)); let encoded = frame::encode(FrameType::Close, 7, &[]); let cost = encoded.len() + QUEUE_ITEM_COST; state @@ -128,7 +249,7 @@ fn tombstone_eviction_releases_lane_budget_and_accepts_late_frames() { state.pending_control_bytes = cost; state.pending_control_items = 1; session.remember_closed_locked(&mut state, 7); - state.carrier_lanes.insert(8, CarrierLane::new()); + state.carrier_lanes.insert(8, CarrierLane::new(8)); session.remember_closed_locked(&mut state, 8); assert!(!state.carrier_lanes.contains_key(&7)); assert_eq!(state.pending_bytes, 0); diff --git a/src/web/session/lifecycle.rs b/src/web/session/lifecycle.rs index 0add9a2..630320d 100644 --- a/src/web/session/lifecycle.rs +++ b/src/web/session/lifecycle.rs @@ -10,6 +10,20 @@ struct ReleasedQueues { control_items: usize, } +/// Deferred queue release after manager publication linearizes a supersede. +#[must_use] +pub(crate) struct CarrierSupersedeCompletion<'a> { + session: &'a WebSession, + released: ReleasedQueues, +} + +impl CarrierSupersedeCompletion<'_> { + /// Releases process budgets and signals cancellation after manager locks are dropped. + pub(crate) fn finish(self) { + self.session.finish_close(self.released, true); + } +} + impl WebSession { /// Closes carrier state while relay tasks retain their admission until exit. pub(crate) fn close(&self) { @@ -50,14 +64,13 @@ impl WebSession { } } - /// Completes manager-owned replacement without unregistering the old session twice. - pub(crate) fn finish_carrier_supersede(&self) -> bool { - let close_requested = self.state.lock().close_requested; - let Some(released) = self.begin_close(true, None) else { - return close_requested; - }; - self.finish_close(released, true); - close_requested + /// Linearizes manager publication against close requests on the old token. + pub(crate) fn prepare_carrier_supersede(&self) -> Option> { + let released = self.begin_close(true, None)?; + Some(CarrierSupersedeCompletion { + session: self, + released, + }) } /// Waits for all logical-stream tasks after admission has closed. @@ -73,6 +86,13 @@ impl WebSession { /// Atomically closes a session only when reconnect grace is still due. pub(crate) fn close_if_due(&self, now: Instant) -> bool { + let healthy = { + let mut state = self.state.lock(); + self.carrier_health_ready_locked(&mut state, now) + }; + if healthy { + self.finish_carrier_health(); + } let Some(released) = self.begin_close(false, Some(now)) else { return false; }; @@ -82,7 +102,11 @@ impl WebSession { fn begin_close(&self, superseded: bool, idle_now: Option) -> Option { let mut state = self.state.lock(); - if state.closed || (superseded && state.negotiation_phase != SessionNegotiationPhase::Replacing) { + if state.closed + || (superseded + && (state.negotiation_phase != SessionNegotiationPhase::Replacing + || state.close_requested)) + { return None; } if let Some(now) = idle_now @@ -111,10 +135,37 @@ impl WebSession { state.streams.clear(); state.pending_frames.clear(); state.pending_windows.clear(); - state.unacked = None; - for lane in state.carrier_lanes.values() { - lane.notify.notify_waiters(); + if let Some(batch) = state.unacked.take() { + batch.lease.detach(); + self.release_local_locked(&mut state, batch.data_bytes, batch.data_items, false); + self.release_local_locked( + &mut state, + batch.control_bytes, + batch.control_items, + true, + ); } + let mut lane_data_bytes = 0usize; + let mut lane_data_items = 0usize; + let mut lane_control_bytes = 0usize; + let mut lane_control_items = 0usize; + for lane in state.carrier_lanes.values_mut() { + lane.notify.notify_waiters(); + if let Some(batch) = lane.unacked.take() { + batch.lease.detach(); + lane_data_bytes = lane_data_bytes.saturating_add(batch.data_bytes); + lane_data_items = lane_data_items.saturating_add(batch.data_items); + lane_control_bytes = lane_control_bytes.saturating_add(batch.control_bytes); + lane_control_items = lane_control_items.saturating_add(batch.control_items); + } + } + self.release_local_locked(&mut state, lane_data_bytes, lane_data_items, false); + self.release_local_locked( + &mut state, + lane_control_bytes, + lane_control_items, + true, + ); state.carrier_lanes.clear(); let control_bytes = state.pending_control_bytes; let control_items = state.pending_control_items; @@ -137,6 +188,9 @@ impl WebSession { if self.carrier().is_multiplexed() { self.down_notify.notify_waiters(); } + if self.carrier().uses_lanes() { + self.lane_open_notify.notify_waiters(); + } if let Some(manager) = self.manager.upgrade() { manager.release_pending( self.profile_key, diff --git a/src/web/session/negotiation.rs b/src/web/session/negotiation.rs new file mode 100644 index 0000000..fc88320 --- /dev/null +++ b/src/web/session/negotiation.rs @@ -0,0 +1,296 @@ +use std::time::{Duration, Instant}; + +use super::uplink::AppliedProgress; +use super::{SessionNegotiationPhase, SessionState, WebSession}; + +impl WebSession { + /// Returns whether accepted carrier progress made this attempt immutable. + pub(crate) fn is_carrier_committed(&self) -> bool { + self.state.lock().negotiation_phase == SessionNegotiationPhase::Committed + } + + /// Rejects mutation after replacement, supersede, or the final chain deadline. + pub(super) fn ensure_carrier_active_locked( + &self, + state: &SessionState, + ) -> Result<(), crate::web::manager::ManagerError> { + if state.negotiation_phase == SessionNegotiationPhase::Uncommitted + && self + .carrier_deadline_at + .is_some_and(|deadline| Instant::now() >= deadline) + { + return Err(crate::web::manager::ManagerError::Closed); + } + match state.negotiation_phase { + SessionNegotiationPhase::Uncommitted | SessionNegotiationPhase::Committed => Ok(()), + SessionNegotiationPhase::Replacing | SessionNegotiationPhase::Superseded => { + Err(crate::web::manager::ManagerError::Closed) + } + } + } + + /// Publishes the already-linearized session commit to process state. + pub(super) fn finish_carrier_commit(&self) { + if let Some(manager) = self.manager.upgrade() { + manager.carrier_committed( + self.bootstrap_hash, + self.token_hash, + self.carrier_attempt, + self.selected_carrier, + self.carrier_class, + self.client_ip, + self.trace_identity(), + ); + } + } + + /// Publishes complete transport-specific health evidence to process state. + pub(super) fn finish_carrier_health(&self) { + if let Some(manager) = self.manager.upgrade() { + manager.carrier_became_healthy( + self.bootstrap_hash, + self.token_hash, + self.carrier_attempt, + self.selected_carrier, + self.carrier_class, + self.learning_context, + self.client_ip, + self.trace_identity(), + ); + } + } + + /// Records accepted OPEN or DATA progress and returns commit and health transitions. + pub(super) fn record_uplink_progress_locked( + &self, + state: &mut SessionState, + progress: AppliedProgress, + ) -> (bool, bool) { + if !self.automatic_carrier || !progress.any() { + return (false, false); + } + if self.selected_carrier.uses_websocket() { + state.websocket_carrier_active = true; + state.websocket_commit_ack_pending = true; + } else if progress.accepted_data { + state.carrier_health_uplink = true; + } + let now = Instant::now(); + let committed = if state.negotiation_phase == SessionNegotiationPhase::Uncommitted { + state.negotiation_phase = SessionNegotiationPhase::Committed; + state.carrier_health_due_at = Some( + now + Duration::from_secs(self.timeouts.carrier_health_secs), + ); + true + } else { + false + }; + state.carrier_health_activity_at = Some(now); + let healthy = self.carrier_health_ready_locked(state, now); + (committed, healthy) + } + + /// Consumes complete post-commit health evidence at most once. + pub(super) fn carrier_health_ready_locked( + &self, + state: &mut SessionState, + now: Instant, + ) -> bool { + if !self.automatic_carrier + || state.negotiation_phase != SessionNegotiationPhase::Committed + || state.carrier_health_reported + || state.carrier_health_due_at.is_none_or(|due| now < due) + { + return false; + } + let evidence = if state.websocket_carrier_active { + state.websocket_probe_claimed + && state.websocket_commit_ack_owner.is_some() + && state.websocket_commit_ack_written + && state.carrier_health_uplink + } else { + state.carrier_health_uplink + && state.carrier_health_downlink + && state + .carrier_health_activity_at + .zip(state.carrier_health_due_at) + .is_some_and(|(activity, due)| activity >= due) + }; + if evidence { + state.carrier_health_reported = true; + } + evidence + } + + /// Returns whether the exact automatic WebSocket owner must receive a commit acknowledgement. + pub(crate) fn needs_websocket_commit_ack(&self, owner: u64) -> bool { + let state = self.state.lock(); + !state.closed + && state.websocket_commit_ack_owner == Some(owner) + && state.websocket_commit_ack_pending + && !state.websocket_commit_ack_written + } + + /// Records that the commit acknowledgement reached its exact WebSocket owner. + pub(crate) fn websocket_commit_ack_written(&self, owner: u64) -> bool { + let mut state = self.state.lock(); + if state.closed + || state.websocket_commit_ack_owner != Some(owner) + || !state.websocket_commit_ack_pending + { + return false; + } + state.websocket_commit_ack_written = true; + true + } + + /// Records validated binary peer progress after the exact WebSocket acknowledgement. + pub(crate) fn websocket_peer_after_commit_ack(&self, owner: u64) -> bool { + let healthy = { + let mut state = self.state.lock(); + if state.closed + || state.websocket_commit_ack_owner != Some(owner) + || !state.websocket_commit_ack_written + { + return false; + } + state.carrier_health_uplink = true; + let now = Instant::now(); + state.carrier_health_activity_at = Some(now); + self.carrier_health_ready_locked(&mut state, now) + }; + if healthy { + self.finish_carrier_health(); + } + true + } +} + +#[cfg(test)] +mod tests { + use std::net::SocketAddr; + use std::sync::Arc; + + use super::*; + use crate::config::{ + WebCarrier, WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig, + }; + use crate::web::manager::{CarrierClientClass, WebProcessRuntime}; + + fn session(carrier: WebCarrier, deadline: Instant) -> Arc { + let profile = Arc::new(WebRuntimeProfile { + host: "proxy.example.com".to_string(), + public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), + user: "alice".to_string(), + secret_mode: WebSecretMode::Plain, + carrier, + carrier_negotiation_enabled: true, + carrier_learning: false, + carriers: Arc::from([carrier]), + carrier_negotiation_deadlines_secs: [3, 5, 8, 12], + capability: [0; 32], + key_fingerprint: "0000000000000000".to_string(), + max_sessions: 1, + max_streams: 1, + max_streams_per_session: 1, + }); + WebSession::new( + std::sync::Weak::::new(), + [1; 32], + "192.0.2.10".parse().unwrap(), + 1, + profile, + [2; 32], + carrier, + 1, + [3; 32], + Some(deadline), + CarrierClientClass::Bridge, + None, + true, + WebLimitsConfig::default(), + WebTimeoutsConfig::default(), + ) + } + + #[test] + fn final_deadline_refuses_uncommitted_progress() { + let session = session(WebCarrier::Https, Instant::now() - Duration::from_secs(1)); + let state = session.state.lock(); + assert_eq!( + session.ensure_carrier_active_locked(&state), + Err(crate::web::manager::ManagerError::Closed) + ); + assert!(matches!( + state.negotiation_phase, + SessionNegotiationPhase::Uncommitted + )); + } + + #[test] + fn http_health_requires_authenticated_activity_after_the_window() { + let session = session(WebCarrier::Https, Instant::now() + Duration::from_secs(60)); + let now = Instant::now(); + let mut state = session.state.lock(); + state.negotiation_phase = SessionNegotiationPhase::Committed; + state.carrier_health_due_at = Some(now - Duration::from_secs(1)); + state.carrier_health_uplink = true; + state.carrier_health_downlink = true; + state.carrier_health_activity_at = Some(now - Duration::from_secs(2)); + assert!(!session.carrier_health_ready_locked(&mut state, now)); + state.carrier_health_activity_at = Some(now); + assert!(session.carrier_health_ready_locked(&mut state, now)); + } + + #[test] + fn websocket_health_requires_the_exact_live_probe_owner() { + let session = session( + WebCarrier::Websocket, + Instant::now() + Duration::from_secs(60), + ); + let now = Instant::now(); + let mut state = session.state.lock(); + state.negotiation_phase = SessionNegotiationPhase::Committed; + state.carrier_health_due_at = Some(now - Duration::from_secs(1)); + state.websocket_carrier_active = true; + state.websocket_commit_ack_owner = Some(7); + state.websocket_commit_ack_written = true; + state.carrier_health_uplink = true; + assert!(!session.carrier_health_ready_locked(&mut state, now)); + state.websocket_probe_claimed = true; + assert!(session.carrier_health_ready_locked(&mut state, now)); + } + + #[test] + fn commit_and_supersede_have_one_session_lock_winner() { + let committed = session(WebCarrier::Https, Instant::now() + Duration::from_secs(60)); + { + let mut state = committed.state.lock(); + assert!( + committed + .record_uplink_progress_locked( + &mut state, + AppliedProgress { + accepted_open: true, + accepted_data: true, + }, + ) + .0 + ); + } + assert!(!committed.begin_carrier_supersede()); + + let replacing = session(WebCarrier::Https, Instant::now() + Duration::from_secs(60)); + assert!(replacing.begin_carrier_supersede()); + assert_eq!( + replacing.ensure_carrier_active_locked(&replacing.state.lock()), + Err(crate::web::manager::ManagerError::Closed) + ); + replacing.cancel_carrier_supersede(); + assert!( + replacing + .ensure_carrier_active_locked(&replacing.state.lock()) + .is_ok() + ); + } +} diff --git a/src/web/session/uplink.rs b/src/web/session/uplink.rs index 8b5c8fd..19af5e3 100644 --- a/src/web/session/uplink.rs +++ b/src/web/session/uplink.rs @@ -15,6 +15,18 @@ use super::{ use crate::web::frame::{self, Frame, FrameType}; use crate::web::manager::{ManagerError, TokenHash}; +#[derive(Clone, Copy, Default)] +pub(super) struct AppliedProgress { + pub(super) accepted_open: bool, + pub(super) accepted_data: bool, +} + +impl AppliedProgress { + pub(super) fn any(self) -> bool { + self.accepted_open || self.accepted_data + } +} + impl WebSession { /// Applies one exactly-once uplink batch. pub(crate) fn process_up( @@ -22,6 +34,25 @@ impl WebSession { sequence: u64, body: &[u8], ) -> Result { + self.process_up_inner(sequence, body) + .map(|(acknowledged, _)| acknowledged) + } + + /// Applies one WebSocket uplink batch and reports actual carrier progress. + pub(crate) fn process_websocket_multiplex( + self: &Arc, + sequence: u64, + body: &[u8], + ) -> Result { + self.process_up_inner(sequence, body) + .map(|(_, progress)| progress) + } + + fn process_up_inner( + self: &Arc, + sequence: u64, + body: &[u8], + ) -> Result<(u64, bool), ManagerError> { if !self.carrier().is_multiplexed() { return Err(ManagerError::Protocol); } @@ -49,11 +80,9 @@ impl WebSession { return Err(ManagerError::Protocol); } let digest: TokenHash = Sha256::digest(body).into(); - let progress = frames - .iter() - .any(|frame| matches!(frame.frame_type, FrameType::Open | FrameType::Data)); let mut opened = Vec::new(); let mut committed = false; + let mut healthy = false; let result = { let mut state = self.state.lock(); if state.closed { @@ -63,7 +92,7 @@ impl WebSession { state.last_activity = Instant::now(); if sequence == state.last_up_sequence && sequence != 0 { return if bool::from(state.last_up_digest.ct_eq(&digest)) { - Ok(sequence) + Ok((sequence, false)) } else { drop(state); self.close(); @@ -91,6 +120,7 @@ impl WebSession { } let mut unused_bytes = reserve_bytes; let mut unused_items = reserve_items; + let mut progress = AppliedProgress::default(); let applied = self.apply_batch_locked( &mut state, &frames, @@ -98,6 +128,7 @@ impl WebSession { &mut None, &mut unused_bytes, &mut unused_items, + &mut progress, ); self.release_locked(&mut state, unused_bytes, unused_items, false); if !applied { @@ -105,8 +136,9 @@ impl WebSession { } else { state.last_up_sequence = sequence; state.last_up_digest = digest; - committed = self.commit_carrier_locked(&mut state, progress); - Ok(sequence) + (committed, healthy) = + self.record_uplink_progress_locked(&mut state, progress); + Ok((sequence, progress.any())) } }; if matches!(result, Err(ManagerError::Backpressure)) { @@ -120,6 +152,9 @@ impl WebSession { if committed { self.finish_carrier_commit(); } + if healthy { + self.finish_carrier_health(); + } for completion in opened { self.spawn_stream(completion, false); } @@ -137,6 +172,7 @@ impl WebSession { reserved_open: &mut Option<(u32, u16)>, unused_bytes: &mut usize, unused_items: &mut usize, + progress: &mut AppliedProgress, ) -> bool { for value in frames { if value.stream_id == 0 { @@ -186,6 +222,7 @@ impl WebSession { write_waker: None, }, ); + progress.accepted_open = true; opened.push(self.own_stream_task(stream, peer_port)); } FrameType::Data if !was_closed => { @@ -197,6 +234,7 @@ impl WebSession { bytes: Bytes::copy_from_slice(value.payload), offset: 0, }); + progress.accepted_data = true; *unused_bytes = unused_bytes.saturating_sub(value.payload.len() + QUEUE_ITEM_COST); *unused_items = unused_items.saturating_sub(1); @@ -409,6 +447,9 @@ mod tests { 1, [3; 32], None, + crate::web::manager::CarrierClientClass::Legacy, + None, + false, WebLimitsConfig::default(), WebTimeoutsConfig::default(), ) @@ -445,6 +486,7 @@ mod tests { state.streams.insert( 1, StreamState { + instance: 1, inbound: VecDeque::new(), receive_window: frame::INITIAL_STREAM_WINDOW, send_credit: u64::from(frame::INITIAL_STREAM_WINDOW), diff --git a/src/web/session/websocket.rs b/src/web/session/websocket.rs index b16df8a..cd6299c 100644 --- a/src/web/session/websocket.rs +++ b/src/web/session/websocket.rs @@ -3,7 +3,7 @@ use std::time::Instant; use sha2::{Digest, Sha256}; -use super::uplink::{inbound_reservation, validate_batch}; +use super::uplink::{AppliedProgress, inbound_reservation, validate_batch}; use super::{PendingClass, WebSession, inbound_queue_cost, insert_carrier_lane}; use crate::config::WebCarrier; use crate::web::frame; @@ -17,6 +17,43 @@ pub(crate) struct WebSocketLaneReservation { transferred: bool, } +/// Session-wide ownership of the only automatic WebSocket carrier probe. +pub(crate) struct WebSocketProbeReservation { + session: Arc, + owner: Option, +} + +impl WebSocketProbeReservation { + /// Binds the admitted process connection to the future commit acknowledgement. + pub(crate) fn bind(&mut self, owner: u64) -> Result<(), ManagerError> { + let mut state = self.session.state.lock(); + if state.closed + || !state.websocket_probe_claimed + || state.websocket_commit_ack_owner.is_some() + { + return Err(ManagerError::Closed); + } + state.websocket_commit_ack_owner = Some(owner); + self.owner = Some(owner); + Ok(()) + } +} + +impl Drop for WebSocketProbeReservation { + fn drop(&mut self) { + let mut state = self.session.state.lock(); + state.websocket_probe_claimed = false; + if state.websocket_commit_ack_owner == self.owner { + state.websocket_commit_ack_owner = None; + if !state.carrier_health_reported { + state.websocket_commit_ack_written = false; + state.carrier_health_uplink = false; + state.carrier_health_activity_at = None; + } + } + } +} + impl WebSocketLaneReservation { /// Returns the logical stream owned by this connection. pub(crate) fn lane_id(&self) -> u32 { @@ -46,6 +83,42 @@ impl Drop for WebSocketLaneReservation { } impl WebSession { + /// Reserves the only automatic WebSocket probe before any HTTP 101 response. + pub(crate) fn reserve_websocket_probe( + self: &Arc, + acknowledge_commit: bool, + ) -> Result, ManagerError> { + let mut state = self.state.lock(); + if state.closed { + return Err(ManagerError::Closed); + } + self.ensure_carrier_active_locked(&state)?; + if !self.automatic_carrier { + return if acknowledge_commit { + Err(ManagerError::Protocol) + } else { + Ok(None) + }; + } + match state.negotiation_phase { + super::SessionNegotiationPhase::Uncommitted if acknowledge_commit => { + if state.websocket_probe_claimed || state.websocket_commit_ack_owner.is_some() { + return Err(ManagerError::Concurrent); + } + state.websocket_probe_claimed = true; + Ok(Some(WebSocketProbeReservation { + session: Arc::clone(self), + owner: None, + })) + } + super::SessionNegotiationPhase::Committed if !acknowledge_commit => Ok(None), + super::SessionNegotiationPhase::Committed => Err(ManagerError::Committed), + super::SessionNegotiationPhase::Uncommitted => Err(ManagerError::Protocol), + super::SessionNegotiationPhase::Replacing + | super::SessionNegotiationPhase::Superseded => Err(ManagerError::Closed), + } + } + /// Acquires stream quota and tuple ownership before a lane returns HTTP 101. pub(crate) fn reserve_websocket_lane( self: &Arc, @@ -63,6 +136,7 @@ impl WebSession { } if state.active_peer_ports.len() >= self.profile.max_streams_per_session || state.streams.contains_key(&lane_id) + || state.closing_streams.contains_key(&lane_id) || state.closed_streams.contains(&lane_id) || state.websocket_lane_reservations.contains_key(&lane_id) { @@ -116,7 +190,7 @@ impl WebSession { reservation: &mut WebSocketLaneReservation, sequence: u64, body: &[u8], - ) -> Result<(), ManagerError> { + ) -> Result { if !Arc::ptr_eq(self, &reservation.session) || reservation.lane_id == 0 || reservation.lane_id > frame::MAX_STREAM_ID @@ -133,11 +207,9 @@ impl WebSession { return Err(ManagerError::Protocol); } let digest = Sha256::digest(body).into(); - let progress = frames - .iter() - .any(|frame| matches!(frame.frame_type, frame::FrameType::Open | frame::FrameType::Data)); let mut opened = Vec::new(); let mut committed = false; + let mut healthy = false; let result = { let mut state = self.state.lock(); if state.closed { @@ -179,6 +251,7 @@ impl WebSession { } let mut unused_bytes = reserve_bytes; let mut unused_items = reserve_items; + let mut progress = AppliedProgress::default(); let mut reserved_open = (!reservation.transferred).then_some((lane_id, reservation.peer_port)); let applied = self.apply_batch_locked( @@ -188,6 +261,7 @@ impl WebSession { &mut reserved_open, &mut unused_bytes, &mut unused_items, + &mut progress, ); self.release_locked(&mut state, unused_bytes, unused_items, false); if let Some(lane) = state.carrier_lanes.get_mut(&lane_id) { @@ -199,14 +273,20 @@ impl WebSession { } state.last_activity = Instant::now(); if applied { - committed = self.commit_carrier_locked(&mut state, progress); + (committed, healthy) = + self.record_uplink_progress_locked(&mut state, progress); } - applied.then_some(()).ok_or(ManagerError::Protocol) + applied + .then_some(progress.any()) + .ok_or(ManagerError::Protocol) }; - result?; + let progressed = result?; if committed { self.finish_carrier_commit(); } + if healthy { + self.finish_carrier_health(); + } for completion in opened { if completion.stream.id != lane_id || completion.peer_port != reservation.peer_port { return Err(ManagerError::Protocol); @@ -222,7 +302,7 @@ impl WebSession { if let Some(manager) = self.manager.upgrade() { manager.record_up(body.len()); } - Ok(()) + Ok(progressed) } /// Ends one failed or disconnected lane without closing its parent session. diff --git a/src/web/session/websocket/tests.rs b/src/web/session/websocket/tests.rs index 659a836..7b2ab6b 100644 --- a/src/web/session/websocket/tests.rs +++ b/src/web/session/websocket/tests.rs @@ -68,6 +68,9 @@ fn runtime(admission: bool) -> TestRuntime { 1, [9; 32], None, + crate::web::manager::CarrierClientClass::Legacy, + None, + false, limits, timeouts, ); diff --git a/src/web/trace/types.rs b/src/web/trace/types.rs index 4bf0c98..175982a 100644 --- a/src/web/trace/types.rs +++ b/src/web/trace/types.rs @@ -236,8 +236,10 @@ pub(crate) enum TraceLifecycleEvent { CarrierFailed, /// An uncommitted carrier session was atomically superseded. CarrierSuperseded, - /// The first OPEN or DATA batch made a carrier immutable. + /// Bidirectional carrier evidence made replacement unsafe. CarrierCommitted, + /// A committed carrier survived its configured health interval. + CarrierHealthy, /// A new session was created. SessionCreated, /// An idempotent session creation was replayed. @@ -279,6 +281,7 @@ impl TraceLifecycleEvent { Self::CarrierFailed => "carrier_failed", Self::CarrierSuperseded => "carrier_superseded", Self::CarrierCommitted => "carrier_committed", + Self::CarrierHealthy => "carrier_healthy", Self::SessionCreated => "session_created", Self::SessionReplayed => "session_replayed", Self::SessionRejected => "session_rejected",