mirror of
https://github.com/telemt/telemt.git
synced 2026-09-05 18:16:06 +03:00
WEB Carriers Safe-matrix Refactored
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
@@ -65,7 +65,8 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
secret_mode: profile.secret_mode,
|
secret_mode: profile.secret_mode,
|
||||||
carrier: config.web.carrier,
|
carrier: config.web.carrier,
|
||||||
carrier_negotiation_enabled: config.web.carrier_negotiation_enabled(),
|
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),
|
carriers: Arc::clone(&carrier_candidates),
|
||||||
carrier_negotiation_deadlines_secs: config
|
carrier_negotiation_deadlines_secs: config
|
||||||
.web
|
.web
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
validate_limits(&config.web.limits)?;
|
validate_limits(&config.web.limits)?;
|
||||||
debug::validate(&config.web.debug, &config.web.limits)?;
|
debug::validate(&config.web.debug, &config.web.limits)?;
|
||||||
let carriers = negotiation::validate(&config.web)?;
|
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 {
|
if carriers.contains(&WebCarrier::HttpsLanes) && config.web.limits.max_http_handlers < 4 {
|
||||||
return config_error(
|
return config_error(
|
||||||
"WEB https-lanes candidates require web.limits.max_http_handlers >= 4",
|
"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) {
|
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]");
|
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
|
if limits.carrier_batch_bytes > limits.max_body_bytes
|
||||||
|| limits.carrier_batch_bytes
|
|| limits.carrier_batch_bytes
|
||||||
< limits
|
< limits
|
||||||
@@ -248,6 +256,7 @@ fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
|
|||||||
|| limits.max_bootstraps_per_ip > limits.max_bootstraps_global
|
|| limits.max_bootstraps_per_ip > limits.max_bootstraps_global
|
||||||
|| limits.max_http_handlers > limits.max_http_connections
|
|| limits.max_http_handlers > limits.max_http_connections
|
||||||
|| limits.max_body_readers > limits.max_http_handlers
|
|| 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_bytes_per_session > limits.pending_bytes_global
|
||||||
|| limits.pending_items_per_session > limits.pending_items_global
|
|| limits.pending_items_per_session > limits.pending_items_global
|
||||||
|| limits.pending_bytes_per_lane > limits.pending_bytes_per_session
|
|| limits.pending_bytes_per_lane > limits.pending_bytes_per_session
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ use super::*;
|
|||||||
const WEB_DEBUG_RENDERERS: usize = 2;
|
const WEB_DEBUG_RENDERERS: usize = 2;
|
||||||
const WEB_DEBUG_STATUS_PAGE_BYTES: usize = 8 * 1024 * 1024;
|
const WEB_DEBUG_STATUS_PAGE_BYTES: usize = 8 * 1024 * 1024;
|
||||||
const WEB_DEBUG_GROUP_SCRATCH_BYTES: usize = 4 * 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.
|
/// Validates process-wide body, header, queue, static, and debug reservations.
|
||||||
pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
|
pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
|
||||||
@@ -57,6 +58,15 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
|
|||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
ProxyError::Config("web.carrier learning reservation overflowed usize".to_string())
|
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
|
let reserved = limits
|
||||||
.pending_bytes_global
|
.pending_bytes_global
|
||||||
.checked_add(limits.max_body_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(status_pages))
|
||||||
.and_then(|value| value.checked_add(debug_reservation))
|
.and_then(|value| value.checked_add(debug_reservation))
|
||||||
.and_then(|value| value.checked_add(carrier_learning_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))
|
.and_then(|value| value.checked_add(http_header_reservation))
|
||||||
.ok_or_else(|| ProxyError::Config("web.limits byte ceilings overflow usize".to_string()))?;
|
.ok_or_else(|| ProxyError::Config("web.limits byte ceilings overflow usize".to_string()))?;
|
||||||
if reserved > limits.memory_envelope_bytes
|
if reserved > limits.memory_envelope_bytes
|
||||||
@@ -76,3 +87,20 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ pub(super) fn validate(config: &WebConfig) -> Result<Vec<WebCarrier>> {
|
|||||||
"web.timeouts.carrier_negotiation_deadlines_secs must be non-zero and strictly increasing",
|
"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(
|
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)
|
Ok(candidates)
|
||||||
@@ -112,5 +115,9 @@ mod tests {
|
|||||||
assert!(validate(&config).is_err());
|
assert!(validate(&config).is_err());
|
||||||
config.timeouts.carrier_negotiation_deadlines_secs = [3, 5, 8, 121];
|
config.timeouts.carrier_negotiation_deadlines_secs = [3, 5, 8, 121];
|
||||||
assert!(validate(&config).is_err());
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,27 @@ pub(super) fn validate(timeouts: &WebTimeoutsConfig) -> Result<()> {
|
|||||||
{
|
{
|
||||||
return config_error(&format!("web.timeouts.{field} must be within [1, 3600]"));
|
return config_error(&format!("web.timeouts.{field} must be within [1, 3600]"));
|
||||||
}
|
}
|
||||||
if timeouts.carrier_learning_secs == 0 {
|
if !(2..=86_400).contains(&timeouts.carrier_learning_secs) {
|
||||||
return config_error("web.timeouts.carrier_learning_secs must be > 0");
|
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
|
let request_deadline = timeouts
|
||||||
.header_secs
|
.header_secs
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ pub(super) fn validate(
|
|||||||
"WebSocket carriers require websocket_http_connection_reserve within [1, max_http_connections)",
|
"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
|
let socket_base = WEBSOCKET_IO_BUFFER_BYTES
|
||||||
.checked_mul(2)
|
.checked_mul(2)
|
||||||
.and_then(|value| value.checked_add(WEBSOCKET_DRIVER_OVERHEAD_BYTES))
|
.and_then(|value| value.checked_add(WEBSOCKET_DRIVER_OVERHEAD_BYTES))
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ fn web_config_builds_canonical_runtime_snapshot() {
|
|||||||
fn web_carriers_missing_or_false_disable_negotiation() {
|
fn web_carriers_missing_or_false_disable_negotiation() {
|
||||||
let missing = load_config_from_temp_toml(WEB_CONFIG);
|
let missing = load_config_from_temp_toml(WEB_CONFIG);
|
||||||
assert!(!missing.web.carrier_negotiation_enabled());
|
assert!(!missing.web.carrier_negotiation_enabled());
|
||||||
|
assert!(!missing.web.runtime.unwrap().profiles[0].carrier_learning);
|
||||||
|
|
||||||
let disabled = WEB_CONFIG.replace(
|
let disabled = WEB_CONFIG.replace(
|
||||||
"carrier = \"https-lanes\"",
|
"carrier = \"https-lanes\"",
|
||||||
@@ -64,6 +65,7 @@ fn web_carriers_missing_or_false_disable_negotiation() {
|
|||||||
);
|
);
|
||||||
let disabled = load_config_from_temp_toml(&disabled);
|
let disabled = load_config_from_temp_toml(&disabled);
|
||||||
assert!(!disabled.web.carrier_negotiation_enabled());
|
assert!(!disabled.web.carrier_negotiation_enabled());
|
||||||
|
assert!(!disabled.web.runtime.as_ref().unwrap().profiles[0].carrier_learning);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
disabled.web.runtime.unwrap().profiles[0].carriers.as_ref(),
|
disabled.web.runtime.unwrap().profiles[0].carriers.as_ref(),
|
||||||
[WebCarrier::HttpsLanes]
|
[WebCarrier::HttpsLanes]
|
||||||
|
|||||||
@@ -101,10 +101,10 @@ pub struct WebLimitsConfig {
|
|||||||
/// Per-session ceiling for downlink polls waiting for a lane OPEN.
|
/// Per-session ceiling for downlink polls waiting for a lane OPEN.
|
||||||
#[serde(default = "default_web_max_lane_open_waits_per_session")]
|
#[serde(default = "default_web_max_lane_open_waits_per_session")]
|
||||||
pub max_lane_open_waits_per_session: usize,
|
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")]
|
#[serde(default = "default_web_pending_bytes_per_lane")]
|
||||||
pub pending_bytes_per_lane: usize,
|
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")]
|
#[serde(default = "default_web_pending_items_per_lane")]
|
||||||
pub pending_items_per_lane: usize,
|
pub pending_items_per_lane: usize,
|
||||||
/// Process-wide transient WebSocket byte sub-budget inside pending bytes.
|
/// 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.
|
/// Process-wide retained and in-flight WEB debug byte ceiling.
|
||||||
#[serde(default = "default_web_debug_bytes_global")]
|
#[serde(default = "default_web_debug_bytes_global")]
|
||||||
pub debug_bytes_global: usize,
|
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")]
|
#[serde(default = "default_web_memory_envelope_bytes")]
|
||||||
pub memory_envelope_bytes: usize,
|
pub memory_envelope_bytes: usize,
|
||||||
/// Sustained process-wide bootstrap issuance rate.
|
/// Sustained process-wide bootstrap issuance rate.
|
||||||
@@ -414,10 +414,12 @@ impl WebConfig {
|
|||||||
let Some(configured) = self.carriers.enabled() else {
|
let Some(configured) = self.carriers.enabled() else {
|
||||||
return vec![self.carrier];
|
return vec![self.carrier];
|
||||||
};
|
};
|
||||||
let mut candidates = configured.to_vec();
|
let mut candidates = configured
|
||||||
if !candidates.contains(&self.carrier) {
|
.iter()
|
||||||
candidates.push(self.carrier);
|
.copied()
|
||||||
}
|
.filter(|carrier| *carrier != self.carrier)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
candidates.push(self.carrier);
|
||||||
candidates
|
candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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_max_static_bytes, 64 * 1024 * 1024);
|
||||||
usize_default!(default_web_debug_records_capacity, 65_536);
|
usize_default!(default_web_debug_records_capacity, 65_536);
|
||||||
usize_default!(default_web_debug_bytes_global, 64 * 1024 * 1024);
|
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_per_minute, 1200);
|
||||||
u32_default!(default_web_new_bootstraps_burst, 256);
|
u32_default!(default_web_new_bootstraps_burst, 256);
|
||||||
u32_default!(default_web_new_sessions_per_minute, 600);
|
u32_default!(default_web_new_sessions_per_minute, 600);
|
||||||
|
|||||||
+88
-35
@@ -71,24 +71,29 @@ const DOCUMENT: &str = r##"<!doctype html>
|
|||||||
const bootstrap="__BOOTSTRAP__";
|
const bootstrap="__BOOTSTRAP__";
|
||||||
const relayOrigin='https://__HOST__',carrierCapabilities='https,https-lanes,websocket,websocket-lanes';
|
const relayOrigin='https://__HOST__',carrierCapabilities='https,https-lanes,websocket,websocket-lanes';
|
||||||
const negotiationEnabled=__NEGOTIATION_ENABLED__,candidateCount=__CANDIDATE_COUNT__,candidateDeadlines=[__CARRIER_DEADLINES__];
|
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 batchLimit=__BATCH_LIMIT__,queueLimit=__QUEUE_LIMIT__,queueItemLimit=__QUEUE_ITEMS__;
|
||||||
const laneQueueLimit=Math.min(queueLimit,8388608),laneItemLimit=Math.min(queueItemLimit,1024),closedLaneLimit=4096;
|
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]||'';
|
const fragment=location.hash,androidNonce=/^#android=([A-Za-z0-9_-]{43})$/.exec(fragment)?.[1]||'';
|
||||||
history.replaceState(null,'',location.pathname);
|
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 queuedBytes=0,queuedItems=0,upSequence=1,downCursor='0',upRunning=false,pollController=null;
|
||||||
let helloFrame=null,welcomeSent=false,carrierAttempt=1,carrierFailure='',carrierCommitted=false;
|
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 pending=[],upPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[];
|
||||||
const status=state=>{if(port&&!closed)port.postMessage({t:'status',state})};
|
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 socketURL=()=>relayOrigin.replace(/^https:/,'wss:')+'/api/v1/ws';
|
||||||
const options=(method,token,body,headers,signal,keepalive)=>({
|
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',
|
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||{})
|
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){
|
function reserve(data,lane){
|
||||||
let buffered=socket?socket.bufferedAmount:0;for(const value of lanes.values())if(value.socket)buffered+=value.socket.bufferedAmount;
|
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;
|
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}
|
if(Number.isFinite(when)){const delta=when-Date.now();return delta>0?Math.min(delta,30000):0}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
async function request(path,makeOptions){
|
async function request(path,frozenOptions){
|
||||||
let delay=250,attempt=0;const deadline=Date.now()+90000;
|
let delay=250,attempt=0;const deadline=Date.now()+90000;
|
||||||
while(true){
|
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});
|
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;
|
let serviceUnavailable=false,wait=0;
|
||||||
try{
|
try{
|
||||||
const response=await fetch(relayOrigin+path,requestOptions);
|
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');
|
if(++attempt===9)throw new Error('carrier retry limit reached');
|
||||||
}finally{clearTimeout(timer);if(external)external.removeEventListener('abort',abort)}
|
}finally{clearTimeout(timer);if(external)external.removeEventListener('abort',abort)}
|
||||||
if(serviceUnavailable&&Date.now()>=deadline)throw new Error('carrier retry limit reached');
|
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);
|
if(!serviceUnavailable)delay=Math.min(delay*2,5000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function fail(){if(closed)return;status('failed');if(port)port.postMessage({t:'close'});close(true)}
|
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 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){
|
function armCarrierDeadline(epoch){
|
||||||
if(!negotiationStartedAt||epoch!==attemptEpoch)return;
|
if(!negotiationStartedAt||epoch!==attemptEpoch)return;
|
||||||
if(carrierTimer)clearTimeout(carrierTimer);
|
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));
|
carrierTimer=setTimeout(()=>advanceCarrier('timeout',epoch),Math.max(0,remaining));
|
||||||
}
|
}
|
||||||
function resetCandidate(){
|
function resetCandidate(){
|
||||||
@@ -198,21 +220,48 @@ function resetCandidate(){
|
|||||||
if(socket){const previous=socket;socket=null;previous.close()}socketReady=false;
|
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()}
|
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;
|
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){
|
function advanceCarrier(reason,epoch){
|
||||||
if(closed||carrierCommitted||epoch!==attemptEpoch||switching)return;
|
if(closed||carrierCommitted||epoch!==attemptEpoch||switching)return;
|
||||||
|
if(!negotiationEnabled){fail();return}
|
||||||
switching=true;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
|
switching=true;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
|
||||||
if(attemptController)attemptController.abort();attemptController=null;
|
const snapshot=currentAttempt;if(attemptController)attemptController.abort();attemptController=null;
|
||||||
resetCandidate();
|
if(!snapshot||snapshot.epoch!==epoch){switching=false;fail();return}
|
||||||
if(carrierAttempt>=candidateCount||Date.now()>=negotiationStartedAt+effectiveDeadlines[effectiveDeadlines.length-1]*1000){switching=false;fail();return}
|
resolveAttempt(reason,epoch,snapshot);
|
||||||
carrierAttempt++;carrierFailure=reason;attemptEpoch++;const nextEpoch=attemptEpoch;switching=false;
|
}
|
||||||
status('reconnecting');armCarrierDeadline(nextEpoch);createSession(nextEpoch);
|
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(){
|
function maybeStartCandidate(){
|
||||||
let probe;try{probe=findProbe()}catch(error){fail();return}
|
let probe;try{probe=findProbe()}catch(error){fail();return}
|
||||||
if(!probe||closed||carrierCommitted)return;
|
if(!probe||closed||carrierCommitted)return;
|
||||||
if(negotiationEnabled&&!negotiationStartedAt){negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch)}
|
|
||||||
if(!sessionToken||candidateRunning)return;
|
if(!sessionToken||candidateRunning)return;
|
||||||
candidateRunning=true;const epoch=attemptEpoch;
|
candidateRunning=true;const epoch=attemptEpoch;
|
||||||
if(carrier==='https')probeHttp(probe,null,epoch);
|
if(carrier==='https')probeHttp(probe,null,epoch);
|
||||||
@@ -222,26 +271,28 @@ function maybeStartCandidate(){
|
|||||||
else advanceCarrier('protocol',epoch);
|
else advanceCarrier('protocol',epoch);
|
||||||
}
|
}
|
||||||
async function createSession(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{
|
try{
|
||||||
status('connecting');
|
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(closed||epoch!==attemptEpoch){await response.arrayBuffer();return}
|
||||||
if(response.status!==200){await response.arrayBuffer();if(negotiationStartedAt)advanceCarrier('http',epoch);else fail();return}
|
if(response.status===409){sessionEcho(response,attempt,['committed','healthy'],false);await response.arrayBuffer();fail();return}
|
||||||
const selected=response.headers.get('X-Carrier-Mode')||'',echo=response.headers.get('X-Carrier-Attempt')||'';
|
if(response.status!==200){await response.arrayBuffer();advanceCarrier('http',epoch);return}
|
||||||
if(!knownCarrier(selected)||(negotiationEnabled?echo!==String(carrierAttempt):echo!=='')){await response.arrayBuffer();if(negotiationStartedAt)advanceCarrier('protocol',epoch);else fail();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')||'';
|
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;
|
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])}
|
if(!welcomeSent){welcomeSent=true;port.postMessage(welcome,[welcome])}
|
||||||
maybeStartCandidate();
|
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){
|
async function probeHttp(probe,laneID,epoch){
|
||||||
try{
|
try{
|
||||||
const headers={'X-Up-Seq':'1'};if(laneID!==null)headers['X-Lane-ID']=String(laneID);
|
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',sessionToken,probe.data,headers,attemptController.signal));
|
const response=await request('/api/v1/up',options('POST',token,body,headers,controller.signal));
|
||||||
if(closed||epoch!==attemptEpoch){await response.arrayBuffer();return}
|
if(closed||epoch!==attemptEpoch){await response.arrayBuffer();return}
|
||||||
if(response.status!==204){await response.arrayBuffer();advanceCarrier('http',epoch);return}
|
if(response.status!==204){await response.arrayBuffer();advanceCarrier('http',epoch);return}
|
||||||
if(response.headers.get('X-Up-Ack')!=='1'){advanceCarrier('protocol',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){
|
function commitCarrier(probe,epoch){
|
||||||
if(closed||carrierCommitted||epoch!==attemptEpoch)return;
|
if(closed||carrierCommitted||epoch!==attemptEpoch)return;
|
||||||
|
if(switching){fail();return}
|
||||||
try{consumeProbe(probe)}catch(error){fail();return}
|
try{consumeProbe(probe)}catch(error){fail();return}
|
||||||
carrierCommitted=true;candidateRunning=false;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
|
carrierCommitted=true;candidateRunning=false;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
|
||||||
|
attemptController=null;currentAttempt=null;
|
||||||
status('connected');
|
status('connected');
|
||||||
if(carrier==='https')poll();
|
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)}
|
for(const data of pending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)}
|
||||||
}
|
}
|
||||||
function queueCarrier(data){
|
function queueCarrier(data){
|
||||||
@@ -271,7 +324,7 @@ async function runUp(){
|
|||||||
try{
|
try{
|
||||||
while(!closed&&sessionToken&&upPending.length){
|
while(!closed&&sessionToken&&upPending.length){
|
||||||
const batch=joinPending(upPending,null),sequence=String(upSequence);
|
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');
|
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++;
|
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()}
|
finally{upRunning=false;if(!closed&&sessionToken&&upPending.length)runUp()}
|
||||||
}
|
}
|
||||||
function openCandidateSocket(probe,laneID,epoch){
|
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;
|
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}
|
if(laneID===null)socket=next;else{lane=ensureLane(laneID);lane.socket=next}
|
||||||
next.onopen=()=>{
|
next.onopen=()=>{
|
||||||
@@ -322,7 +375,7 @@ async function poll(){
|
|||||||
while(!closed&&sessionToken){
|
while(!closed&&sessionToken){
|
||||||
try{
|
try{
|
||||||
pollController=new AbortController();
|
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===204){status('connected');continue}
|
||||||
if(response.status!==200)throw new Error('downlink rejected');
|
if(response.status!==200)throw new Error('downlink rejected');
|
||||||
const next=response.headers.get('X-Down-Cursor')||'',data=await response.arrayBuffer();
|
const next=response.headers.get('X-Down-Cursor')||'',data=await response.arrayBuffer();
|
||||||
@@ -386,7 +439,7 @@ async function runLaneUp(lane){
|
|||||||
try{
|
try{
|
||||||
while(!closed&&sessionToken&&lane.pending.length){
|
while(!closed&&sessionToken&&lane.pending.length){
|
||||||
const batch=joinPending(lane.pending,lane),sequence=String(lane.sequence),laneID=String(lane.id);
|
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');
|
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++;
|
release(batch.total,batch.count,lane);port.postMessage({t:'traffic',up:batch.total,down:0});lane.sequence++;
|
||||||
if(!lane.polling)pollLane(lane);
|
if(!lane.polling)pollLane(lane);
|
||||||
@@ -399,7 +452,7 @@ async function pollLane(lane){
|
|||||||
try{
|
try{
|
||||||
while(!closed&&sessionToken&&lanes.get(lane.id)===lane){
|
while(!closed&&sessionToken&&lanes.get(lane.id)===lane){
|
||||||
const controller=new AbortController(),laneID=String(lane.id);lane.controller=controller;
|
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.status===204){
|
||||||
if(response.headers.get('X-Lane-Closed')==='1'){finishLane(lane,false);return}
|
if(response.headers.get('X-Lane-Closed')==='1'){finishLane(lane,false);return}
|
||||||
status('connected');continue;
|
status('connected');continue;
|
||||||
@@ -415,7 +468,7 @@ async function pollLane(lane){
|
|||||||
finally{lane.polling=false;lane.controller=null}
|
finally{lane.polling=false;lane.controller=null}
|
||||||
}
|
}
|
||||||
function deleteSession(){
|
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){
|
function close(notifyServer){
|
||||||
if(closed)return;closed=true;if(carrierTimer)clearTimeout(carrierTimer);if(attemptController)attemptController.abort();if(pollController)pollController.abort();
|
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;
|
initialized=true;port=nextPort;
|
||||||
port.onmessage=message=>{
|
port.onmessage=message=>{
|
||||||
if(message.data instanceof ArrayBuffer){
|
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 if(!carrierCommitted){if(!reserve(message.data,null)){fail();return}pending.push(message.data);maybeStartCandidate()}
|
||||||
else queueCarrier(message.data);
|
else queueCarrier(message.data);
|
||||||
}else if(message.data&&message.data.t==='close')close(true);
|
}else if(message.data&&message.data.t==='close')close(true);
|
||||||
|
|||||||
+34
-4
@@ -49,9 +49,8 @@ fn effective_deadline_formula_uses_the_final_checkpoint() {
|
|||||||
"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
|
"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
|
||||||
3,
|
3,
|
||||||
);
|
);
|
||||||
assert!(page.body.contains(
|
assert!(page.body.contains("negotiatedFinalDeadline=candidateDeadlines[3]"));
|
||||||
"candidateDeadlines.slice(0,candidateCount-1).concat(candidateDeadlines[3])"
|
assert!(page.body.contains("carrierAttempt>=negotiatedCandidateCount?negotiatedFinalDeadline"));
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -68,9 +67,40 @@ fn disabled_negotiation_does_not_arm_a_carrier_deadline() {
|
|||||||
&SecureRandom::new(),
|
&SecureRandom::new(),
|
||||||
);
|
);
|
||||||
assert!(page.body.contains(
|
assert!(page.body.contains(
|
||||||
"if(negotiationEnabled&&!negotiationStartedAt)"
|
"if(negotiationEnabled){negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch)}"
|
||||||
));
|
));
|
||||||
assert!(page.body.contains(
|
assert!(page.body.contains(
|
||||||
"negotiationEnabled?'tproxy-auto-v1.':'tproxy-v1.'"
|
"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"));
|
||||||
|
}
|
||||||
|
|||||||
+60
-8
@@ -50,7 +50,8 @@ use decoy::serve_decoy;
|
|||||||
use down::handle_down;
|
use down::handle_down;
|
||||||
use request::{
|
use request::{
|
||||||
bearer_token_hash, binary_content_type, bridge_candidate, canonical_request_host,
|
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::{
|
use response::{
|
||||||
bad_gateway, carrier_empty, carrier_headers, carrier_lane, full_response, generic_not_found,
|
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 {
|
let Some(carrier_request) = carrier_request(&request, &vhost.host) else {
|
||||||
return serve_decoy(request, vhost, true, &runtime).await;
|
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)) =
|
let Some((trace_session_id, profile)) =
|
||||||
runtime.bootstrap_trace_identity(token_hash, &vhost.host)
|
runtime.bootstrap_trace_identity(token_hash, &vhost.host)
|
||||||
else {
|
else {
|
||||||
@@ -391,6 +393,7 @@ async fn handle_session(
|
|||||||
client_ip,
|
client_ip,
|
||||||
&body,
|
&body,
|
||||||
carrier_request,
|
carrier_request,
|
||||||
|
ip_learning_eligible,
|
||||||
) {
|
) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let welcome = frame::encode(FrameType::Welcome, 0, &[]);
|
let welcome = frame::encode(FrameType::Welcome, 0, &[]);
|
||||||
@@ -424,6 +427,60 @@ async fn handle_session(
|
|||||||
&attempt.to_string(),
|
&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
|
response
|
||||||
}
|
}
|
||||||
Err(
|
Err(
|
||||||
@@ -479,12 +536,7 @@ async fn handle_up(
|
|||||||
let Some(lane_id) = carrier_lane(&request, session.carrier()) else {
|
let Some(lane_id) = carrier_lane(&request, session.carrier()) else {
|
||||||
return serve_decoy(request, vhost, true, &runtime).await;
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
};
|
};
|
||||||
let limit = runtime
|
let limit = session.limits().max_body_bytes;
|
||||||
.active_generation()
|
|
||||||
.config()
|
|
||||||
.web
|
|
||||||
.limits
|
|
||||||
.max_body_bytes;
|
|
||||||
let CollectedBody {
|
let CollectedBody {
|
||||||
request,
|
request,
|
||||||
body,
|
body,
|
||||||
@@ -500,7 +552,7 @@ async fn handle_up(
|
|||||||
trace.record_frames(
|
trace.record_frames(
|
||||||
TraceDirection::Request,
|
TraceDirection::Request,
|
||||||
&body,
|
&body,
|
||||||
&runtime.active_generation().config().web.limits,
|
session.limits(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let result = match lane_id {
|
let result = match lane_id {
|
||||||
|
|||||||
@@ -56,9 +56,11 @@ pub(super) async fn handle_down(
|
|||||||
if !body.is_empty() {
|
if !body.is_empty() {
|
||||||
return serve_decoy(request, vhost, true, &runtime).await;
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
}
|
}
|
||||||
let _lane_poll = if lane_id.is_some() {
|
let Some(_down_poll) = runtime.try_lane_poll(false) else {
|
||||||
let auxiliary = lane_id.is_some_and(|lane_id| session.lane_poll_is_auxiliary(lane_id));
|
return service_unavailable();
|
||||||
let Some(permit) = runtime.try_lane_poll(auxiliary) else {
|
};
|
||||||
|
let _control_lane_poll = if lane_id == Some(0) {
|
||||||
|
let Some(permit) = runtime.try_lane_poll(true) else {
|
||||||
return service_unavailable();
|
return service_unavailable();
|
||||||
};
|
};
|
||||||
Some(permit)
|
Some(permit)
|
||||||
@@ -90,7 +92,7 @@ pub(super) async fn handle_down(
|
|||||||
trace.record_frames(
|
trace.record_frames(
|
||||||
TraceDirection::Response,
|
TraceDirection::Response,
|
||||||
&result.body,
|
&result.body,
|
||||||
&runtime.active_generation().config().web.limits,
|
session.limits(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let mut response = full_response(StatusCode::OK, result.body);
|
let mut response = full_response(StatusCode::OK, result.body);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
const CAPABILITIES: &str = "https,https-lanes,websocket,websocket-lanes";
|
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]
|
#[tokio::test]
|
||||||
async fn negotiation_replays_replaces_and_freezes_after_carrier_commit() {
|
async fn negotiation_replays_replaces_and_freezes_after_carrier_commit() {
|
||||||
let capability = [42; 32];
|
let capability = [42; 32];
|
||||||
let config = negotiation_runtime_config(
|
let mut config = negotiation_runtime_config(
|
||||||
capability,
|
capability,
|
||||||
WebCarrier::Websocket,
|
WebCarrier::Websocket,
|
||||||
false,
|
false,
|
||||||
@@ -105,6 +107,7 @@ async fn negotiation_replays_replaces_and_freezes_after_carrier_commit() {
|
|||||||
WebCarrier::Websocket,
|
WebCarrier::Websocket,
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
config.web.timeouts.long_poll_secs = 1;
|
||||||
let generation = test_runtime_generation(1, config);
|
let generation = test_runtime_generation(1, config);
|
||||||
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
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 = request(&listener, &runtime, first_request).await;
|
||||||
let (replay_headers, _) = split_response(&replay);
|
let (replay_headers, _) = split_response(&replay);
|
||||||
assert_eq!(response_header(replay_headers, "x-session-token"), first_token);
|
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 = create_request(&bootstrap, &hello, Some(2), Some("timeout"));
|
||||||
let second = request(&listener, &runtime, second_request.clone()).await;
|
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 = request(&listener, &runtime, second_request).await;
|
||||||
let (second_replay_headers, _) = split_response(&second_replay);
|
let (second_replay_headers, _) = split_response(&second_replay);
|
||||||
assert_eq!(
|
for header in [
|
||||||
response_header(second_replay_headers, "x-session-token"),
|
"x-session-token",
|
||||||
second_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 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!(
|
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",
|
"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();
|
.into_bytes();
|
||||||
uplink.extend_from_slice(&open);
|
uplink.extend_from_slice(&body);
|
||||||
let committed = request(&listener, &runtime, uplink).await;
|
let accepted = request(&listener, &runtime, uplink).await;
|
||||||
assert!(committed.starts_with(b"HTTP/1.1 204"));
|
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(
|
let third = request(
|
||||||
&listener,
|
&listener,
|
||||||
@@ -162,7 +206,13 @@ async fn negotiation_replays_replaces_and_freezes_after_carrier_commit() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let (third_headers, _) = split_response(&third);
|
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!(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!(
|
assert!(
|
||||||
runtime
|
runtime
|
||||||
.get_session(token_hash(&second_token), "proxy.example.com")
|
.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_sessions().await;
|
||||||
generation.stop_background_tasks().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;
|
||||||
|
}
|
||||||
|
|||||||
+84
-57
@@ -1,69 +1,23 @@
|
|||||||
use std::net::{IpAddr, SocketAddr};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use hyper::Request;
|
use hyper::Request;
|
||||||
use hyper::header;
|
use hyper::header;
|
||||||
use ipnetwork::IpNetwork;
|
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use subtle::ConstantTimeEq;
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
use crate::config::{WebClientIpSource, WebRuntimeProfile, WebRuntimeVhost};
|
use crate::config::{WebRuntimeProfile, WebRuntimeVhost};
|
||||||
use crate::web::manager::{
|
use crate::web::manager::{
|
||||||
CarrierCapabilities, CarrierClientClass, CarrierFailure, CarrierRequest, TokenHash,
|
CarrierCapabilities, CarrierClientClass, CarrierFailure, CarrierRequest, TokenHash,
|
||||||
};
|
};
|
||||||
|
|
||||||
const USER_AGENT_CONTEXT: &[u8] = b"telemt-web-carrier-user-agent-v1\0";
|
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.
|
// Canonical host and forwarded-address provenance remain isolated from credentials.
|
||||||
pub(super) fn canonical_request_host<B>(request: &Request<B>) -> Option<&str> {
|
mod identity;
|
||||||
let values = request.headers().get_all(header::HOST);
|
pub(super) use identity::{
|
||||||
let mut values = values.iter();
|
canonical_request_host, carrier_ip_learning_eligible, client_ip,
|
||||||
let value = values.next()?.to_str().ok()?;
|
};
|
||||||
if values.next().is_some() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let authority = value.parse::<hyper::http::uri::Authority>().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<B>(
|
|
||||||
request: &Request<B>,
|
|
||||||
peer: SocketAddr,
|
|
||||||
source: WebClientIpSource,
|
|
||||||
trusted_proxy_cidrs: &[IpNetwork],
|
|
||||||
) -> Option<IpAddr> {
|
|
||||||
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::<IpAddr>().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes an exact canonical bridge query without allocating credential strings.
|
/// Decodes an exact canonical bridge query without allocating credential strings.
|
||||||
pub(super) fn bridge_candidate(query: Option<&str>) -> ([u8; 32], bool) {
|
pub(super) fn bridge_candidate(query: Option<&str>) -> ([u8; 32], bool) {
|
||||||
@@ -177,16 +131,31 @@ pub(super) fn carrier_request<B>(request: &Request<B>, host: &str) -> Option<Car
|
|||||||
let capabilities = single_header(request, "x-carrier-capabilities");
|
let capabilities = single_header(request, "x-carrier-capabilities");
|
||||||
let attempt = optional_canonical_u8_header(request, "x-carrier-attempt")?;
|
let attempt = optional_canonical_u8_header(request, "x-carrier-attempt")?;
|
||||||
let failure = optional_failure_header(request)?;
|
let failure = optional_failure_header(request)?;
|
||||||
|
let native_ios = native_ios_user_agent(request);
|
||||||
match (capabilities, attempt) {
|
match (capabilities, attempt) {
|
||||||
(None, None) if failure.is_none() => 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)) => {
|
(Some(capabilities), Some(attempt)) => {
|
||||||
let capabilities = parse_capabilities(capabilities)?;
|
let capabilities = parse_capabilities(capabilities)?;
|
||||||
if (attempt == 1) != failure.is_none() {
|
if (attempt == 1) != failure.is_none() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(CarrierRequest::automatic(
|
Some(CarrierRequest::automatic(
|
||||||
CarrierClientClass::Bridge,
|
if native_ios {
|
||||||
capabilities,
|
CarrierClientClass::Ios
|
||||||
|
} else {
|
||||||
|
CarrierClientClass::Bridge
|
||||||
|
},
|
||||||
|
if native_ios {
|
||||||
|
CarrierCapabilities::ios()
|
||||||
|
} else {
|
||||||
|
capabilities
|
||||||
|
},
|
||||||
attempt,
|
attempt,
|
||||||
failure,
|
failure,
|
||||||
user_agent_hash,
|
user_agent_hash,
|
||||||
@@ -197,8 +166,16 @@ pub(super) fn carrier_request<B>(request: &Request<B>, host: &str) -> Option<Car
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(CarrierRequest::automatic(
|
Some(CarrierRequest::automatic(
|
||||||
CarrierClientClass::BrowserHint,
|
if native_ios {
|
||||||
CarrierCapabilities::all(),
|
CarrierClientClass::Ios
|
||||||
|
} else {
|
||||||
|
CarrierClientClass::BrowserHint
|
||||||
|
},
|
||||||
|
if native_ios {
|
||||||
|
CarrierCapabilities::ios()
|
||||||
|
} else {
|
||||||
|
CarrierCapabilities::all()
|
||||||
|
},
|
||||||
attempt,
|
attempt,
|
||||||
failure,
|
failure,
|
||||||
user_agent_hash,
|
user_agent_hash,
|
||||||
@@ -208,6 +185,13 @@ pub(super) fn carrier_request<B>(request: &Request<B>, host: &str) -> Option<Car
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn native_ios_user_agent<B>(request: &Request<B>) -> 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<CarrierCapabilities> {
|
fn parse_capabilities(value: &str) -> Option<CarrierCapabilities> {
|
||||||
let mut bits = 0u8;
|
let mut bits = 0u8;
|
||||||
let mut previous = None;
|
let mut previous = None;
|
||||||
@@ -292,6 +276,9 @@ fn single_header<B>(request: &Request<B>, name: impl header::AsHeaderName) -> Op
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use ipnetwork::IpNetwork;
|
||||||
|
|
||||||
|
use crate::config::{WebCarrier, WebClientIpSource};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_bridge_query_rejects_aliases() {
|
fn canonical_bridge_query_rejects_aliases() {
|
||||||
@@ -485,6 +472,46 @@ mod tests {
|
|||||||
assert!(carrier_request(&reordered, "proxy.example.com").is_none());
|
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]
|
#[test]
|
||||||
fn strict_browser_metadata_recovers_a_stripped_capability_marker() {
|
fn strict_browser_metadata_recovers_a_stripped_capability_marker() {
|
||||||
let request = Request::builder()
|
let request = Request::builder()
|
||||||
|
|||||||
@@ -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<B>(request: &Request<B>) -> 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::<hyper::http::uri::Authority>().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<B>(
|
||||||
|
request: &Request<B>,
|
||||||
|
peer: SocketAddr,
|
||||||
|
source: WebClientIpSource,
|
||||||
|
trusted_proxy_cidrs: &[IpNetwork],
|
||||||
|
) -> Option<IpAddr> {
|
||||||
|
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::<IpAddr>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allows IP learning only for one explicit globally routable forwarded address.
|
||||||
|
pub(in crate::web::http) fn carrier_ip_learning_eligible<B>(
|
||||||
|
request: &Request<B>,
|
||||||
|
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::<IpAddr>().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)
|
||||||
|
}
|
||||||
@@ -272,6 +272,10 @@ pub(super) async fn handle(
|
|||||||
(ParsedCarrier::Lane(lane_id), WebCarrier::WebsocketLanes) => WebSocketKind::Lane(lane_id),
|
(ParsedCarrier::Lane(lane_id), WebCarrier::WebsocketLanes) => WebSocketKind::Lane(lane_id),
|
||||||
_ => return serve_decoy(request, vhost, true, &runtime).await,
|
_ => 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 {
|
let mut lane_reservation = match kind {
|
||||||
WebSocketKind::Multiplex => None,
|
WebSocketKind::Multiplex => None,
|
||||||
WebSocketKind::Lane(lane_id) => match session.reserve_websocket_lane(lane_id) {
|
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,
|
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
|
let connection = match runtime
|
||||||
.admit_websocket(
|
.admit_websocket(
|
||||||
session.profile_key(),
|
session.profile_key(),
|
||||||
@@ -297,6 +301,11 @@ pub(super) async fn handle(
|
|||||||
Ok(connection) => connection,
|
Ok(connection) => connection,
|
||||||
Err(_) => return serve_decoy(request, vhost, true, &runtime).await,
|
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(
|
let trace_context = runtime.trace().websocket_context(
|
||||||
&request,
|
&request,
|
||||||
peer.ip(),
|
peer.ip(),
|
||||||
@@ -326,6 +335,7 @@ pub(super) async fn handle(
|
|||||||
driver_session,
|
driver_session,
|
||||||
connection,
|
connection,
|
||||||
lane_reservation.take(),
|
lane_reservation.take(),
|
||||||
|
probe_reservation.take(),
|
||||||
trace_context,
|
trace_context,
|
||||||
parsed.acknowledge_commit,
|
parsed.acknowledge_commit,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ use super::ConnectionIo;
|
|||||||
use crate::web::manager::{
|
use crate::web::manager::{
|
||||||
WebProcessRuntime, WebSocketBudgetLease, WebSocketConnection,
|
WebProcessRuntime, WebSocketBudgetLease, WebSocketConnection,
|
||||||
};
|
};
|
||||||
use crate::web::session::{WebSession, WebSocketLaneReservation};
|
use crate::web::session::{
|
||||||
|
WebSession, WebSocketLaneReservation, WebSocketProbeReservation,
|
||||||
|
};
|
||||||
use crate::web::trace::{TraceDirection, TraceWebSocketContext};
|
use crate::web::trace::{TraceDirection, TraceWebSocketContext};
|
||||||
|
|
||||||
const READ_BUFFER_BYTES: usize = 64 * 1024;
|
const READ_BUFFER_BYTES: usize = 64 * 1024;
|
||||||
@@ -27,11 +29,12 @@ pub(super) async fn run_upgraded(
|
|||||||
session: Arc<WebSession>,
|
session: Arc<WebSession>,
|
||||||
connection: WebSocketConnection,
|
connection: WebSocketConnection,
|
||||||
mut lane_reservation: Option<WebSocketLaneReservation>,
|
mut lane_reservation: Option<WebSocketLaneReservation>,
|
||||||
|
_probe_reservation: Option<WebSocketProbeReservation>,
|
||||||
trace: Option<TraceWebSocketContext>,
|
trace: Option<TraceWebSocketContext>,
|
||||||
acknowledge_commit: bool,
|
acknowledge_commit: bool,
|
||||||
) {
|
) {
|
||||||
let cancellation = connection.cancellation();
|
let cancellation = connection.cancellation();
|
||||||
let timeouts = runtime.active_generation().config().web.timeouts.clone();
|
let timeouts = session.timeouts().clone();
|
||||||
let upgraded = tokio::select! {
|
let upgraded = tokio::select! {
|
||||||
_ = cancellation.cancelled() => return,
|
_ = cancellation.cancelled() => return,
|
||||||
result = tokio::time::timeout(
|
result = tokio::time::timeout(
|
||||||
@@ -47,7 +50,7 @@ pub(super) async fn run_upgraded(
|
|||||||
};
|
};
|
||||||
let mut io = parts.io.into_inner();
|
let mut io = parts.io.into_inner();
|
||||||
io.enable_websocket(parts.read_buf);
|
io.enable_websocket(parts.read_buf);
|
||||||
let limits = runtime.active_generation().config().web.limits.clone();
|
let limits = session.limits().clone();
|
||||||
let config = WebSocketConfig::default()
|
let config = WebSocketConfig::default()
|
||||||
.read_buffer_size(READ_BUFFER_BYTES)
|
.read_buffer_size(READ_BUFFER_BYTES)
|
||||||
.write_buffer_size(WRITE_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_message_size(Some(limits.carrier_batch_bytes))
|
||||||
.max_frame_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;
|
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() {
|
if let Some(reservation) = lane_reservation.as_mut() {
|
||||||
let _ = run_lane(
|
let _ = run_lane(
|
||||||
&mut socket,
|
&mut socket,
|
||||||
@@ -84,16 +89,11 @@ pub(super) async fn run_upgraded(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
let eviction = Duration::from_secs(
|
let eviction = Duration::from_secs(timeouts.websocket_eviction_secs);
|
||||||
runtime
|
tokio::select! {
|
||||||
.active_generation()
|
biased;
|
||||||
.config()
|
_ = cancellation.cancelled() => {}
|
||||||
.web
|
_ = tokio::time::timeout(eviction, socket.close(None)) => {}
|
||||||
.timeouts
|
|
||||||
.websocket_eviction_secs,
|
|
||||||
);
|
|
||||||
if !cancellation.is_cancelled() {
|
|
||||||
let _ = tokio::time::timeout(eviction, socket.close(None)).await;
|
|
||||||
}
|
}
|
||||||
if let Some(reservation) = lane_reservation {
|
if let Some(reservation) = lane_reservation {
|
||||||
session.close_websocket_lane(reservation.lane_id());
|
session.close_websocket_lane(reservation.lane_id());
|
||||||
@@ -121,15 +121,12 @@ async fn run_multiplex(
|
|||||||
let mut read_budget = None;
|
let mut read_budget = None;
|
||||||
let liveness_interval = connection.liveness_interval();
|
let liveness_interval = connection.liveness_interval();
|
||||||
let mut next_ping = Instant::now() + liveness_interval;
|
let mut next_ping = Instant::now() + liveness_interval;
|
||||||
let open_deadline = Instant::now()
|
let open_deadline =
|
||||||
+ Duration::from_secs(
|
Instant::now() + Duration::from_secs(session.timeouts().websocket_open_secs);
|
||||||
runtime
|
let backpressure_timeout =
|
||||||
.active_generation()
|
Duration::from_secs(session.timeouts().websocket_backpressure_secs);
|
||||||
.config()
|
let write_timeout = Duration::from_secs(session.timeouts().websocket_write_secs);
|
||||||
.web
|
let maximum_message = session.limits().carrier_batch_bytes;
|
||||||
.timeouts
|
|
||||||
.websocket_open_secs,
|
|
||||||
);
|
|
||||||
let mut active = false;
|
let mut active = false;
|
||||||
loop {
|
loop {
|
||||||
let down = session.poll_down(cursor);
|
let down = session.poll_down(cursor);
|
||||||
@@ -144,6 +141,8 @@ async fn run_multiplex(
|
|||||||
session.profile_key(),
|
session.profile_key(),
|
||||||
&cancellation,
|
&cancellation,
|
||||||
&mut read_budget,
|
&mut read_budget,
|
||||||
|
maximum_message,
|
||||||
|
backpressure_timeout,
|
||||||
) => {
|
) => {
|
||||||
DriverEvent::Incoming(incoming?)
|
DriverEvent::Incoming(incoming?)
|
||||||
}
|
}
|
||||||
@@ -153,8 +152,15 @@ async fn run_multiplex(
|
|||||||
DriverEvent::Incoming((message, _budget)) => match message {
|
DriverEvent::Incoming((message, _budget)) => match message {
|
||||||
Message::Binary(body) => {
|
Message::Binary(body) => {
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let result =
|
let result = process_multiplex(
|
||||||
process_multiplex(runtime, session, sequence, &body, &cancellation).await;
|
runtime,
|
||||||
|
session,
|
||||||
|
sequence,
|
||||||
|
&body,
|
||||||
|
&cancellation,
|
||||||
|
backpressure_timeout,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
record_message(
|
record_message(
|
||||||
runtime,
|
runtime,
|
||||||
trace,
|
trace,
|
||||||
@@ -163,14 +169,17 @@ async fn run_multiplex(
|
|||||||
&body,
|
&body,
|
||||||
started,
|
started,
|
||||||
);
|
);
|
||||||
result?;
|
let progressed = result?;
|
||||||
if acknowledge_commit && sequence == 1 && session.is_carrier_committed() {
|
if acknowledge_commit && sequence == 1 {
|
||||||
|
if !session.needs_websocket_commit_ack(connection.id()) {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
send(
|
send(
|
||||||
socket,
|
socket,
|
||||||
runtime,
|
|
||||||
Message::Binary(Bytes::new()),
|
Message::Binary(Bytes::new()),
|
||||||
&cancellation,
|
&cancellation,
|
||||||
|
write_timeout,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
record_message(
|
record_message(
|
||||||
@@ -181,9 +190,19 @@ async fn run_multiplex(
|
|||||||
&[],
|
&[],
|
||||||
started,
|
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 {
|
if !active && progressed {
|
||||||
connection.mark_active();
|
if !connection.mark_active() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
active = true;
|
active = true;
|
||||||
}
|
}
|
||||||
sequence = sequence.checked_add(1).ok_or(())?;
|
sequence = sequence.checked_add(1).ok_or(())?;
|
||||||
@@ -204,7 +223,7 @@ async fn run_multiplex(
|
|||||||
}
|
}
|
||||||
Message::Ping(payload) => {
|
Message::Ping(payload) => {
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
flush(socket, runtime, &cancellation).await?;
|
flush(socket, &cancellation, write_timeout).await?;
|
||||||
record_message(
|
record_message(
|
||||||
runtime,
|
runtime,
|
||||||
trace,
|
trace,
|
||||||
@@ -251,7 +270,13 @@ async fn run_multiplex(
|
|||||||
DriverEvent::Down(result) => {
|
DriverEvent::Down(result) => {
|
||||||
if result.body.is_empty() {
|
if result.body.is_empty() {
|
||||||
let started = Instant::now();
|
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(
|
record_message(
|
||||||
runtime,
|
runtime,
|
||||||
trace,
|
trace,
|
||||||
@@ -267,12 +292,19 @@ async fn run_multiplex(
|
|||||||
session.profile_key(),
|
session.profile_key(),
|
||||||
result.body.len(),
|
result.body.len(),
|
||||||
&cancellation,
|
&cancellation,
|
||||||
|
backpressure_timeout,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let body = result.body;
|
let body = result.body;
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
if trace.is_some() {
|
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(
|
record_message(
|
||||||
runtime,
|
runtime,
|
||||||
trace,
|
trace,
|
||||||
@@ -282,7 +314,13 @@ async fn run_multiplex(
|
|||||||
started,
|
started,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
send(socket, runtime, Message::Binary(body), &cancellation).await?;
|
send(
|
||||||
|
socket,
|
||||||
|
Message::Binary(body),
|
||||||
|
&cancellation,
|
||||||
|
write_timeout,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
connection.mark_progress();
|
connection.mark_progress();
|
||||||
}
|
}
|
||||||
@@ -290,7 +328,13 @@ async fn run_multiplex(
|
|||||||
}
|
}
|
||||||
DriverEvent::Liveness => {
|
DriverEvent::Liveness => {
|
||||||
let started = Instant::now();
|
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(
|
record_message(
|
||||||
runtime,
|
runtime,
|
||||||
trace,
|
trace,
|
||||||
@@ -322,15 +366,12 @@ async fn run_lane(
|
|||||||
let mut read_budget = None;
|
let mut read_budget = None;
|
||||||
let liveness_interval = connection.liveness_interval();
|
let liveness_interval = connection.liveness_interval();
|
||||||
let mut next_ping = Instant::now() + liveness_interval;
|
let mut next_ping = Instant::now() + liveness_interval;
|
||||||
let open_deadline = Instant::now()
|
let open_deadline =
|
||||||
+ Duration::from_secs(
|
Instant::now() + Duration::from_secs(session.timeouts().websocket_open_secs);
|
||||||
runtime
|
let backpressure_timeout =
|
||||||
.active_generation()
|
Duration::from_secs(session.timeouts().websocket_backpressure_secs);
|
||||||
.config()
|
let write_timeout = Duration::from_secs(session.timeouts().websocket_write_secs);
|
||||||
.web
|
let maximum_message = session.limits().carrier_batch_bytes;
|
||||||
.timeouts
|
|
||||||
.websocket_open_secs,
|
|
||||||
);
|
|
||||||
let mut active = false;
|
let mut active = false;
|
||||||
loop {
|
loop {
|
||||||
let down = session.poll_down_lane(reservation.lane_id(), cursor);
|
let down = session.poll_down_lane(reservation.lane_id(), cursor);
|
||||||
@@ -345,6 +386,8 @@ async fn run_lane(
|
|||||||
session.profile_key(),
|
session.profile_key(),
|
||||||
&cancellation,
|
&cancellation,
|
||||||
&mut read_budget,
|
&mut read_budget,
|
||||||
|
maximum_message,
|
||||||
|
backpressure_timeout,
|
||||||
) => {
|
) => {
|
||||||
DriverEvent::Incoming(incoming?)
|
DriverEvent::Incoming(incoming?)
|
||||||
}
|
}
|
||||||
@@ -361,6 +404,7 @@ async fn run_lane(
|
|||||||
sequence,
|
sequence,
|
||||||
&body,
|
&body,
|
||||||
&cancellation,
|
&cancellation,
|
||||||
|
backpressure_timeout,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
record_message(
|
record_message(
|
||||||
@@ -371,14 +415,17 @@ async fn run_lane(
|
|||||||
&body,
|
&body,
|
||||||
started,
|
started,
|
||||||
);
|
);
|
||||||
result?;
|
let progressed = result?;
|
||||||
if acknowledge_commit && sequence == 1 && session.is_carrier_committed() {
|
if acknowledge_commit && sequence == 1 {
|
||||||
|
if !session.needs_websocket_commit_ack(connection.id()) {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
if send(
|
if send(
|
||||||
socket,
|
socket,
|
||||||
runtime,
|
|
||||||
Message::Binary(Bytes::new()),
|
Message::Binary(Bytes::new()),
|
||||||
&cancellation,
|
&cancellation,
|
||||||
|
write_timeout,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
@@ -394,9 +441,19 @@ async fn run_lane(
|
|||||||
&[],
|
&[],
|
||||||
started,
|
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 {
|
if !active && progressed {
|
||||||
connection.mark_active();
|
if !connection.mark_active() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
active = true;
|
active = true;
|
||||||
}
|
}
|
||||||
sequence = sequence.checked_add(1).ok_or(())?;
|
sequence = sequence.checked_add(1).ok_or(())?;
|
||||||
@@ -417,7 +474,7 @@ async fn run_lane(
|
|||||||
}
|
}
|
||||||
Message::Ping(payload) => {
|
Message::Ping(payload) => {
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
flush(socket, runtime, &cancellation).await?;
|
flush(socket, &cancellation, write_timeout).await?;
|
||||||
record_message(
|
record_message(
|
||||||
runtime,
|
runtime,
|
||||||
trace,
|
trace,
|
||||||
@@ -467,7 +524,13 @@ async fn run_lane(
|
|||||||
}
|
}
|
||||||
if result.body.is_empty() {
|
if result.body.is_empty() {
|
||||||
let started = Instant::now();
|
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(
|
record_message(
|
||||||
runtime,
|
runtime,
|
||||||
trace,
|
trace,
|
||||||
@@ -483,12 +546,19 @@ async fn run_lane(
|
|||||||
session.profile_key(),
|
session.profile_key(),
|
||||||
result.body.len(),
|
result.body.len(),
|
||||||
&cancellation,
|
&cancellation,
|
||||||
|
backpressure_timeout,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let body = result.body;
|
let body = result.body;
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
if trace.is_some() {
|
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(
|
record_message(
|
||||||
runtime,
|
runtime,
|
||||||
trace,
|
trace,
|
||||||
@@ -498,7 +568,13 @@ async fn run_lane(
|
|||||||
started,
|
started,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
send(socket, runtime, Message::Binary(body), &cancellation).await?;
|
send(
|
||||||
|
socket,
|
||||||
|
Message::Binary(body),
|
||||||
|
&cancellation,
|
||||||
|
write_timeout,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
connection.mark_progress();
|
connection.mark_progress();
|
||||||
}
|
}
|
||||||
@@ -506,7 +582,13 @@ async fn run_lane(
|
|||||||
}
|
}
|
||||||
DriverEvent::Liveness => {
|
DriverEvent::Liveness => {
|
||||||
let started = Instant::now();
|
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(
|
record_message(
|
||||||
runtime,
|
runtime,
|
||||||
trace,
|
trace,
|
||||||
|
|||||||
@@ -16,19 +16,24 @@ pub(super) async fn read_message(
|
|||||||
owner: crate::web::manager::ProfileKey,
|
owner: crate::web::manager::ProfileKey,
|
||||||
cancellation: &CancellationToken,
|
cancellation: &CancellationToken,
|
||||||
retained_budget: &mut Option<WebSocketBudgetLease>,
|
retained_budget: &mut Option<WebSocketBudgetLease>,
|
||||||
|
maximum: usize,
|
||||||
|
backpressure_timeout: Duration,
|
||||||
) -> Result<(Message, Option<WebSocketBudgetLease>), ()> {
|
) -> Result<(Message, Option<WebSocketBudgetLease>), ()> {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = cancellation.cancelled() => return Err(()),
|
_ = cancellation.cancelled() => return Err(()),
|
||||||
ready = socket.get_ref().readable() => ready.map_err(|_| ())?,
|
ready = socket.get_ref().readable() => ready.map_err(|_| ())?,
|
||||||
}
|
}
|
||||||
if retained_budget.is_none() {
|
if retained_budget.is_none() {
|
||||||
let maximum = runtime
|
*retained_budget = Some(
|
||||||
.active_generation()
|
reserve_data(
|
||||||
.config()
|
runtime,
|
||||||
.web
|
owner,
|
||||||
.limits
|
maximum,
|
||||||
.carrier_batch_bytes;
|
cancellation,
|
||||||
*retained_budget = Some(reserve_data(runtime, owner, maximum, cancellation).await?);
|
backpressure_timeout,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let message = tokio::select! {
|
let message = tokio::select! {
|
||||||
_ = cancellation.cancelled() => return Err(()),
|
_ = cancellation.cancelled() => return Err(()),
|
||||||
@@ -47,17 +52,13 @@ pub(super) async fn reserve_data(
|
|||||||
owner: crate::web::manager::ProfileKey,
|
owner: crate::web::manager::ProfileKey,
|
||||||
bytes: usize,
|
bytes: usize,
|
||||||
cancellation: &CancellationToken,
|
cancellation: &CancellationToken,
|
||||||
|
timeout: Duration,
|
||||||
) -> Result<WebSocketBudgetLease, ()> {
|
) -> Result<WebSocketBudgetLease, ()> {
|
||||||
let timeout = Duration::from_secs(
|
|
||||||
runtime
|
|
||||||
.active_generation()
|
|
||||||
.config()
|
|
||||||
.web
|
|
||||||
.timeouts
|
|
||||||
.websocket_backpressure_secs,
|
|
||||||
);
|
|
||||||
tokio::time::timeout(timeout, async {
|
tokio::time::timeout(timeout, async {
|
||||||
loop {
|
loop {
|
||||||
|
if cancellation.is_cancelled() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
let notify = runtime.budget_notify();
|
let notify = runtime.budget_notify();
|
||||||
let notified = notify.notified();
|
let notified = notify.notified();
|
||||||
if let Some(budget) = runtime.try_websocket_data_budget(owner, bytes.max(1)) {
|
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,
|
sequence: u64,
|
||||||
body: &[u8],
|
body: &[u8],
|
||||||
cancellation: &CancellationToken,
|
cancellation: &CancellationToken,
|
||||||
) -> Result<(), ()> {
|
timeout: Duration,
|
||||||
retry_backpressure(runtime, cancellation, || {
|
) -> Result<bool, ()> {
|
||||||
session.process_up(sequence, body).map(|_| ())
|
retry_backpressure(runtime, cancellation, timeout, || {
|
||||||
|
session.process_websocket_multiplex(sequence, body)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -93,21 +95,17 @@ pub(super) async fn process_lane(
|
|||||||
sequence: u64,
|
sequence: u64,
|
||||||
body: &[u8],
|
body: &[u8],
|
||||||
cancellation: &CancellationToken,
|
cancellation: &CancellationToken,
|
||||||
) -> Result<(), ()> {
|
timeout: Duration,
|
||||||
let timeout = Duration::from_secs(
|
) -> Result<bool, ()> {
|
||||||
runtime
|
|
||||||
.active_generation()
|
|
||||||
.config()
|
|
||||||
.web
|
|
||||||
.timeouts
|
|
||||||
.websocket_backpressure_secs,
|
|
||||||
);
|
|
||||||
tokio::time::timeout(timeout, async {
|
tokio::time::timeout(timeout, async {
|
||||||
loop {
|
loop {
|
||||||
|
if cancellation.is_cancelled() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
let notify = runtime.budget_notify();
|
let notify = runtime.budget_notify();
|
||||||
let notified = notify.notified();
|
let notified = notify.notified();
|
||||||
match session.process_websocket_lane(reservation, sequence, body) {
|
match session.process_websocket_lane(reservation, sequence, body) {
|
||||||
Ok(()) => return Ok(()),
|
Ok(progressed) => return Ok(progressed),
|
||||||
Err(ManagerError::Backpressure) => {}
|
Err(ManagerError::Backpressure) => {}
|
||||||
Err(_) => return Err(()),
|
Err(_) => return Err(()),
|
||||||
}
|
}
|
||||||
@@ -121,28 +119,24 @@ pub(super) async fn process_lane(
|
|||||||
.map_err(|_| ())?
|
.map_err(|_| ())?
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn retry_backpressure<F>(
|
async fn retry_backpressure<F, T>(
|
||||||
runtime: &Arc<WebProcessRuntime>,
|
runtime: &Arc<WebProcessRuntime>,
|
||||||
cancellation: &CancellationToken,
|
cancellation: &CancellationToken,
|
||||||
|
timeout: Duration,
|
||||||
mut operation: F,
|
mut operation: F,
|
||||||
) -> Result<(), ()>
|
) -> Result<T, ()>
|
||||||
where
|
where
|
||||||
F: FnMut() -> Result<(), ManagerError>,
|
F: FnMut() -> Result<T, ManagerError>,
|
||||||
{
|
{
|
||||||
let timeout = Duration::from_secs(
|
|
||||||
runtime
|
|
||||||
.active_generation()
|
|
||||||
.config()
|
|
||||||
.web
|
|
||||||
.timeouts
|
|
||||||
.websocket_backpressure_secs,
|
|
||||||
);
|
|
||||||
tokio::time::timeout(timeout, async {
|
tokio::time::timeout(timeout, async {
|
||||||
loop {
|
loop {
|
||||||
|
if cancellation.is_cancelled() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
let notify = runtime.budget_notify();
|
let notify = runtime.budget_notify();
|
||||||
let notified = notify.notified();
|
let notified = notify.notified();
|
||||||
match operation() {
|
match operation() {
|
||||||
Ok(()) => return Ok(()),
|
Ok(value) => return Ok(value),
|
||||||
Err(ManagerError::Backpressure) => {}
|
Err(ManagerError::Backpressure) => {}
|
||||||
Err(_) => return Err(()),
|
Err(_) => return Err(()),
|
||||||
}
|
}
|
||||||
@@ -158,18 +152,10 @@ where
|
|||||||
|
|
||||||
pub(super) async fn send(
|
pub(super) async fn send(
|
||||||
socket: &mut CarrierSocket,
|
socket: &mut CarrierSocket,
|
||||||
runtime: &WebProcessRuntime,
|
|
||||||
message: Message,
|
message: Message,
|
||||||
cancellation: &CancellationToken,
|
cancellation: &CancellationToken,
|
||||||
|
timeout: Duration,
|
||||||
) -> Result<(), ()> {
|
) -> Result<(), ()> {
|
||||||
let timeout = Duration::from_secs(
|
|
||||||
runtime
|
|
||||||
.active_generation()
|
|
||||||
.config()
|
|
||||||
.web
|
|
||||||
.timeouts
|
|
||||||
.websocket_write_secs,
|
|
||||||
);
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = cancellation.cancelled() => Err(()),
|
_ = cancellation.cancelled() => Err(()),
|
||||||
result = tokio::time::timeout(timeout, socket.send(message)) => {
|
result = tokio::time::timeout(timeout, socket.send(message)) => {
|
||||||
@@ -180,17 +166,9 @@ pub(super) async fn send(
|
|||||||
|
|
||||||
pub(super) async fn flush(
|
pub(super) async fn flush(
|
||||||
socket: &mut CarrierSocket,
|
socket: &mut CarrierSocket,
|
||||||
runtime: &WebProcessRuntime,
|
|
||||||
cancellation: &CancellationToken,
|
cancellation: &CancellationToken,
|
||||||
|
timeout: Duration,
|
||||||
) -> Result<(), ()> {
|
) -> Result<(), ()> {
|
||||||
let timeout = Duration::from_secs(
|
|
||||||
runtime
|
|
||||||
.active_generation()
|
|
||||||
.config()
|
|
||||||
.web
|
|
||||||
.timeouts
|
|
||||||
.websocket_write_secs,
|
|
||||||
);
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = cancellation.cancelled() => Err(()),
|
_ = cancellation.cancelled() => Err(()),
|
||||||
result = tokio::time::timeout(timeout, socket.flush()) => {
|
result = tokio::time::timeout(timeout, socket.flush()) => {
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ fn create_automatic_session(
|
|||||||
None,
|
None,
|
||||||
[9; 32],
|
[9; 32],
|
||||||
),
|
),
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.token;
|
.token;
|
||||||
@@ -194,6 +195,7 @@ fn create_session(runtime: &Arc<WebProcessRuntime>) -> (String, TokenHash) {
|
|||||||
client_ip,
|
client_ip,
|
||||||
&hello,
|
&hello,
|
||||||
CarrierRequest::legacy([0; 32]),
|
CarrierRequest::legacy([0; 32]),
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.token;
|
.token;
|
||||||
@@ -382,7 +384,7 @@ async fn malformed_websocket_lane_closes_only_that_lane() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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] {
|
for carrier in [WebCarrier::Websocket, WebCarrier::WebsocketLanes] {
|
||||||
let live = live_negotiation_runtime(carrier, Arc::from([carrier]));
|
let live = live_negotiation_runtime(carrier, Arc::from([carrier]));
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
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()
|
.unwrap()
|
||||||
.is_carrier_committed()
|
.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;
|
let _ = socket.close(None).await;
|
||||||
live.shutdown().await;
|
live.shutdown().await;
|
||||||
@@ -448,6 +478,7 @@ async fn failed_automatic_multiplex_socket_remains_supersedable() {
|
|||||||
Some(CarrierFailure::Upgrade),
|
Some(CarrierFailure::Upgrade),
|
||||||
[9; 32],
|
[9; 32],
|
||||||
),
|
),
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(replacement.carrier, WebCarrier::Https);
|
assert_eq!(replacement.carrier, WebCarrier::Https);
|
||||||
|
|||||||
+42
-4
@@ -19,11 +19,16 @@ mod state;
|
|||||||
// Carrier attempt metadata remains explicit and independent from HTTP parsing.
|
// Carrier attempt metadata remains explicit and independent from HTTP parsing.
|
||||||
mod negotiation;
|
mod negotiation;
|
||||||
// Bounded process-local carrier evidence is isolated from session registries.
|
// Bounded process-local carrier evidence is isolated from session registries.
|
||||||
|
#[path = "manager/carrier_learning.rs"]
|
||||||
mod learning;
|
mod learning;
|
||||||
// Bootstrap credentials and idempotent session creation are isolated from queue accounting.
|
// Bootstrap credentials and idempotent session creation are isolated from queue accounting.
|
||||||
mod credentials;
|
mod credentials;
|
||||||
// First-session admission and bounded carrier replacement share one state machine.
|
// First-session admission and bounded carrier replacement share one state machine.
|
||||||
mod session_creation;
|
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.
|
// Stream admission and synthetic tuple ownership are process-scoped.
|
||||||
mod admission;
|
mod admission;
|
||||||
// Shutdown and expiry work remain outside request-path coordination.
|
// Shutdown and expiry work remain outside request-path coordination.
|
||||||
@@ -34,7 +39,7 @@ mod budget;
|
|||||||
mod websocket;
|
mod websocket;
|
||||||
pub(crate) use budget::WebSocketBudgetLease;
|
pub(crate) use budget::WebSocketBudgetLease;
|
||||||
use budget::{WebDataBudget, WebSocketBudgetClass};
|
use budget::{WebDataBudget, WebSocketBudgetClass};
|
||||||
use state::ManagerState;
|
use state::{ManagerState, StreamAdmissionState};
|
||||||
pub(crate) use negotiation::{
|
pub(crate) use negotiation::{
|
||||||
CarrierCapabilities, CarrierClientClass, CarrierFailure, CarrierLearningContext, CarrierRequest,
|
CarrierCapabilities, CarrierClientClass, CarrierFailure, CarrierLearningContext, CarrierRequest,
|
||||||
};
|
};
|
||||||
@@ -61,6 +66,8 @@ pub(crate) enum ManagerError {
|
|||||||
Protocol,
|
Protocol,
|
||||||
/// The operation conflicts with another in-flight operation.
|
/// The operation conflicts with another in-flight operation.
|
||||||
Concurrent,
|
Concurrent,
|
||||||
|
/// An authenticated attempt chain is already committed.
|
||||||
|
Committed,
|
||||||
/// The process or session has stopped accepting work.
|
/// The process or session has stopped accepting work.
|
||||||
Closed,
|
Closed,
|
||||||
}
|
}
|
||||||
@@ -74,6 +81,7 @@ impl ManagerError {
|
|||||||
Self::Limit => "limit",
|
Self::Limit => "limit",
|
||||||
Self::Protocol => "protocol",
|
Self::Protocol => "protocol",
|
||||||
Self::Concurrent => "concurrent",
|
Self::Concurrent => "concurrent",
|
||||||
|
Self::Committed => "committed",
|
||||||
Self::Closed => "closed",
|
Self::Closed => "closed",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,6 +95,26 @@ pub(crate) struct CreateResult {
|
|||||||
pub(crate) carrier: WebCarrier,
|
pub(crate) carrier: WebCarrier,
|
||||||
/// One-based carrier attempt echoed only for negotiated sessions.
|
/// One-based carrier attempt echoed only for negotiated sessions.
|
||||||
pub(crate) attempt: Option<u8>,
|
pub(crate) attempt: Option<u8>,
|
||||||
|
/// Effective frozen candidate count on the first automatic response.
|
||||||
|
pub(crate) candidate_count: Option<u8>,
|
||||||
|
/// Cumulative final chain deadline on the first automatic response.
|
||||||
|
pub(crate) deadline_secs: Option<u64>,
|
||||||
|
/// 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.
|
/// Successful bridge bootstrap issuance result.
|
||||||
@@ -103,6 +131,7 @@ pub(crate) struct WebProcessRuntime {
|
|||||||
trace: Arc<WebTraceStore>,
|
trace: Arc<WebTraceStore>,
|
||||||
limits: WebLimitsConfig,
|
limits: WebLimitsConfig,
|
||||||
state: Mutex<ManagerState>,
|
state: Mutex<ManagerState>,
|
||||||
|
stream_admission: Mutex<StreamAdmissionState>,
|
||||||
learning: Mutex<learning::CarrierLearning>,
|
learning: Mutex<learning::CarrierLearning>,
|
||||||
http_connections: Arc<Semaphore>,
|
http_connections: Arc<Semaphore>,
|
||||||
http_handlers: Arc<Semaphore>,
|
http_handlers: Arc<Semaphore>,
|
||||||
@@ -142,8 +171,16 @@ impl WebProcessRuntime {
|
|||||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
trace: Arc<WebTraceStore>,
|
trace: Arc<WebTraceStore>,
|
||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
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 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
|
let websocket_connections = limits
|
||||||
.max_http_connections
|
.max_http_connections
|
||||||
.saturating_sub(limits.websocket_http_connection_reserve);
|
.saturating_sub(limits.websocket_http_connection_reserve);
|
||||||
@@ -155,7 +192,7 @@ impl WebProcessRuntime {
|
|||||||
http_connections: Arc::new(Semaphore::new(limits.max_http_connections)),
|
http_connections: Arc::new(Semaphore::new(limits.max_http_connections)),
|
||||||
http_handlers: Arc::new(Semaphore::new(limits.max_http_handlers)),
|
http_handlers: Arc::new(Semaphore::new(limits.max_http_handlers)),
|
||||||
lane_polls: Arc::new(Semaphore::new(
|
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)),
|
lane_aux_polls: Arc::new(Semaphore::new(lane_aux_poll_limit)),
|
||||||
body_readers: Arc::new(Semaphore::new(limits.max_body_readers)),
|
body_readers: Arc::new(Semaphore::new(limits.max_body_readers)),
|
||||||
@@ -169,7 +206,8 @@ impl WebProcessRuntime {
|
|||||||
data_budget: WebDataBudget::new(limits.clone()),
|
data_budget: WebDataBudget::new(limits.clone()),
|
||||||
limits,
|
limits,
|
||||||
state: Mutex::new(ManagerState::default()),
|
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(),
|
shutdown: CancellationToken::new(),
|
||||||
tasks: TaskTracker::new(),
|
tasks: TaskTracker::new(),
|
||||||
sessions_created: AtomicU64::new(0),
|
sessions_created: AtomicU64::new(0),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ impl WebProcessRuntime {
|
|||||||
public_addr: SocketAddr,
|
public_addr: SocketAddr,
|
||||||
) -> Option<u16> {
|
) -> Option<u16> {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut state = self.state.lock();
|
let mut state = self.stream_admission.lock();
|
||||||
if state.closed
|
if state.closed
|
||||||
|| state.streams_live >= self.limits.max_streams_global
|
|| state.streams_live >= self.limits.max_streams_global
|
||||||
|| state
|
|| state
|
||||||
@@ -54,7 +54,7 @@ impl WebProcessRuntime {
|
|||||||
public_addr: SocketAddr,
|
public_addr: SocketAddr,
|
||||||
peer_port: u16,
|
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) {
|
if !release_stream_port(&mut state, client_ip, public_addr, peer_port) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,10 @@ impl WebDataBudget {
|
|||||||
self.pressured.swap(false, Ordering::AcqRel)
|
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 {
|
pub(super) fn owner_usage(&self, owner: ProfileKey) -> usize {
|
||||||
self.state
|
self.state
|
||||||
.lock()
|
.lock()
|
||||||
|
|||||||
@@ -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<u8>,
|
||||||
|
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<EvidenceKey, Evidence>,
|
||||||
|
insertion_order: VecDeque<(EvidenceKey, u64)>,
|
||||||
|
capacity: usize,
|
||||||
|
insertion_sequence: u64,
|
||||||
|
epoch: Option<u64>,
|
||||||
|
policy: Option<LearningPolicy>,
|
||||||
|
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<u64> {
|
||||||
|
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<u64> {
|
||||||
|
(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<WebCarrier>, [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<EvidenceKey>; 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<u64> {
|
||||||
|
let sequence = self.insertion_sequence;
|
||||||
|
self.insertion_sequence = sequence.checked_add(1)?;
|
||||||
|
Some(sequence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported(configured: &[WebCarrier], request: super::CarrierRequest) -> Vec<WebCarrier> {
|
||||||
|
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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CarrierEcho> {
|
||||||
|
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<WebSession>,
|
||||||
|
) {
|
||||||
|
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<Self>,
|
||||||
|
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<CarrierLearningContext>,
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@ use std::time::{Duration, Instant};
|
|||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use super::state::{
|
use super::state::{
|
||||||
Bootstrap, allow_rate, evict_oldest_unused_bootstrap, matching_profile, new_unique_token,
|
Bootstrap, CarrierChainPhase, allow_rate, evict_oldest_unused_bootstrap, matching_profile,
|
||||||
remove_expired_locked,
|
new_unique_token, remove_expired_locked,
|
||||||
};
|
};
|
||||||
use super::{BootstrapResult, ManagerError, TOKEN_BYTES, TokenHash, WebProcessRuntime};
|
use super::{BootstrapResult, ManagerError, TOKEN_BYTES, TokenHash, WebProcessRuntime};
|
||||||
use crate::config::WebRuntimeProfile;
|
use crate::config::WebRuntimeProfile;
|
||||||
@@ -78,8 +78,14 @@ impl WebProcessRuntime {
|
|||||||
carrier_scores: [0; 4],
|
carrier_scores: [0; 4],
|
||||||
carrier_attempt: 0,
|
carrier_attempt: 0,
|
||||||
carrier_transitioning: false,
|
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_client_ip: None,
|
||||||
|
session_ip_learning_eligible: false,
|
||||||
used: false,
|
used: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -140,12 +146,24 @@ impl WebProcessRuntime {
|
|||||||
hash: TokenHash,
|
hash: TokenHash,
|
||||||
host: &str,
|
host: &str,
|
||||||
) -> std::result::Result<(), ManagerError> {
|
) -> std::result::Result<(), ManagerError> {
|
||||||
let state = self.state.lock();
|
let mut state = self.state.lock();
|
||||||
let session = state
|
let session = state
|
||||||
.sessions
|
.sessions
|
||||||
.get(&hash)
|
.get(&hash)
|
||||||
.filter(|session| session.matches_host(host))
|
.filter(|session| session.matches_host(host))
|
||||||
.cloned();
|
.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
|
let closed = state
|
||||||
.closed_tokens
|
.closed_tokens
|
||||||
.get(&hash)
|
.get(&hash)
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ impl WebProcessRuntime {
|
|||||||
state.bootstraps_per_ip.clear();
|
state.bootstraps_per_ip.clear();
|
||||||
state.sessions.values().cloned().collect::<Vec<_>>()
|
state.sessions.values().cloned().collect::<Vec<_>>()
|
||||||
};
|
};
|
||||||
|
self.stream_admission.lock().closed = true;
|
||||||
for session in &sessions {
|
for session in &sessions {
|
||||||
session.close();
|
session.close();
|
||||||
}
|
}
|
||||||
@@ -85,10 +86,8 @@ impl WebProcessRuntime {
|
|||||||
let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), waits).await;
|
let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), waits).await;
|
||||||
self.tasks.close();
|
self.tasks.close();
|
||||||
let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), self.tasks.wait()).await;
|
let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), self.tasks.wait()).await;
|
||||||
let (sessions_live, streams_live) = {
|
let sessions_live = self.state.lock().sessions.len();
|
||||||
let state = self.state.lock();
|
let streams_live = self.stream_admission.lock().streams_live;
|
||||||
(state.sessions.len(), state.streams_live)
|
|
||||||
};
|
|
||||||
let budget = self.data_budget.snapshot();
|
let budget = self.data_budget.snapshot();
|
||||||
info!(
|
info!(
|
||||||
target: "telemt::web",
|
target: "telemt::web",
|
||||||
@@ -113,12 +112,51 @@ impl WebProcessRuntime {
|
|||||||
pub(super) fn cleanup(&self) {
|
pub(super) fn cleanup(&self) {
|
||||||
self.cleanup_websockets();
|
self.cleanup_websockets();
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
self.learning.lock().prune(now);
|
let generation = self.active_generation();
|
||||||
let sessions = {
|
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 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::<Vec<_>>();
|
||||||
|
let expired_chains = expired
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(_, session)| session.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for (hash, _) in expired {
|
||||||
|
remove_bootstrap_locked(&mut state, hash);
|
||||||
|
}
|
||||||
remove_expired_locked(&mut state, now);
|
remove_expired_locked(&mut state, now);
|
||||||
state.sessions.values().cloned().collect::<Vec<_>>()
|
(
|
||||||
|
state.sessions.values().cloned().collect::<Vec<_>>(),
|
||||||
|
expired_chains,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
for session in expired_chains {
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
for session in sessions {
|
for session in sessions {
|
||||||
session.close_if_due(now);
|
session.close_if_due(now);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ pub(crate) enum CarrierClientClass {
|
|||||||
Bridge,
|
Bridge,
|
||||||
/// Strict same-origin browser metadata survived while the marker did not.
|
/// Strict same-origin browser metadata survived while the marker did not.
|
||||||
BrowserHint,
|
BrowserHint,
|
||||||
|
/// A native iOS client that supports only the serialized HTTPS carrier.
|
||||||
|
Ios,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CarrierClientClass {
|
impl CarrierClientClass {
|
||||||
@@ -20,6 +22,7 @@ impl CarrierClientClass {
|
|||||||
Self::Legacy => "legacy",
|
Self::Legacy => "legacy",
|
||||||
Self::Bridge => "bridge",
|
Self::Bridge => "bridge",
|
||||||
Self::BrowserHint => "browser-hint",
|
Self::BrowserHint => "browser-hint",
|
||||||
|
Self::Ios => "ios",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +77,11 @@ impl CarrierCapabilities {
|
|||||||
Self(0b1111)
|
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.
|
/// Builds a set from a validated bit representation.
|
||||||
pub(crate) const fn from_bits(bits: u8) -> Option<Self> {
|
pub(crate) const fn from_bits(bits: u8) -> Option<Self> {
|
||||||
if bits != 0 && bits & !0b1111 == 0 {
|
if bits != 0 && bits & !0b1111 == 0 {
|
||||||
@@ -97,6 +105,7 @@ pub(crate) struct CarrierRequest {
|
|||||||
attempt: Option<u8>,
|
attempt: Option<u8>,
|
||||||
failure: Option<CarrierFailure>,
|
failure: Option<CarrierFailure>,
|
||||||
user_agent_hash: [u8; 32],
|
user_agent_hash: [u8; 32],
|
||||||
|
initial_only: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CarrierRequest {
|
impl CarrierRequest {
|
||||||
@@ -108,6 +117,19 @@ impl CarrierRequest {
|
|||||||
attempt: None,
|
attempt: None,
|
||||||
failure: None,
|
failure: None,
|
||||||
user_agent_hash,
|
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),
|
attempt: Some(attempt),
|
||||||
failure,
|
failure,
|
||||||
user_agent_hash,
|
user_agent_hash,
|
||||||
|
initial_only: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns whether this request participates in server-side negotiation.
|
/// Returns whether this request participates in server-side negotiation.
|
||||||
pub(crate) const fn is_automatic(self) -> bool {
|
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()
|
self.capabilities.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +194,14 @@ impl CarrierRequest {
|
|||||||
self.class == other.class
|
self.class == other.class
|
||||||
&& self.capabilities_bits() == other.capabilities_bits()
|
&& self.capabilities_bits() == other.capabilities_bits()
|
||||||
&& self.user_agent_hash == other.user_agent_hash
|
&& 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<u8> {
|
fn capabilities_bits(self) -> Option<u8> {
|
||||||
@@ -184,4 +220,8 @@ pub(crate) struct CarrierLearningContext {
|
|||||||
pub(crate) class: CarrierClientClass,
|
pub(crate) class: CarrierClientClass,
|
||||||
/// Domain-separated normalized User-Agent digest.
|
/// Domain-separated normalized User-Agent digest.
|
||||||
pub(crate) user_agent_hash: [u8; 32],
|
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+180
-143
@@ -8,17 +8,17 @@ use subtle::ConstantTimeEq;
|
|||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use super::state::{
|
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,
|
remember_closed_token_locked, remove_expired_locked,
|
||||||
};
|
};
|
||||||
|
use super::session_admission::admit_initial;
|
||||||
use super::{
|
use super::{
|
||||||
CarrierLearningContext, CarrierRequest, CreateResult, ManagerError, TokenHash,
|
CarrierLearningContext, CarrierRequest, CreateResult, ManagerError, TokenHash, WebProcessRuntime,
|
||||||
WebProcessRuntime,
|
|
||||||
};
|
};
|
||||||
use crate::config::{WebCarrier, WebRuntimeProfile, WebTimeoutsConfig};
|
use crate::config::{WebCarrier, WebRuntimeProfile};
|
||||||
use crate::web::frame;
|
use crate::web::frame;
|
||||||
use crate::web::session::WebSession;
|
use crate::web::session::WebSession;
|
||||||
use crate::web::trace::{TraceIdentity, TraceLifecycleEvent};
|
use crate::web::trace::TraceLifecycleEvent;
|
||||||
|
|
||||||
struct Replacement {
|
struct Replacement {
|
||||||
old_session: Arc<WebSession>,
|
old_session: Arc<WebSession>,
|
||||||
@@ -29,6 +29,9 @@ struct Replacement {
|
|||||||
carrier: WebCarrier,
|
carrier: WebCarrier,
|
||||||
request: CarrierRequest,
|
request: CarrierRequest,
|
||||||
scores: [i16; 4],
|
scores: [i16; 4],
|
||||||
|
learning_epoch: u64,
|
||||||
|
ip_learning_eligible: bool,
|
||||||
|
carrier_deadline_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebProcessRuntime {
|
impl WebProcessRuntime {
|
||||||
@@ -40,6 +43,7 @@ impl WebProcessRuntime {
|
|||||||
client_ip: IpAddr,
|
client_ip: IpAddr,
|
||||||
body: &[u8],
|
body: &[u8],
|
||||||
carrier_request: CarrierRequest,
|
carrier_request: CarrierRequest,
|
||||||
|
ip_learning_eligible: bool,
|
||||||
) -> std::result::Result<CreateResult, ManagerError> {
|
) -> std::result::Result<CreateResult, ManagerError> {
|
||||||
if !frame::validate_hello(body, &self.limits) {
|
if !frame::validate_hello(body, &self.limits) {
|
||||||
return Err(ManagerError::Protocol);
|
return Err(ManagerError::Protocol);
|
||||||
@@ -57,8 +61,25 @@ impl WebProcessRuntime {
|
|||||||
return Err(ManagerError::Authentication);
|
return Err(ManagerError::Authentication);
|
||||||
}
|
}
|
||||||
if entry.used {
|
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 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
|
let request_matches = entry
|
||||||
.carrier_request
|
.carrier_request
|
||||||
.is_some_and(|current| current.matches_client(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)
|
if carrier_request.attempt() == Some(entry.carrier_attempt)
|
||||||
|| (!carrier_request.is_automatic() && entry.carrier_attempt == 1)
|
|| (!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 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 {
|
let result = CreateResult {
|
||||||
token: entry.session_token.as_str().to_owned(),
|
token: entry.session_token.as_str().to_owned(),
|
||||||
carrier: session.carrier(),
|
carrier: session.carrier(),
|
||||||
attempt: carrier_request.attempt(),
|
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();
|
let identity = session.trace_identity();
|
||||||
drop(state);
|
drop(state);
|
||||||
@@ -92,9 +133,19 @@ impl WebProcessRuntime {
|
|||||||
let next_attempt = entry.carrier_attempt.saturating_add(1);
|
let next_attempt = entry.carrier_attempt.saturating_add(1);
|
||||||
if !carrier_request.is_automatic()
|
if !carrier_request.is_automatic()
|
||||||
|| carrier_request.attempt() != Some(next_attempt)
|
|| 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
|
let Some(carrier) = entry
|
||||||
.carrier_candidates
|
.carrier_candidates
|
||||||
@@ -103,6 +154,15 @@ impl WebProcessRuntime {
|
|||||||
else {
|
else {
|
||||||
return Err(ManagerError::Protocol);
|
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 old_session = entry.session.clone().ok_or(ManagerError::Authentication)?;
|
||||||
let replacement = Replacement {
|
let replacement = Replacement {
|
||||||
profile: Arc::clone(&entry.profile),
|
profile: Arc::clone(&entry.profile),
|
||||||
@@ -113,6 +173,9 @@ impl WebProcessRuntime {
|
|||||||
carrier,
|
carrier,
|
||||||
request: carrier_request,
|
request: carrier_request,
|
||||||
scores: entry.carrier_scores,
|
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
|
state
|
||||||
.bootstraps
|
.bootstraps
|
||||||
@@ -120,12 +183,7 @@ impl WebProcessRuntime {
|
|||||||
.ok_or(ManagerError::Authentication)?
|
.ok_or(ManagerError::Authentication)?
|
||||||
.carrier_transitioning = true;
|
.carrier_transitioning = true;
|
||||||
drop(state);
|
drop(state);
|
||||||
return self.replace_session(
|
return self.replace_session(bootstrap_hash, client_ip, replacement);
|
||||||
bootstrap_hash,
|
|
||||||
client_ip,
|
|
||||||
replacement,
|
|
||||||
&config.web.timeouts,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (carrier_request.is_automatic() && carrier_request.attempt() != Some(1))
|
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 {
|
if carrier_request.is_automatic() && !profile.carrier_negotiation_enabled {
|
||||||
return Err(ManagerError::Protocol);
|
return Err(ManagerError::Protocol);
|
||||||
}
|
}
|
||||||
let (candidates, scores) = if carrier_request.is_automatic() && profile.carrier_learning {
|
let capability_selection = carrier_request.uses_capabilities()
|
||||||
self.learning.lock().rank(
|
&& profile.carrier_negotiation_enabled;
|
||||||
now,
|
let learning_policy = (
|
||||||
&profile.carriers,
|
config.web.carrier_negotiation_enabled() && config.web.carrier_learning,
|
||||||
carrier_request,
|
config.web.carrier_negotiation_aggressiveness,
|
||||||
profile_key,
|
Duration::from_secs(config.web.timeouts.carrier_learning_secs),
|
||||||
client_ip,
|
);
|
||||||
)
|
let (candidates, scores, learning_epoch) = if capability_selection
|
||||||
} else if carrier_request.is_automatic() {
|
&& 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
|
profile
|
||||||
.carriers
|
.carriers
|
||||||
@@ -166,9 +253,14 @@ impl WebProcessRuntime {
|
|||||||
.filter(|carrier| carrier_request.supports(*carrier))
|
.filter(|carrier| carrier_request.supports(*carrier))
|
||||||
.collect(),
|
.collect(),
|
||||||
[0; 4],
|
[0; 4],
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
|
} else if carrier_request.uses_capabilities()
|
||||||
|
&& !carrier_request.supports(profile.carrier)
|
||||||
|
{
|
||||||
|
return Err(ManagerError::Protocol);
|
||||||
} else {
|
} else {
|
||||||
(vec![profile.carrier], [0; 4])
|
(vec![profile.carrier], [0; 4], None)
|
||||||
};
|
};
|
||||||
let Some(carrier) = candidates.first().copied() else {
|
let Some(carrier) = candidates.first().copied() else {
|
||||||
return Err(ManagerError::Protocol);
|
return Err(ManagerError::Protocol);
|
||||||
@@ -180,12 +272,16 @@ impl WebProcessRuntime {
|
|||||||
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||||
return Err(ManagerError::Limit);
|
return Err(ManagerError::Limit);
|
||||||
};
|
};
|
||||||
let learning_context = (carrier_request.is_automatic() && profile.carrier_learning)
|
let carrier_deadline_at = carrier_request.is_automatic().then_some(
|
||||||
.then_some(CarrierLearningContext {
|
now + Duration::from_secs(profile.carrier_negotiation_deadlines_secs[3]),
|
||||||
|
);
|
||||||
|
let learning_context = learning_epoch.map(|epoch| CarrierLearningContext {
|
||||||
profile_key,
|
profile_key,
|
||||||
client_ip,
|
client_ip,
|
||||||
class: carrier_request.class(),
|
class: carrier_request.class(),
|
||||||
user_agent_hash: carrier_request.user_agent_hash(),
|
user_agent_hash: carrier_request.user_agent_hash(),
|
||||||
|
epoch,
|
||||||
|
ip_learning_eligible,
|
||||||
});
|
});
|
||||||
let session = WebSession::new(
|
let session = WebSession::new(
|
||||||
Arc::downgrade(self),
|
Arc::downgrade(self),
|
||||||
@@ -197,7 +293,10 @@ impl WebProcessRuntime {
|
|||||||
carrier,
|
carrier,
|
||||||
1,
|
1,
|
||||||
bootstrap_hash,
|
bootstrap_hash,
|
||||||
|
carrier_deadline_at,
|
||||||
|
carrier_request.class(),
|
||||||
learning_context,
|
learning_context,
|
||||||
|
carrier_request.is_automatic(),
|
||||||
self.limits.clone(),
|
self.limits.clone(),
|
||||||
config.web.timeouts.clone(),
|
config.web.timeouts.clone(),
|
||||||
);
|
);
|
||||||
@@ -216,8 +315,16 @@ impl WebProcessRuntime {
|
|||||||
entry.carrier_candidates = candidates.into();
|
entry.carrier_candidates = candidates.into();
|
||||||
entry.carrier_scores = scores;
|
entry.carrier_scores = scores;
|
||||||
entry.carrier_attempt = 1;
|
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_client_ip = Some(client_ip);
|
||||||
|
entry.session_ip_learning_eligible = ip_learning_eligible;
|
||||||
let issuance_ip = entry.issuance_ip;
|
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);
|
decrement_map(&mut state.bootstraps_per_ip, &issuance_ip);
|
||||||
self.sessions_created.fetch_add(1, Ordering::Relaxed);
|
self.sessions_created.fetch_add(1, Ordering::Relaxed);
|
||||||
let identity = session.trace_identity();
|
let identity = session.trace_identity();
|
||||||
@@ -225,6 +332,15 @@ impl WebProcessRuntime {
|
|||||||
token: session_token,
|
token: session_token,
|
||||||
carrier,
|
carrier,
|
||||||
attempt: carrier_request.attempt(),
|
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);
|
drop(state);
|
||||||
self.trace.record_carrier_lifecycle(
|
self.trace.record_carrier_lifecycle(
|
||||||
@@ -263,11 +379,15 @@ impl WebProcessRuntime {
|
|||||||
bootstrap_hash: TokenHash,
|
bootstrap_hash: TokenHash,
|
||||||
client_ip: IpAddr,
|
client_ip: IpAddr,
|
||||||
replacement: Replacement,
|
replacement: Replacement,
|
||||||
timeouts: &WebTimeoutsConfig,
|
|
||||||
) -> std::result::Result<CreateResult, ManagerError> {
|
) -> std::result::Result<CreateResult, ManagerError> {
|
||||||
if !replacement.old_session.begin_carrier_supersede() {
|
if !replacement.old_session.begin_carrier_supersede() {
|
||||||
|
let committed = replacement.old_session.is_carrier_committed();
|
||||||
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
|
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 generation = self.active_generation();
|
||||||
let config = generation.config();
|
let config = generation.config();
|
||||||
@@ -276,7 +396,10 @@ impl WebProcessRuntime {
|
|||||||
remove_expired_locked(&mut state, now);
|
remove_expired_locked(&mut state, now);
|
||||||
let valid = state.bootstraps.get(&bootstrap_hash).is_some_and(|entry| {
|
let valid = state.bootstraps.get(&bootstrap_hash).is_some_and(|entry| {
|
||||||
entry.carrier_transitioning
|
entry.carrier_transitioning
|
||||||
|
&& entry.carrier_phase == CarrierChainPhase::Provisional
|
||||||
|
&& !entry.close_requested
|
||||||
&& entry.carrier_attempt.saturating_add(1) == replacement.attempt
|
&& entry.carrier_attempt.saturating_add(1) == replacement.attempt
|
||||||
|
&& now < replacement.carrier_deadline_at
|
||||||
&& entry
|
&& entry
|
||||||
.session
|
.session
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -302,14 +425,16 @@ impl WebProcessRuntime {
|
|||||||
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
|
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
|
||||||
return Err(ManagerError::Limit);
|
return Err(ManagerError::Limit);
|
||||||
};
|
};
|
||||||
let learning_context = replacement.profile.carrier_learning.then_some(
|
let learning_context = (replacement.profile.carrier_learning
|
||||||
CarrierLearningContext {
|
&& replacement.learning_epoch != 0)
|
||||||
|
.then_some(CarrierLearningContext {
|
||||||
profile_key: replacement.profile_key,
|
profile_key: replacement.profile_key,
|
||||||
client_ip,
|
client_ip,
|
||||||
class: replacement.request.class(),
|
class: replacement.request.class(),
|
||||||
user_agent_hash: replacement.request.user_agent_hash(),
|
user_agent_hash: replacement.request.user_agent_hash(),
|
||||||
},
|
epoch: replacement.learning_epoch,
|
||||||
);
|
ip_learning_eligible: replacement.ip_learning_eligible,
|
||||||
|
});
|
||||||
let session = WebSession::new(
|
let session = WebSession::new(
|
||||||
Arc::downgrade(self),
|
Arc::downgrade(self),
|
||||||
session_hash,
|
session_hash,
|
||||||
@@ -320,10 +445,19 @@ impl WebProcessRuntime {
|
|||||||
replacement.carrier,
|
replacement.carrier,
|
||||||
replacement.attempt,
|
replacement.attempt,
|
||||||
bootstrap_hash,
|
bootstrap_hash,
|
||||||
|
Some(replacement.carrier_deadline_at),
|
||||||
|
replacement.request.class(),
|
||||||
learning_context,
|
learning_context,
|
||||||
|
true,
|
||||||
self.limits.clone(),
|
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();
|
let old_hash = replacement.old_session.token_hash();
|
||||||
state.sessions.remove(&old_hash);
|
state.sessions.remove(&old_hash);
|
||||||
remember_closed_token_locked(
|
remember_closed_token_locked(
|
||||||
@@ -343,23 +477,29 @@ impl WebProcessRuntime {
|
|||||||
entry.carrier_request = Some(replacement.request);
|
entry.carrier_request = Some(replacement.request);
|
||||||
entry.carrier_attempt = replacement.attempt;
|
entry.carrier_attempt = replacement.attempt;
|
||||||
entry.carrier_transitioning = false;
|
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_created.fetch_add(1, Ordering::Relaxed);
|
||||||
self.sessions_closed.fetch_add(1, Ordering::Relaxed);
|
self.sessions_closed.fetch_add(1, Ordering::Relaxed);
|
||||||
let result = CreateResult {
|
let result = CreateResult {
|
||||||
token: session_token,
|
token: session_token,
|
||||||
carrier: replacement.carrier,
|
carrier: replacement.carrier,
|
||||||
attempt: Some(replacement.attempt),
|
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 identity = session.trace_identity();
|
||||||
let old_identity = replacement.old_session.trace_identity();
|
let old_identity = replacement.old_session.trace_identity();
|
||||||
drop(state);
|
drop(state);
|
||||||
if replacement.old_session.finish_carrier_supersede() {
|
supersede.finish();
|
||||||
session.close();
|
|
||||||
}
|
|
||||||
if let Some(context) = learning_context {
|
|
||||||
self.record_carrier_outcome(context, replacement.old_session.carrier(), false);
|
|
||||||
}
|
|
||||||
self.trace.record_carrier_lifecycle(
|
self.trace.record_carrier_lifecycle(
|
||||||
client_ip,
|
client_ip,
|
||||||
old_identity.clone(),
|
old_identity.clone(),
|
||||||
@@ -401,107 +541,4 @@ impl WebProcessRuntime {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cancel_replacement(&self, bootstrap_hash: TokenHash, old_session: &Arc<WebSession>) {
|
|
||||||
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<CarrierLearningContext>,
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-12
@@ -14,6 +14,23 @@ use crate::web::session::WebSession;
|
|||||||
|
|
||||||
const WEB_PROFILE_OWNER_CONTEXT: &[u8] = b"telemt-web-profile-owner-v1\0";
|
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.
|
/// One issued bootstrap and optional idempotent session-creation replay state.
|
||||||
pub(super) struct Bootstrap {
|
pub(super) struct Bootstrap {
|
||||||
/// Credential and replay-state expiry deadline.
|
/// Credential and replay-state expiry deadline.
|
||||||
@@ -42,10 +59,22 @@ pub(super) struct Bootstrap {
|
|||||||
pub(super) carrier_attempt: u8,
|
pub(super) carrier_attempt: u8,
|
||||||
/// Prevents concurrent retries from replacing the same attempt twice.
|
/// Prevents concurrent retries from replacing the same attempt twice.
|
||||||
pub(super) carrier_transitioning: bool,
|
pub(super) carrier_transitioning: bool,
|
||||||
/// Records the first accepted OPEN or DATA transition exactly once.
|
/// Manager-owned attempt-chain phase used for replacement linearization.
|
||||||
pub(super) carrier_committed: bool,
|
pub(super) carrier_phase: CarrierChainPhase,
|
||||||
|
/// Monotonic start of the first automatic session attempt.
|
||||||
|
pub(super) carrier_started_at: Option<Instant>,
|
||||||
|
/// Absolute server-side end of the automatic attempt chain.
|
||||||
|
pub(super) carrier_deadline_at: Option<Instant>,
|
||||||
|
/// Failed candidates staged until one winner becomes healthy.
|
||||||
|
pub(super) carrier_failures: [Option<WebCarrier>; 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.
|
/// Effective address frozen by the first session-creation request.
|
||||||
pub(super) session_client_ip: Option<IpAddr>,
|
pub(super) session_client_ip: Option<IpAddr>,
|
||||||
|
/// 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.
|
/// Distinguishes unused issuance quota from completed creation replay state.
|
||||||
pub(super) used: bool,
|
pub(super) used: bool,
|
||||||
}
|
}
|
||||||
@@ -70,6 +99,20 @@ struct StreamPortState {
|
|||||||
next: u16,
|
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<ProfileKey, usize>,
|
||||||
|
/// 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.
|
/// Process-wide WEB registries and quota accounting protected by one short lock.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub(super) struct ManagerState {
|
pub(super) struct ManagerState {
|
||||||
@@ -85,17 +128,10 @@ pub(super) struct ManagerState {
|
|||||||
pub(super) sessions_per_ip: HashMap<IpAddr, usize>,
|
pub(super) sessions_per_ip: HashMap<IpAddr, usize>,
|
||||||
/// Live session counts by stable profile key.
|
/// Live session counts by stable profile key.
|
||||||
pub(super) sessions_per_profile: HashMap<ProfileKey, usize>,
|
pub(super) sessions_per_profile: HashMap<ProfileKey, usize>,
|
||||||
/// Live relay-task counts by stable profile key.
|
|
||||||
pub(super) streams_per_profile: HashMap<ProfileKey, usize>,
|
|
||||||
/// Process-wide live relay-task count.
|
|
||||||
pub(super) streams_live: usize,
|
|
||||||
stream_ports: HashMap<(IpAddr, SocketAddr), StreamPortState>,
|
|
||||||
/// Bootstrap issuance rate limiter.
|
/// Bootstrap issuance rate limiter.
|
||||||
pub(super) bootstrap_rate: RateState,
|
pub(super) bootstrap_rate: RateState,
|
||||||
/// Session creation rate limiter.
|
/// Session creation rate limiter.
|
||||||
pub(super) session_rate: RateState,
|
pub(super) session_rate: RateState,
|
||||||
/// Logical-stream creation rate limiter.
|
|
||||||
pub(super) stream_rate: RateState,
|
|
||||||
/// Process shutdown admission latch.
|
/// Process shutdown admission latch.
|
||||||
pub(super) closed: bool,
|
pub(super) closed: bool,
|
||||||
}
|
}
|
||||||
@@ -264,7 +300,7 @@ where
|
|||||||
|
|
||||||
/// Allocates a non-zero source port unique among live streams for one KDF route.
|
/// Allocates a non-zero source port unique among live streams for one KDF route.
|
||||||
pub(super) fn allocate_stream_port(
|
pub(super) fn allocate_stream_port(
|
||||||
state: &mut ManagerState,
|
state: &mut StreamAdmissionState,
|
||||||
client_ip: IpAddr,
|
client_ip: IpAddr,
|
||||||
public_addr: SocketAddr,
|
public_addr: SocketAddr,
|
||||||
) -> Option<u16> {
|
) -> Option<u16> {
|
||||||
@@ -287,7 +323,7 @@ pub(super) fn allocate_stream_port(
|
|||||||
|
|
||||||
/// Releases one source port and reclaims empty per-route allocator state.
|
/// Releases one source port and reclaims empty per-route allocator state.
|
||||||
pub(super) fn release_stream_port(
|
pub(super) fn release_stream_port(
|
||||||
state: &mut ManagerState,
|
state: &mut StreamAdmissionState,
|
||||||
client_ip: IpAddr,
|
client_ip: IpAddr,
|
||||||
public_addr: SocketAddr,
|
public_addr: SocketAddr,
|
||||||
peer_port: u16,
|
peer_port: u16,
|
||||||
@@ -309,7 +345,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn synthetic_ports_are_unique_per_live_route_and_state_is_reclaimed() {
|
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 client_ip = "192.0.2.10".parse().unwrap();
|
||||||
let public_addr = "203.0.113.10:443".parse().unwrap();
|
let public_addr = "203.0.113.10:443".parse().unwrap();
|
||||||
let first = allocate_stream_port(&mut state, client_ip, public_addr).unwrap();
|
let first = allocate_stream_port(&mut state, client_ip, public_addr).unwrap();
|
||||||
|
|||||||
@@ -82,19 +82,45 @@ impl WebSocketConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Marks successful ownership transfer from HTTP to the WebSocket codec.
|
/// Marks successful ownership transfer from HTTP to the WebSocket codec.
|
||||||
pub(crate) fn mark_opened(&self) {
|
pub(crate) fn mark_opened(&self) -> bool {
|
||||||
self.entry
|
if self.entry.closing.load(Ordering::Acquire)
|
||||||
.phase
|
|| self
|
||||||
.store(WebSocketPhase::Upgraded as u8, Ordering::Release);
|
.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();
|
self.mark_progress();
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marks the first validated carrier binary message as active progress.
|
/// Marks the first validated carrier binary message as active progress.
|
||||||
pub(crate) fn mark_active(&self) {
|
pub(crate) fn mark_active(&self) -> bool {
|
||||||
self.entry
|
if self.entry.closing.load(Ordering::Acquire)
|
||||||
.phase
|
|| self
|
||||||
.store(WebSocketPhase::Active as u8, Ordering::Release);
|
.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();
|
self.mark_peer_activity();
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refreshes the peer-liveness deadline after any received WebSocket message.
|
/// Refreshes the peer-liveness deadline after any received WebSocket message.
|
||||||
@@ -125,12 +151,16 @@ impl Drop for WebSocketConnection {
|
|||||||
registry.claims.remove(&self.entry.claim);
|
registry.claims.remove(&self.entry.claim);
|
||||||
}
|
}
|
||||||
if self.entry.closing.load(Ordering::Acquire) {
|
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);
|
drop(registry);
|
||||||
self.entry.released.cancel();
|
|
||||||
drop(self.base_budget.take());
|
drop(self.base_budget.take());
|
||||||
drop(self.slot.take());
|
drop(self.slot.take());
|
||||||
|
self.entry.released.cancel();
|
||||||
runtime.websocket_notify.notify_waiters();
|
runtime.websocket_notify.notify_waiters();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +179,9 @@ pub(super) async fn admit(
|
|||||||
eviction_timeout: Duration,
|
eviction_timeout: Duration,
|
||||||
parent_cancellation: CancellationToken,
|
parent_cancellation: CancellationToken,
|
||||||
) -> Result<WebSocketConnection, ManagerError> {
|
) -> Result<WebSocketConnection, ManagerError> {
|
||||||
|
if parent_cancellation.is_cancelled() {
|
||||||
|
return Err(ManagerError::Closed);
|
||||||
|
}
|
||||||
let liveness_interval_ms = liveness_interval.as_millis().min(u128::from(u64::MAX)) as u64;
|
let liveness_interval_ms = liveness_interval.as_millis().min(u128::from(u64::MAX)) as u64;
|
||||||
match try_admit(
|
match try_admit(
|
||||||
runtime,
|
runtime,
|
||||||
@@ -172,7 +205,13 @@ pub(super) async fn admit(
|
|||||||
};
|
};
|
||||||
let released = victim.released.cancelled();
|
let released = victim.released.cancelled();
|
||||||
victim.cancel.cancel();
|
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(
|
match try_admit(
|
||||||
runtime,
|
runtime,
|
||||||
owner,
|
owner,
|
||||||
@@ -211,6 +250,9 @@ fn try_admit(
|
|||||||
liveness_interval_ms: u64,
|
liveness_interval_ms: u64,
|
||||||
parent_cancellation: &CancellationToken,
|
parent_cancellation: &CancellationToken,
|
||||||
) -> Result<WebSocketConnection, TryAdmitError> {
|
) -> Result<WebSocketConnection, TryAdmitError> {
|
||||||
|
if parent_cancellation.is_cancelled() {
|
||||||
|
return Err(TryAdmitError::Closed);
|
||||||
|
}
|
||||||
let claim = WebSocketClaimKey { session_hash, kind };
|
let claim = WebSocketClaimKey { session_hash, kind };
|
||||||
{
|
{
|
||||||
let registry = runtime.websockets.lock();
|
let registry = runtime.websockets.lock();
|
||||||
@@ -227,6 +269,9 @@ fn try_admit(
|
|||||||
let base_budget = runtime
|
let base_budget = runtime
|
||||||
.try_websocket_base_budget(owner, base_bytes)
|
.try_websocket_base_budget(owner, base_bytes)
|
||||||
.ok_or(TryAdmitError::Capacity)?;
|
.ok_or(TryAdmitError::Capacity)?;
|
||||||
|
if parent_cancellation.is_cancelled() {
|
||||||
|
return Err(TryAdmitError::Closed);
|
||||||
|
}
|
||||||
let id = runtime
|
let id = runtime
|
||||||
.websocket_next_id
|
.websocket_next_id
|
||||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| {
|
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| {
|
||||||
@@ -251,7 +296,7 @@ fn try_admit(
|
|||||||
released: CancellationToken::new(),
|
released: CancellationToken::new(),
|
||||||
});
|
});
|
||||||
let mut registry = runtime.websockets.lock();
|
let mut registry = runtime.websockets.lock();
|
||||||
if registry.closed {
|
if registry.closed || parent_cancellation.is_cancelled() {
|
||||||
return Err(TryAdmitError::Closed);
|
return Err(TryAdmitError::Closed);
|
||||||
}
|
}
|
||||||
if registry.claims.contains_key(&claim) {
|
if registry.claims.contains_key(&claim) {
|
||||||
@@ -276,11 +321,12 @@ impl WebProcessRuntime {
|
|||||||
pub(super) fn cleanup_websockets(&self) {
|
pub(super) fn cleanup_websockets(&self) {
|
||||||
let now = self.websocket_tick();
|
let now = self.websocket_tick();
|
||||||
let mut victims = claim_stale_victims(self, now);
|
let mut victims = claim_stale_victims(self, now);
|
||||||
if victims.is_empty()
|
if victims.is_empty() && self.data_budget.take_pressure() {
|
||||||
&& self.data_budget.take_pressure()
|
if let Some(victim) = select_pressure_victim(self, now, true) {
|
||||||
&& let Some(victim) = select_pressure_victim(self, now, true)
|
victims.push(victim);
|
||||||
{
|
} else {
|
||||||
victims.push(victim);
|
self.data_budget.restore_pressure();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for victim in victims {
|
for victim in victims {
|
||||||
victim.cancel.cancel();
|
victim.cancel.cancel();
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn entry(kind: WebSocketKind, opened: bool, peer_tick: u64) -> WebSocketEntry {
|
fn entry(kind: WebSocketKind, opened: bool, peer_tick: u64) -> WebSocketEntry {
|
||||||
|
let phase = if opened {
|
||||||
|
WebSocketPhase::Active
|
||||||
|
} else {
|
||||||
|
WebSocketPhase::Claimed
|
||||||
|
};
|
||||||
WebSocketEntry {
|
WebSocketEntry {
|
||||||
id: 1,
|
id: 1,
|
||||||
owner: [0; 32],
|
owner: [0; 32],
|
||||||
session_id: 1,
|
session_id: 1,
|
||||||
|
claim: WebSocketClaimKey {
|
||||||
|
session_hash: [0; 32],
|
||||||
|
kind,
|
||||||
|
},
|
||||||
client_ip: "192.0.2.10".parse().unwrap(),
|
client_ip: "192.0.2.10".parse().unwrap(),
|
||||||
kind,
|
kind,
|
||||||
liveness_interval_ms: 10,
|
liveness_interval_ms: 10,
|
||||||
created_tick: 1,
|
created_tick: 1,
|
||||||
last_peer_tick: AtomicU64::new(peer_tick),
|
last_peer_tick: AtomicU64::new(peer_tick),
|
||||||
last_progress_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(),
|
cancel: CancellationToken::new(),
|
||||||
|
released: CancellationToken::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-44
@@ -14,8 +14,9 @@ use tokio_util::sync::CancellationToken;
|
|||||||
|
|
||||||
use crate::config::{WebCarrier, WebLimitsConfig, WebRuntimeProfile, WebTimeoutsConfig};
|
use crate::config::{WebCarrier, WebLimitsConfig, WebRuntimeProfile, WebTimeoutsConfig};
|
||||||
use crate::web::frame::{self, FrameType};
|
use crate::web::frame::{self, FrameType};
|
||||||
use crate::web::manager::{ProfileKey, TokenHash, WebProcessRuntime};
|
use crate::web::manager::{
|
||||||
use crate::web::manager::CarrierLearningContext;
|
CarrierClientClass, CarrierLearningContext, ProfileKey, TokenHash, WebProcessRuntime,
|
||||||
|
};
|
||||||
|
|
||||||
// Backend tasks own generation admission and authenticated MTProxy relay lifetimes.
|
// Backend tasks own generation admission and authenticated MTProxy relay lifetimes.
|
||||||
mod backend;
|
mod backend;
|
||||||
@@ -25,9 +26,16 @@ mod downlink;
|
|||||||
mod resident;
|
mod resident;
|
||||||
// Lane carrier state isolates request sequencing and downlink replay per logical stream.
|
// Lane carrier state isolates request sequencing and downlink replay per logical stream.
|
||||||
mod lanes;
|
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.
|
// WebSocket carrier state owns pre-OPEN lane reservations and failure isolation.
|
||||||
mod websocket;
|
mod websocket;
|
||||||
pub(crate) use websocket::WebSocketLaneReservation;
|
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.
|
// Session closure and carrier-attempt transitions share one cancellation boundary.
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
// Uplink batches own exactly-once sequencing and client-frame validation.
|
// Uplink batches own exactly-once sequencing and client-frame validation.
|
||||||
@@ -80,6 +88,7 @@ struct DownBatch {
|
|||||||
data_items: usize,
|
data_items: usize,
|
||||||
control_bytes: usize,
|
control_bytes: usize,
|
||||||
control_items: usize,
|
control_items: usize,
|
||||||
|
carrier_health_eligible: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CarrierLane {
|
struct CarrierLane {
|
||||||
@@ -142,6 +151,16 @@ struct SessionState {
|
|||||||
pending_control_items: usize,
|
pending_control_items: usize,
|
||||||
last_activity: Instant,
|
last_activity: Instant,
|
||||||
negotiation_phase: SessionNegotiationPhase,
|
negotiation_phase: SessionNegotiationPhase,
|
||||||
|
carrier_health_due_at: Option<Instant>,
|
||||||
|
carrier_health_activity_at: Option<Instant>,
|
||||||
|
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<u64>,
|
||||||
|
websocket_commit_ack_written: bool,
|
||||||
|
websocket_probe_claimed: bool,
|
||||||
close_requested: bool,
|
close_requested: bool,
|
||||||
closed: bool,
|
closed: bool,
|
||||||
}
|
}
|
||||||
@@ -165,7 +184,10 @@ pub(crate) struct WebSession {
|
|||||||
selected_carrier: WebCarrier,
|
selected_carrier: WebCarrier,
|
||||||
carrier_attempt: u8,
|
carrier_attempt: u8,
|
||||||
bootstrap_hash: TokenHash,
|
bootstrap_hash: TokenHash,
|
||||||
|
carrier_deadline_at: Option<Instant>,
|
||||||
|
carrier_class: CarrierClientClass,
|
||||||
learning_context: Option<CarrierLearningContext>,
|
learning_context: Option<CarrierLearningContext>,
|
||||||
|
automatic_carrier: bool,
|
||||||
limits: WebLimitsConfig,
|
limits: WebLimitsConfig,
|
||||||
timeouts: WebTimeoutsConfig,
|
timeouts: WebTimeoutsConfig,
|
||||||
state: Mutex<SessionState>,
|
state: Mutex<SessionState>,
|
||||||
@@ -202,7 +224,10 @@ impl WebSession {
|
|||||||
selected_carrier: WebCarrier,
|
selected_carrier: WebCarrier,
|
||||||
carrier_attempt: u8,
|
carrier_attempt: u8,
|
||||||
bootstrap_hash: TokenHash,
|
bootstrap_hash: TokenHash,
|
||||||
|
carrier_deadline_at: Option<Instant>,
|
||||||
|
carrier_class: CarrierClientClass,
|
||||||
learning_context: Option<CarrierLearningContext>,
|
learning_context: Option<CarrierLearningContext>,
|
||||||
|
automatic_carrier: bool,
|
||||||
limits: WebLimitsConfig,
|
limits: WebLimitsConfig,
|
||||||
timeouts: WebTimeoutsConfig,
|
timeouts: WebTimeoutsConfig,
|
||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
@@ -222,7 +247,10 @@ impl WebSession {
|
|||||||
selected_carrier,
|
selected_carrier,
|
||||||
carrier_attempt,
|
carrier_attempt,
|
||||||
bootstrap_hash,
|
bootstrap_hash,
|
||||||
|
carrier_deadline_at,
|
||||||
|
carrier_class,
|
||||||
learning_context,
|
learning_context,
|
||||||
|
automatic_carrier,
|
||||||
limits,
|
limits,
|
||||||
timeouts,
|
timeouts,
|
||||||
state: Mutex::new(SessionState {
|
state: Mutex::new(SessionState {
|
||||||
@@ -249,6 +277,16 @@ impl WebSession {
|
|||||||
pending_control_items: 0,
|
pending_control_items: 0,
|
||||||
last_activity: Instant::now(),
|
last_activity: Instant::now(),
|
||||||
negotiation_phase: SessionNegotiationPhase::Uncommitted,
|
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,
|
close_requested: false,
|
||||||
closed: false,
|
closed: false,
|
||||||
}),
|
}),
|
||||||
@@ -293,11 +331,6 @@ impl WebSession {
|
|||||||
self.cancel.child_token()
|
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.
|
/// Returns a cloned non-secret identity only for enabled debug capture.
|
||||||
pub(crate) fn trace_identity(&self) -> crate::web::trace::TraceIdentity {
|
pub(crate) fn trace_identity(&self) -> crate::web::trace::TraceIdentity {
|
||||||
crate::web::trace::TraceIdentity::from_profile(self.trace_session_id, &self.profile)
|
crate::web::trace::TraceIdentity::from_profile(self.trace_session_id, &self.profile)
|
||||||
@@ -322,45 +355,14 @@ impl WebSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_carrier_active_locked(
|
/// Returns the immutable limits frozen when this carrier chain was created.
|
||||||
&self,
|
pub(crate) fn limits(&self) -> &WebLimitsConfig {
|
||||||
state: &SessionState,
|
&self.limits
|
||||||
) -> Result<(), crate::web::manager::ManagerError> {
|
|
||||||
match state.negotiation_phase {
|
|
||||||
SessionNegotiationPhase::Uncommitted | SessionNegotiationPhase::Committed => Ok(()),
|
|
||||||
SessionNegotiationPhase::Replacing | SessionNegotiationPhase::Superseded => {
|
|
||||||
Err(crate::web::manager::ManagerError::Closed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn commit_carrier_locked(&self, state: &mut SessionState, progress: bool) -> bool {
|
/// Returns the immutable timeouts frozen when this carrier chain was created.
|
||||||
if !progress {
|
pub(crate) fn timeouts(&self) -> &WebTimeoutsConfig {
|
||||||
return false;
|
&self.timeouts
|
||||||
}
|
|
||||||
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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polls client-to-server bytes and returns consumed flow-control credit.
|
/// Polls client-to-server bytes and returns consumed flow-control credit.
|
||||||
@@ -431,6 +433,13 @@ impl WebSession {
|
|||||||
.len()
|
.len()
|
||||||
.min(frame::DATA_CHUNK_BYTES)
|
.min(frame::DATA_CHUNK_BYTES)
|
||||||
.min(self.limits.max_frame_payload_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);
|
.min(stream_state.send_credit as usize);
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
stream_state.write_waker = Some(cx.waker().clone());
|
stream_state.write_waker = Some(cx.waker().clone());
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ impl WebSession {
|
|||||||
}
|
}
|
||||||
let queued = {
|
let queued = {
|
||||||
let mut state = self.state.lock();
|
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
|
state
|
||||||
.streams
|
.streams
|
||||||
.get(&stream.id)
|
.get(&stream.id)
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ fn test_runtime_with_dc(
|
|||||||
1,
|
1,
|
||||||
[9; 32],
|
[9; 32],
|
||||||
None,
|
None,
|
||||||
|
crate::web::manager::CarrierClientClass::Legacy,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
limits,
|
limits,
|
||||||
timeouts,
|
timeouts,
|
||||||
);
|
);
|
||||||
|
|||||||
+21
-114
@@ -16,7 +16,7 @@ impl WebSession {
|
|||||||
if !self.carrier().is_multiplexed() {
|
if !self.carrier().is_multiplexed() {
|
||||||
return Err(ManagerError::Protocol);
|
return Err(ManagerError::Protocol);
|
||||||
}
|
}
|
||||||
let epoch = {
|
let (epoch, healthy) = {
|
||||||
let mut state = self.state.lock();
|
let mut state = self.state.lock();
|
||||||
if state.closed {
|
if state.closed {
|
||||||
return Err(ManagerError::Closed);
|
return Err(ManagerError::Closed);
|
||||||
@@ -35,15 +35,29 @@ impl WebSession {
|
|||||||
self.close();
|
self.close();
|
||||||
return Err(ManagerError::Protocol);
|
return Err(ManagerError::Protocol);
|
||||||
}
|
}
|
||||||
|
let carrier_health_eligible = unacked.carrier_health_eligible;
|
||||||
self.release_unacked_locked(&mut state);
|
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 {
|
} else if cursor != state.down_cursor {
|
||||||
drop(state);
|
drop(state);
|
||||||
self.close();
|
self.close();
|
||||||
return Err(ManagerError::Protocol);
|
return Err(ManagerError::Protocol);
|
||||||
}
|
}
|
||||||
state.down_epoch = state.down_epoch.wrapping_add(1).max(1);
|
let Some(epoch) = state.down_epoch.checked_add(1) else {
|
||||||
state.down_epoch
|
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();
|
self.down_notify.notify_waiters();
|
||||||
|
|
||||||
let deadline = Duration::from_secs(self.timeouts.long_poll_secs);
|
let deadline = Duration::from_secs(self.timeouts.long_poll_secs);
|
||||||
@@ -431,6 +445,8 @@ impl WebSession {
|
|||||||
data_items,
|
data_items,
|
||||||
control_bytes,
|
control_bytes,
|
||||||
control_items,
|
control_items,
|
||||||
|
carrier_health_eligible: state.negotiation_phase
|
||||||
|
== super::SessionNegotiationPhase::Committed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,114 +466,5 @@ impl WebSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "downlink_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::config::{
|
|
||||||
WebCarrier, WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig,
|
|
||||||
};
|
|
||||||
use crate::web::manager::WebProcessRuntime;
|
|
||||||
|
|
||||||
fn session() -> Arc<WebSession> {
|
|
||||||
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::<WebProcessRuntime>::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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<WebSession>, Arc<WebProcessRuntime>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<DownBatch, ManagerError> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<Self>,
|
||||||
|
lane_id: u32,
|
||||||
|
sequence: u64,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<u64, ManagerError> {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
+101
-299
@@ -2,184 +2,16 @@ use std::sync::Arc;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use bytes::{BufMut, Bytes, BytesMut};
|
use bytes::{BufMut, Bytes, BytesMut};
|
||||||
use sha2::{Digest, Sha256};
|
use tokio::sync::OwnedSemaphorePermit;
|
||||||
use subtle::ConstantTimeEq;
|
use super::lane_downlink::take_lane_down_batch;
|
||||||
|
|
||||||
use super::uplink::{inbound_reservation, validate_batch};
|
|
||||||
use super::resident::{OwnedBatchBody, PendingCounts, PendingResponseLease};
|
|
||||||
use super::{
|
use super::{
|
||||||
CarrierLane, DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState,
|
PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState, WebSession,
|
||||||
WebSession, insert_carrier_lane, remember_closed,
|
remember_closed,
|
||||||
};
|
};
|
||||||
use crate::config::{WebCarrier, WebLimitsConfig};
|
use crate::web::frame::{self, FrameType};
|
||||||
use crate::web::frame::{self, Frame, FrameType};
|
use crate::web::manager::ManagerError;
|
||||||
use crate::web::manager::{ManagerError, TokenHash};
|
|
||||||
|
|
||||||
impl WebSession {
|
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<Self>,
|
|
||||||
lane_id: u32,
|
|
||||||
sequence: u64,
|
|
||||||
body: &[u8],
|
|
||||||
) -> Result<u64, ManagerError> {
|
|
||||||
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.
|
/// Polls one lane with independent cursor replay and newest-poll-wins semantics.
|
||||||
pub(crate) async fn poll_down_lane(
|
pub(crate) async fn poll_down_lane(
|
||||||
&self,
|
&self,
|
||||||
@@ -196,7 +28,7 @@ impl WebSession {
|
|||||||
lane_closed: false,
|
lane_closed: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let (instance, epoch, notify) = {
|
let (instance, epoch, notify, healthy) = {
|
||||||
let mut state = self.state.lock();
|
let mut state = self.state.lock();
|
||||||
if state.closed {
|
if state.closed {
|
||||||
return Err(ManagerError::Closed);
|
return Err(ManagerError::Closed);
|
||||||
@@ -235,12 +67,8 @@ impl WebSession {
|
|||||||
};
|
};
|
||||||
if let Some(batch) = acknowledged {
|
if let Some(batch) = acknowledged {
|
||||||
if let Some(lane) = state.carrier_lanes.get_mut(&lane_id) {
|
if let Some(lane) = state.carrier_lanes.get_mut(&lane_id) {
|
||||||
lane.pending_bytes = lane
|
lane.pending_bytes = lane.pending_bytes.saturating_sub(batch.data_bytes);
|
||||||
.pending_bytes
|
lane.pending_items = lane.pending_items.saturating_sub(batch.data_items);
|
||||||
.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));
|
|
||||||
}
|
}
|
||||||
batch.lease.detach();
|
batch.lease.detach();
|
||||||
self.release_local_locked(&mut state, batch.data_bytes, batch.data_items, false);
|
self.release_local_locked(&mut state, batch.data_bytes, batch.data_items, false);
|
||||||
@@ -250,6 +78,10 @@ impl WebSession {
|
|||||||
batch.control_items,
|
batch.control_items,
|
||||||
true,
|
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)
|
if let Some(stream) = state.streams.get_mut(&lane_id)
|
||||||
&& let Some(waker) = stream.write_waker.take()
|
&& let Some(waker) = stream.write_waker.take()
|
||||||
{
|
{
|
||||||
@@ -260,9 +92,20 @@ impl WebSession {
|
|||||||
.carrier_lanes
|
.carrier_lanes
|
||||||
.get_mut(&lane_id)
|
.get_mut(&lane_id)
|
||||||
.ok_or(ManagerError::Protocol)?;
|
.ok_or(ManagerError::Protocol)?;
|
||||||
lane.down_epoch = lane.down_epoch.wrapping_add(1).max(1);
|
let Some(epoch) = lane.down_epoch.checked_add(1) else {
|
||||||
(lane.instance, lane.down_epoch, Arc::clone(&lane.notify))
|
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();
|
notify.notify_waiters();
|
||||||
|
|
||||||
let deadline = Duration::from_secs(self.timeouts.long_poll_secs);
|
let deadline = Duration::from_secs(self.timeouts.long_poll_secs);
|
||||||
@@ -274,6 +117,9 @@ impl WebSession {
|
|||||||
if state.closed {
|
if state.closed {
|
||||||
return Err(ManagerError::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 {
|
let Some(lane) = state.carrier_lanes.get_mut(&lane_id) else {
|
||||||
return Ok(PollResult {
|
return Ok(PollResult {
|
||||||
body: Bytes::new(),
|
body: Bytes::new(),
|
||||||
@@ -281,7 +127,14 @@ impl WebSession {
|
|||||||
lane_closed: true,
|
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 {
|
return Ok(PollResult {
|
||||||
body: Bytes::new(),
|
body: Bytes::new(),
|
||||||
next_cursor: cursor,
|
next_cursor: cursor,
|
||||||
@@ -289,7 +142,13 @@ impl WebSession {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if !lane.pending_frames.is_empty() {
|
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,
|
Ok(batch) => batch,
|
||||||
Err(ManagerError::Backpressure) => {
|
Err(ManagerError::Backpressure) => {
|
||||||
return Err(ManagerError::Backpressure);
|
return Err(ManagerError::Backpressure);
|
||||||
@@ -350,12 +209,17 @@ impl WebSession {
|
|||||||
lane_closed: true,
|
lane_closed: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if state
|
if let Some(lane) = state.carrier_lanes.get(&lane_id) {
|
||||||
.carrier_lanes
|
if lane.instance != instance {
|
||||||
.get(&lane_id)
|
return Ok(PollResult {
|
||||||
.is_some_and(|lane| lane.instance == instance && lane.down_epoch == epoch)
|
body: Bytes::new(),
|
||||||
{
|
next_cursor: cursor,
|
||||||
state.last_activity = Instant::now();
|
lane_closed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if lane.down_epoch == epoch {
|
||||||
|
state.last_activity = Instant::now();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(PollResult {
|
Ok(PollResult {
|
||||||
body: Bytes::new(),
|
body: Bytes::new(),
|
||||||
@@ -392,8 +256,17 @@ impl WebSession {
|
|||||||
if state.lane_open_waits >= self.limits.max_lane_open_waits_per_session {
|
if state.lane_open_waits >= self.limits.max_lane_open_waits_per_session {
|
||||||
return Err(ManagerError::Limit);
|
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;
|
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 deadline = Duration::from_secs(self.timeouts.lane_open_wait_secs);
|
||||||
let opened = tokio::time::timeout(deadline, async {
|
let opened = tokio::time::timeout(deadline, async {
|
||||||
@@ -401,23 +274,34 @@ impl WebSession {
|
|||||||
let notified = self.lane_open_notify.notified();
|
let notified = self.lane_open_notify.notified();
|
||||||
{
|
{
|
||||||
let state = self.state.lock();
|
let state = self.state.lock();
|
||||||
if state.closed
|
if state.closed {
|
||||||
|| state.carrier_lanes.contains_key(&lane_id)
|
return Err(ManagerError::Closed);
|
||||||
|
}
|
||||||
|
if state.carrier_lanes.contains_key(&lane_id)
|
||||||
|| state.closed_streams.contains(&lane_id)
|
|| state.closed_streams.contains(&lane_id)
|
||||||
|| state.closing_streams.contains_key(&lane_id)
|
|| state.closing_streams.contains_key(&lane_id)
|
||||||
{
|
{
|
||||||
return state.carrier_lanes.contains_key(&lane_id)
|
return Ok(true);
|
||||||
|| state.closed_streams.contains(&lane_id)
|
|
||||||
|| state.closing_streams.contains_key(&lane_id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notified.await;
|
notified.await;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await;
|
||||||
.unwrap_or(false);
|
|
||||||
drop(wait);
|
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(
|
pub(super) fn queue_lane_frame_locked(
|
||||||
@@ -468,7 +352,8 @@ impl WebSession {
|
|||||||
if can_coalesce {
|
if can_coalesce {
|
||||||
if state.carrier_lanes.get(&stream_id).is_none_or(|lane| {
|
if state.carrier_lanes.get(&stream_id).is_none_or(|lane| {
|
||||||
let resident = lane.resident.snapshot();
|
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
|
> self
|
||||||
.limits
|
.limits
|
||||||
.pending_bytes_per_lane
|
.pending_bytes_per_lane
|
||||||
@@ -501,13 +386,16 @@ impl WebSession {
|
|||||||
} else {
|
} else {
|
||||||
PendingClass::Downlink
|
PendingClass::Downlink
|
||||||
};
|
};
|
||||||
if state.carrier_lanes.get(&stream_id).is_none_or(|lane| {
|
if !control
|
||||||
let resident = lane.resident.snapshot();
|
&& state.carrier_lanes.get(&stream_id).is_none_or(|lane| {
|
||||||
lane.pending_bytes.saturating_add(resident.bytes())
|
let resident = lane.resident.snapshot();
|
||||||
> self.limits.pending_bytes_per_lane.saturating_sub(cost)
|
cost > self.limits.pending_bytes_per_lane
|
||||||
|| lane.pending_items.saturating_add(resident.items())
|
|| lane.pending_bytes.saturating_add(resident.data_bytes)
|
||||||
>= self.limits.pending_items_per_lane
|
> 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;
|
return false;
|
||||||
}
|
}
|
||||||
if !self.reserve_locked(state, cost, 1, class) {
|
if !self.reserve_locked(state, cost, 1, class) {
|
||||||
@@ -532,8 +420,10 @@ impl WebSession {
|
|||||||
control,
|
control,
|
||||||
cost,
|
cost,
|
||||||
});
|
});
|
||||||
lane.pending_bytes += cost;
|
if !control {
|
||||||
lane.pending_items += 1;
|
lane.pending_bytes += cost;
|
||||||
|
lane.pending_items += 1;
|
||||||
|
}
|
||||||
if frame_type == FrameType::Window {
|
if frame_type == FrameType::Window {
|
||||||
lane.pending_windows.insert(stream_id, index);
|
lane.pending_windows.insert(stream_id, index);
|
||||||
}
|
}
|
||||||
@@ -585,6 +475,7 @@ impl WebSession {
|
|||||||
|
|
||||||
struct LaneOpenWaitGuard<'a> {
|
struct LaneOpenWaitGuard<'a> {
|
||||||
session: &'a WebSession,
|
session: &'a WebSession,
|
||||||
|
_auxiliary: OwnedSemaphorePermit,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for LaneOpenWaitGuard<'_> {
|
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<DownBatch, ManagerError> {
|
|
||||||
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.
|
// Lane-specific protocol, replay, and lifecycle tests.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
+137
-16
@@ -1,13 +1,26 @@
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
use bytes::BytesMut;
|
use bytes::BytesMut;
|
||||||
|
|
||||||
use super::*;
|
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::manager::WebProcessRuntime;
|
||||||
|
use crate::web::session::{CarrierLane, insert_carrier_lane};
|
||||||
|
|
||||||
fn session_with_limits(limits: WebLimitsConfig) -> Arc<WebSession> {
|
fn session_with_limits(limits: WebLimitsConfig) -> Arc<WebSession> {
|
||||||
|
new_session(limits, std::sync::Weak::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_session(
|
||||||
|
limits: WebLimitsConfig,
|
||||||
|
manager: std::sync::Weak<WebProcessRuntime>,
|
||||||
|
) -> Arc<WebSession> {
|
||||||
let profile = Arc::new(WebRuntimeProfile {
|
let profile = Arc::new(WebRuntimeProfile {
|
||||||
host: "proxy.example.com".to_string(),
|
host: "proxy.example.com".to_string(),
|
||||||
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
|
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
|
||||||
@@ -25,7 +38,7 @@ fn session_with_limits(limits: WebLimitsConfig) -> Arc<WebSession> {
|
|||||||
max_streams_per_session: 2,
|
max_streams_per_session: 2,
|
||||||
});
|
});
|
||||||
WebSession::new(
|
WebSession::new(
|
||||||
std::sync::Weak::<WebProcessRuntime>::new(),
|
manager,
|
||||||
[1; 32],
|
[1; 32],
|
||||||
"192.0.2.10".parse().unwrap(),
|
"192.0.2.10".parse().unwrap(),
|
||||||
1,
|
1,
|
||||||
@@ -35,22 +48,134 @@ fn session_with_limits(limits: WebLimitsConfig) -> Arc<WebSession> {
|
|||||||
1,
|
1,
|
||||||
[3; 32],
|
[3; 32],
|
||||||
None,
|
None,
|
||||||
|
crate::web::manager::CarrierClientClass::Legacy,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
limits,
|
limits,
|
||||||
WebTimeoutsConfig::default(),
|
WebTimeoutsConfig::default(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn session_with_manager() -> (Arc<WebSession>, Arc<WebProcessRuntime>) {
|
||||||
|
session_with_manager_limits(WebLimitsConfig::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_with_manager_limits(
|
||||||
|
limits: WebLimitsConfig,
|
||||||
|
) -> (Arc<WebSession>, Arc<WebProcessRuntime>) {
|
||||||
|
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<WebSession> {
|
fn session() -> Arc<WebSession> {
|
||||||
session_with_limits(WebLimitsConfig::default())
|
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]
|
#[test]
|
||||||
fn lane_uplink_sequences_are_independent_and_exactly_once() {
|
fn lane_uplink_sequences_are_independent_and_exactly_once() {
|
||||||
let session = session();
|
let session = session();
|
||||||
{
|
{
|
||||||
let mut state = session.state.lock();
|
let mut state = session.state.lock();
|
||||||
for lane_id in [51, 52] {
|
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);
|
state.closed_streams.insert(lane_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,20 +200,12 @@ fn cross_lane_frame_is_fatal_to_https_lane_session() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn drained_closed_lane_replays_then_signals_completion() {
|
async fn drained_closed_lane_replays_then_signals_completion() {
|
||||||
let session = session();
|
let (session, manager) = session_with_manager();
|
||||||
{
|
{
|
||||||
let mut state = session.state.lock();
|
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);
|
state.closed_streams.insert(7);
|
||||||
let lane = state.carrier_lanes.get_mut(&7).unwrap();
|
assert!(session.queue_control_locked(&mut state, FrameType::Close, 7, &[]));
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
let first = session.poll_down_lane(7, 0).await.unwrap();
|
let first = session.poll_down_lane(7, 0).await.unwrap();
|
||||||
let replay = 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();
|
let finished = session.poll_down_lane(7, 1).await.unwrap();
|
||||||
assert!(finished.body.is_empty());
|
assert!(finished.body.is_empty());
|
||||||
assert!(finished.lane_closed);
|
assert!(finished.lane_closed);
|
||||||
|
drop(first);
|
||||||
|
drop(replay);
|
||||||
|
session.close();
|
||||||
|
manager.shutdown().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -108,7 +229,7 @@ fn tombstone_eviction_releases_lane_budget_and_accepts_late_frames() {
|
|||||||
let session = session_with_limits(limits);
|
let session = session_with_limits(limits);
|
||||||
{
|
{
|
||||||
let mut state = session.state.lock();
|
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 encoded = frame::encode(FrameType::Close, 7, &[]);
|
||||||
let cost = encoded.len() + QUEUE_ITEM_COST;
|
let cost = encoded.len() + QUEUE_ITEM_COST;
|
||||||
state
|
state
|
||||||
@@ -128,7 +249,7 @@ fn tombstone_eviction_releases_lane_budget_and_accepts_late_frames() {
|
|||||||
state.pending_control_bytes = cost;
|
state.pending_control_bytes = cost;
|
||||||
state.pending_control_items = 1;
|
state.pending_control_items = 1;
|
||||||
session.remember_closed_locked(&mut state, 7);
|
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);
|
session.remember_closed_locked(&mut state, 8);
|
||||||
assert!(!state.carrier_lanes.contains_key(&7));
|
assert!(!state.carrier_lanes.contains_key(&7));
|
||||||
assert_eq!(state.pending_bytes, 0);
|
assert_eq!(state.pending_bytes, 0);
|
||||||
|
|||||||
@@ -10,6 +10,20 @@ struct ReleasedQueues {
|
|||||||
control_items: usize,
|
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 {
|
impl WebSession {
|
||||||
/// Closes carrier state while relay tasks retain their admission until exit.
|
/// Closes carrier state while relay tasks retain their admission until exit.
|
||||||
pub(crate) fn close(&self) {
|
pub(crate) fn close(&self) {
|
||||||
@@ -50,14 +64,13 @@ impl WebSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Completes manager-owned replacement without unregistering the old session twice.
|
/// Linearizes manager publication against close requests on the old token.
|
||||||
pub(crate) fn finish_carrier_supersede(&self) -> bool {
|
pub(crate) fn prepare_carrier_supersede(&self) -> Option<CarrierSupersedeCompletion<'_>> {
|
||||||
let close_requested = self.state.lock().close_requested;
|
let released = self.begin_close(true, None)?;
|
||||||
let Some(released) = self.begin_close(true, None) else {
|
Some(CarrierSupersedeCompletion {
|
||||||
return close_requested;
|
session: self,
|
||||||
};
|
released,
|
||||||
self.finish_close(released, true);
|
})
|
||||||
close_requested
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Waits for all logical-stream tasks after admission has closed.
|
/// 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.
|
/// Atomically closes a session only when reconnect grace is still due.
|
||||||
pub(crate) fn close_if_due(&self, now: Instant) -> bool {
|
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 {
|
let Some(released) = self.begin_close(false, Some(now)) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -82,7 +102,11 @@ impl WebSession {
|
|||||||
|
|
||||||
fn begin_close(&self, superseded: bool, idle_now: Option<Instant>) -> Option<ReleasedQueues> {
|
fn begin_close(&self, superseded: bool, idle_now: Option<Instant>) -> Option<ReleasedQueues> {
|
||||||
let mut state = self.state.lock();
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
if let Some(now) = idle_now
|
if let Some(now) = idle_now
|
||||||
@@ -111,10 +135,37 @@ impl WebSession {
|
|||||||
state.streams.clear();
|
state.streams.clear();
|
||||||
state.pending_frames.clear();
|
state.pending_frames.clear();
|
||||||
state.pending_windows.clear();
|
state.pending_windows.clear();
|
||||||
state.unacked = None;
|
if let Some(batch) = state.unacked.take() {
|
||||||
for lane in state.carrier_lanes.values() {
|
batch.lease.detach();
|
||||||
lane.notify.notify_waiters();
|
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();
|
state.carrier_lanes.clear();
|
||||||
let control_bytes = state.pending_control_bytes;
|
let control_bytes = state.pending_control_bytes;
|
||||||
let control_items = state.pending_control_items;
|
let control_items = state.pending_control_items;
|
||||||
@@ -137,6 +188,9 @@ impl WebSession {
|
|||||||
if self.carrier().is_multiplexed() {
|
if self.carrier().is_multiplexed() {
|
||||||
self.down_notify.notify_waiters();
|
self.down_notify.notify_waiters();
|
||||||
}
|
}
|
||||||
|
if self.carrier().uses_lanes() {
|
||||||
|
self.lane_open_notify.notify_waiters();
|
||||||
|
}
|
||||||
if let Some(manager) = self.manager.upgrade() {
|
if let Some(manager) = self.manager.upgrade() {
|
||||||
manager.release_pending(
|
manager.release_pending(
|
||||||
self.profile_key,
|
self.profile_key,
|
||||||
|
|||||||
@@ -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<WebSession> {
|
||||||
|
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::<WebProcessRuntime>::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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,18 @@ use super::{
|
|||||||
use crate::web::frame::{self, Frame, FrameType};
|
use crate::web::frame::{self, Frame, FrameType};
|
||||||
use crate::web::manager::{ManagerError, TokenHash};
|
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 {
|
impl WebSession {
|
||||||
/// Applies one exactly-once uplink batch.
|
/// Applies one exactly-once uplink batch.
|
||||||
pub(crate) fn process_up(
|
pub(crate) fn process_up(
|
||||||
@@ -22,6 +34,25 @@ impl WebSession {
|
|||||||
sequence: u64,
|
sequence: u64,
|
||||||
body: &[u8],
|
body: &[u8],
|
||||||
) -> Result<u64, ManagerError> {
|
) -> Result<u64, ManagerError> {
|
||||||
|
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<Self>,
|
||||||
|
sequence: u64,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<bool, ManagerError> {
|
||||||
|
self.process_up_inner(sequence, body)
|
||||||
|
.map(|(_, progress)| progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_up_inner(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
sequence: u64,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<(u64, bool), ManagerError> {
|
||||||
if !self.carrier().is_multiplexed() {
|
if !self.carrier().is_multiplexed() {
|
||||||
return Err(ManagerError::Protocol);
|
return Err(ManagerError::Protocol);
|
||||||
}
|
}
|
||||||
@@ -49,11 +80,9 @@ impl WebSession {
|
|||||||
return Err(ManagerError::Protocol);
|
return Err(ManagerError::Protocol);
|
||||||
}
|
}
|
||||||
let digest: TokenHash = Sha256::digest(body).into();
|
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 opened = Vec::new();
|
||||||
let mut committed = false;
|
let mut committed = false;
|
||||||
|
let mut healthy = false;
|
||||||
let result = {
|
let result = {
|
||||||
let mut state = self.state.lock();
|
let mut state = self.state.lock();
|
||||||
if state.closed {
|
if state.closed {
|
||||||
@@ -63,7 +92,7 @@ impl WebSession {
|
|||||||
state.last_activity = Instant::now();
|
state.last_activity = Instant::now();
|
||||||
if sequence == state.last_up_sequence && sequence != 0 {
|
if sequence == state.last_up_sequence && sequence != 0 {
|
||||||
return if bool::from(state.last_up_digest.ct_eq(&digest)) {
|
return if bool::from(state.last_up_digest.ct_eq(&digest)) {
|
||||||
Ok(sequence)
|
Ok((sequence, false))
|
||||||
} else {
|
} else {
|
||||||
drop(state);
|
drop(state);
|
||||||
self.close();
|
self.close();
|
||||||
@@ -91,6 +120,7 @@ impl WebSession {
|
|||||||
}
|
}
|
||||||
let mut unused_bytes = reserve_bytes;
|
let mut unused_bytes = reserve_bytes;
|
||||||
let mut unused_items = reserve_items;
|
let mut unused_items = reserve_items;
|
||||||
|
let mut progress = AppliedProgress::default();
|
||||||
let applied = self.apply_batch_locked(
|
let applied = self.apply_batch_locked(
|
||||||
&mut state,
|
&mut state,
|
||||||
&frames,
|
&frames,
|
||||||
@@ -98,6 +128,7 @@ impl WebSession {
|
|||||||
&mut None,
|
&mut None,
|
||||||
&mut unused_bytes,
|
&mut unused_bytes,
|
||||||
&mut unused_items,
|
&mut unused_items,
|
||||||
|
&mut progress,
|
||||||
);
|
);
|
||||||
self.release_locked(&mut state, unused_bytes, unused_items, false);
|
self.release_locked(&mut state, unused_bytes, unused_items, false);
|
||||||
if !applied {
|
if !applied {
|
||||||
@@ -105,8 +136,9 @@ impl WebSession {
|
|||||||
} else {
|
} else {
|
||||||
state.last_up_sequence = sequence;
|
state.last_up_sequence = sequence;
|
||||||
state.last_up_digest = digest;
|
state.last_up_digest = digest;
|
||||||
committed = self.commit_carrier_locked(&mut state, progress);
|
(committed, healthy) =
|
||||||
Ok(sequence)
|
self.record_uplink_progress_locked(&mut state, progress);
|
||||||
|
Ok((sequence, progress.any()))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if matches!(result, Err(ManagerError::Backpressure)) {
|
if matches!(result, Err(ManagerError::Backpressure)) {
|
||||||
@@ -120,6 +152,9 @@ impl WebSession {
|
|||||||
if committed {
|
if committed {
|
||||||
self.finish_carrier_commit();
|
self.finish_carrier_commit();
|
||||||
}
|
}
|
||||||
|
if healthy {
|
||||||
|
self.finish_carrier_health();
|
||||||
|
}
|
||||||
for completion in opened {
|
for completion in opened {
|
||||||
self.spawn_stream(completion, false);
|
self.spawn_stream(completion, false);
|
||||||
}
|
}
|
||||||
@@ -137,6 +172,7 @@ impl WebSession {
|
|||||||
reserved_open: &mut Option<(u32, u16)>,
|
reserved_open: &mut Option<(u32, u16)>,
|
||||||
unused_bytes: &mut usize,
|
unused_bytes: &mut usize,
|
||||||
unused_items: &mut usize,
|
unused_items: &mut usize,
|
||||||
|
progress: &mut AppliedProgress,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
for value in frames {
|
for value in frames {
|
||||||
if value.stream_id == 0 {
|
if value.stream_id == 0 {
|
||||||
@@ -186,6 +222,7 @@ impl WebSession {
|
|||||||
write_waker: None,
|
write_waker: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
progress.accepted_open = true;
|
||||||
opened.push(self.own_stream_task(stream, peer_port));
|
opened.push(self.own_stream_task(stream, peer_port));
|
||||||
}
|
}
|
||||||
FrameType::Data if !was_closed => {
|
FrameType::Data if !was_closed => {
|
||||||
@@ -197,6 +234,7 @@ impl WebSession {
|
|||||||
bytes: Bytes::copy_from_slice(value.payload),
|
bytes: Bytes::copy_from_slice(value.payload),
|
||||||
offset: 0,
|
offset: 0,
|
||||||
});
|
});
|
||||||
|
progress.accepted_data = true;
|
||||||
*unused_bytes =
|
*unused_bytes =
|
||||||
unused_bytes.saturating_sub(value.payload.len() + QUEUE_ITEM_COST);
|
unused_bytes.saturating_sub(value.payload.len() + QUEUE_ITEM_COST);
|
||||||
*unused_items = unused_items.saturating_sub(1);
|
*unused_items = unused_items.saturating_sub(1);
|
||||||
@@ -409,6 +447,9 @@ mod tests {
|
|||||||
1,
|
1,
|
||||||
[3; 32],
|
[3; 32],
|
||||||
None,
|
None,
|
||||||
|
crate::web::manager::CarrierClientClass::Legacy,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
WebLimitsConfig::default(),
|
WebLimitsConfig::default(),
|
||||||
WebTimeoutsConfig::default(),
|
WebTimeoutsConfig::default(),
|
||||||
)
|
)
|
||||||
@@ -445,6 +486,7 @@ mod tests {
|
|||||||
state.streams.insert(
|
state.streams.insert(
|
||||||
1,
|
1,
|
||||||
StreamState {
|
StreamState {
|
||||||
|
instance: 1,
|
||||||
inbound: VecDeque::new(),
|
inbound: VecDeque::new(),
|
||||||
receive_window: frame::INITIAL_STREAM_WINDOW,
|
receive_window: frame::INITIAL_STREAM_WINDOW,
|
||||||
send_credit: u64::from(frame::INITIAL_STREAM_WINDOW),
|
send_credit: u64::from(frame::INITIAL_STREAM_WINDOW),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::time::Instant;
|
|||||||
|
|
||||||
use sha2::{Digest, Sha256};
|
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 super::{PendingClass, WebSession, inbound_queue_cost, insert_carrier_lane};
|
||||||
use crate::config::WebCarrier;
|
use crate::config::WebCarrier;
|
||||||
use crate::web::frame;
|
use crate::web::frame;
|
||||||
@@ -17,6 +17,43 @@ pub(crate) struct WebSocketLaneReservation {
|
|||||||
transferred: bool,
|
transferred: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Session-wide ownership of the only automatic WebSocket carrier probe.
|
||||||
|
pub(crate) struct WebSocketProbeReservation {
|
||||||
|
session: Arc<WebSession>,
|
||||||
|
owner: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
impl WebSocketLaneReservation {
|
||||||
/// Returns the logical stream owned by this connection.
|
/// Returns the logical stream owned by this connection.
|
||||||
pub(crate) fn lane_id(&self) -> u32 {
|
pub(crate) fn lane_id(&self) -> u32 {
|
||||||
@@ -46,6 +83,42 @@ impl Drop for WebSocketLaneReservation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WebSession {
|
impl WebSession {
|
||||||
|
/// Reserves the only automatic WebSocket probe before any HTTP 101 response.
|
||||||
|
pub(crate) fn reserve_websocket_probe(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
acknowledge_commit: bool,
|
||||||
|
) -> Result<Option<WebSocketProbeReservation>, 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.
|
/// Acquires stream quota and tuple ownership before a lane returns HTTP 101.
|
||||||
pub(crate) fn reserve_websocket_lane(
|
pub(crate) fn reserve_websocket_lane(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
@@ -63,6 +136,7 @@ impl WebSession {
|
|||||||
}
|
}
|
||||||
if state.active_peer_ports.len() >= self.profile.max_streams_per_session
|
if state.active_peer_ports.len() >= self.profile.max_streams_per_session
|
||||||
|| state.streams.contains_key(&lane_id)
|
|| state.streams.contains_key(&lane_id)
|
||||||
|
|| state.closing_streams.contains_key(&lane_id)
|
||||||
|| state.closed_streams.contains(&lane_id)
|
|| state.closed_streams.contains(&lane_id)
|
||||||
|| state.websocket_lane_reservations.contains_key(&lane_id)
|
|| state.websocket_lane_reservations.contains_key(&lane_id)
|
||||||
{
|
{
|
||||||
@@ -116,7 +190,7 @@ impl WebSession {
|
|||||||
reservation: &mut WebSocketLaneReservation,
|
reservation: &mut WebSocketLaneReservation,
|
||||||
sequence: u64,
|
sequence: u64,
|
||||||
body: &[u8],
|
body: &[u8],
|
||||||
) -> Result<(), ManagerError> {
|
) -> Result<bool, ManagerError> {
|
||||||
if !Arc::ptr_eq(self, &reservation.session)
|
if !Arc::ptr_eq(self, &reservation.session)
|
||||||
|| reservation.lane_id == 0
|
|| reservation.lane_id == 0
|
||||||
|| reservation.lane_id > frame::MAX_STREAM_ID
|
|| reservation.lane_id > frame::MAX_STREAM_ID
|
||||||
@@ -133,11 +207,9 @@ impl WebSession {
|
|||||||
return Err(ManagerError::Protocol);
|
return Err(ManagerError::Protocol);
|
||||||
}
|
}
|
||||||
let digest = Sha256::digest(body).into();
|
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 opened = Vec::new();
|
||||||
let mut committed = false;
|
let mut committed = false;
|
||||||
|
let mut healthy = false;
|
||||||
let result = {
|
let result = {
|
||||||
let mut state = self.state.lock();
|
let mut state = self.state.lock();
|
||||||
if state.closed {
|
if state.closed {
|
||||||
@@ -179,6 +251,7 @@ impl WebSession {
|
|||||||
}
|
}
|
||||||
let mut unused_bytes = reserve_bytes;
|
let mut unused_bytes = reserve_bytes;
|
||||||
let mut unused_items = reserve_items;
|
let mut unused_items = reserve_items;
|
||||||
|
let mut progress = AppliedProgress::default();
|
||||||
let mut reserved_open =
|
let mut reserved_open =
|
||||||
(!reservation.transferred).then_some((lane_id, reservation.peer_port));
|
(!reservation.transferred).then_some((lane_id, reservation.peer_port));
|
||||||
let applied = self.apply_batch_locked(
|
let applied = self.apply_batch_locked(
|
||||||
@@ -188,6 +261,7 @@ impl WebSession {
|
|||||||
&mut reserved_open,
|
&mut reserved_open,
|
||||||
&mut unused_bytes,
|
&mut unused_bytes,
|
||||||
&mut unused_items,
|
&mut unused_items,
|
||||||
|
&mut progress,
|
||||||
);
|
);
|
||||||
self.release_locked(&mut state, unused_bytes, unused_items, false);
|
self.release_locked(&mut state, unused_bytes, unused_items, false);
|
||||||
if let Some(lane) = state.carrier_lanes.get_mut(&lane_id) {
|
if let Some(lane) = state.carrier_lanes.get_mut(&lane_id) {
|
||||||
@@ -199,14 +273,20 @@ impl WebSession {
|
|||||||
}
|
}
|
||||||
state.last_activity = Instant::now();
|
state.last_activity = Instant::now();
|
||||||
if applied {
|
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 {
|
if committed {
|
||||||
self.finish_carrier_commit();
|
self.finish_carrier_commit();
|
||||||
}
|
}
|
||||||
|
if healthy {
|
||||||
|
self.finish_carrier_health();
|
||||||
|
}
|
||||||
for completion in opened {
|
for completion in opened {
|
||||||
if completion.stream.id != lane_id || completion.peer_port != reservation.peer_port {
|
if completion.stream.id != lane_id || completion.peer_port != reservation.peer_port {
|
||||||
return Err(ManagerError::Protocol);
|
return Err(ManagerError::Protocol);
|
||||||
@@ -222,7 +302,7 @@ impl WebSession {
|
|||||||
if let Some(manager) = self.manager.upgrade() {
|
if let Some(manager) = self.manager.upgrade() {
|
||||||
manager.record_up(body.len());
|
manager.record_up(body.len());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(progressed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ends one failed or disconnected lane without closing its parent session.
|
/// Ends one failed or disconnected lane without closing its parent session.
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ fn runtime(admission: bool) -> TestRuntime {
|
|||||||
1,
|
1,
|
||||||
[9; 32],
|
[9; 32],
|
||||||
None,
|
None,
|
||||||
|
crate::web::manager::CarrierClientClass::Legacy,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
limits,
|
limits,
|
||||||
timeouts,
|
timeouts,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -236,8 +236,10 @@ pub(crate) enum TraceLifecycleEvent {
|
|||||||
CarrierFailed,
|
CarrierFailed,
|
||||||
/// An uncommitted carrier session was atomically superseded.
|
/// An uncommitted carrier session was atomically superseded.
|
||||||
CarrierSuperseded,
|
CarrierSuperseded,
|
||||||
/// The first OPEN or DATA batch made a carrier immutable.
|
/// Bidirectional carrier evidence made replacement unsafe.
|
||||||
CarrierCommitted,
|
CarrierCommitted,
|
||||||
|
/// A committed carrier survived its configured health interval.
|
||||||
|
CarrierHealthy,
|
||||||
/// A new session was created.
|
/// A new session was created.
|
||||||
SessionCreated,
|
SessionCreated,
|
||||||
/// An idempotent session creation was replayed.
|
/// An idempotent session creation was replayed.
|
||||||
@@ -279,6 +281,7 @@ impl TraceLifecycleEvent {
|
|||||||
Self::CarrierFailed => "carrier_failed",
|
Self::CarrierFailed => "carrier_failed",
|
||||||
Self::CarrierSuperseded => "carrier_superseded",
|
Self::CarrierSuperseded => "carrier_superseded",
|
||||||
Self::CarrierCommitted => "carrier_committed",
|
Self::CarrierCommitted => "carrier_committed",
|
||||||
|
Self::CarrierHealthy => "carrier_healthy",
|
||||||
Self::SessionCreated => "session_created",
|
Self::SessionCreated => "session_created",
|
||||||
Self::SessionReplayed => "session_replayed",
|
Self::SessionReplayed => "session_replayed",
|
||||||
Self::SessionRejected => "session_rejected",
|
Self::SessionRejected => "session_rejected",
|
||||||
|
|||||||
Reference in New Issue
Block a user