From c75cf5cc9d12ea974e172a71981ccc2069c6fbff Mon Sep 17 00:00:00 2001
From: Alexey <247128645+axkurcom@users.noreply.github.com>
Date: Thu, 27 Aug 2026 07:38:42 +0300
Subject: [PATCH 1/7] Carrier negotiation + WebSocket lifecycle
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
---
src/config/load/strict_keys.rs | 3 +
src/config/load/validate_web/timeouts.rs | 12 +
.../tests/load_basic_tests/web_tests.rs | 49 +-
src/config/types/web.rs | 12 +
src/config/types/web/defaults.rs | 3 +
src/metrics.rs | 32 +-
src/web/bridge.rs | 469 +---------------
src/web/bridge/document.html | 13 +
src/web/bridge/runtime.js | 522 ++++++++++++++++++
src/web/bridge/tests.rs | 36 ++
src/web/http.rs | 21 +-
src/web/http/body.rs | 3 +-
src/web/http/down.rs | 11 +-
src/web/http/negotiation_tests.rs | 9 +-
src/web/http/request.rs | 297 +---------
src/web/http/request/tests.rs | 303 ++++++++++
src/web/http/session.rs | 17 +-
src/web/http/session_policy_tests.rs | 102 ++++
src/web/http/tests.rs | 3 +
src/web/manager/credentials.rs | 15 +-
src/web/manager/learning.rs | 346 ------------
src/web/manager/lifecycle.rs | 10 +-
src/web/manager/negotiation.rs | 10 +
src/web/manager/session_creation.rs | 177 +-----
.../manager/session_creation/replacement.rs | 177 ++++++
src/web/session/backend.rs | 26 +-
src/web/session/backend_tests.rs | 52 ++
src/web/session/lifecycle.rs | 1 +
28 files changed, 1411 insertions(+), 1320 deletions(-)
create mode 100644 src/web/bridge/document.html
create mode 100644 src/web/bridge/runtime.js
create mode 100644 src/web/http/request/tests.rs
create mode 100644 src/web/http/session_policy_tests.rs
delete mode 100644 src/web/manager/learning.rs
create mode 100644 src/web/manager/session_creation/replacement.rs
diff --git a/src/config/load/strict_keys.rs b/src/config/load/strict_keys.rs
index 6229bb9..1f1a90e 100644
--- a/src/config/load/strict_keys.rs
+++ b/src/config/load/strict_keys.rs
@@ -339,6 +339,9 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
"stream_handshake_secs",
"stream_first_byte_secs",
"long_poll_secs",
+ "bridge_request_secs",
+ "bridge_retry_secs",
+ "carrier_probe_coalesce_ms",
"lane_open_wait_secs",
"carrier_health_secs",
"websocket_upgrade_secs",
diff --git a/src/config/load/validate_web/timeouts.rs b/src/config/load/validate_web/timeouts.rs
index dd4b989..4c9877c 100644
--- a/src/config/load/validate_web/timeouts.rs
+++ b/src/config/load/validate_web/timeouts.rs
@@ -33,6 +33,18 @@ pub(super) fn validate(timeouts: &WebTimeoutsConfig) -> Result<()> {
if !(2..=86_400).contains(&timeouts.carrier_learning_secs) {
return config_error("web.timeouts.carrier_learning_secs must be within [2, 86400]");
}
+ if !(1..=60).contains(&timeouts.bridge_request_secs) {
+ return config_error("web.timeouts.bridge_request_secs must be within [1, 60]");
+ }
+ if !(1..=300).contains(&timeouts.bridge_retry_secs) {
+ return config_error("web.timeouts.bridge_retry_secs must be within [1, 300]");
+ }
+ if timeouts.bridge_request_secs > timeouts.bridge_retry_secs {
+ return config_error("web.timeouts.bridge_request_secs must not exceed bridge_retry_secs");
+ }
+ if timeouts.carrier_probe_coalesce_ms > 10 {
+ return config_error("web.timeouts.carrier_probe_coalesce_ms must be within [0, 10]");
+ }
if timeouts.stream_first_byte_secs > 300 {
return config_error("web.timeouts.stream_first_byte_secs must be within [1, 300]");
}
diff --git a/src/config/tests/load_basic_tests/web_tests.rs b/src/config/tests/load_basic_tests/web_tests.rs
index c12919a..a223666 100644
--- a/src/config/tests/load_basic_tests/web_tests.rs
+++ b/src/config/tests/load_basic_tests/web_tests.rs
@@ -108,10 +108,10 @@ fn web_carriers_reject_true_empty_and_duplicates() {
}
#[test]
-fn web_carrier_deadlines_and_learning_window_are_configurable() {
+fn web_carrier_and_bridge_deadlines_are_configurable() {
let configured = WEB_CONFIG.replace(
"[[web.vhosts]]",
- "[web.timeouts]\ncarrier_negotiation_deadlines_secs = [1, 2, 4, 9]\ncarrier_learning_secs = 30\n\n[[web.vhosts]]",
+ "[web.timeouts]\ncarrier_negotiation_deadlines_secs = [1, 2, 4, 9]\ncarrier_learning_secs = 30\nbridge_request_secs = 7\nbridge_retry_secs = 41\ncarrier_probe_coalesce_ms = 4\n\n[[web.vhosts]]",
);
let config = load_config_from_temp_toml(&configured);
assert_eq!(
@@ -119,6 +119,51 @@ fn web_carrier_deadlines_and_learning_window_are_configurable() {
[1, 2, 4, 9]
);
assert_eq!(config.web.timeouts.carrier_learning_secs, 30);
+ assert_eq!(config.web.timeouts.bridge_request_secs, 7);
+ assert_eq!(config.web.timeouts.bridge_retry_secs, 41);
+ assert_eq!(config.web.timeouts.carrier_probe_coalesce_ms, 4);
+}
+
+#[test]
+fn web_bridge_deadlines_are_known_in_strict_mode() {
+ let configured = WEB_CONFIG.replace(
+ "[[web.vhosts]]",
+ "[web.timeouts]\nbridge_request_secs = 7\nbridge_retry_secs = 41\ncarrier_probe_coalesce_ms = 4\n\n[[web.vhosts]]",
+ );
+ let configured = format!("[general]\nconfig_strict = true\n{configured}");
+ let config = load_config_from_temp_toml(&configured);
+
+ assert_eq!(config.web.timeouts.bridge_request_secs, 7);
+ assert_eq!(config.web.timeouts.bridge_retry_secs, 41);
+ assert_eq!(config.web.timeouts.carrier_probe_coalesce_ms, 4);
+}
+
+#[test]
+fn web_bridge_deadlines_are_bounded_and_ordered() {
+ for (field, value) in [
+ ("bridge_request_secs", "0"),
+ ("bridge_request_secs", "61"),
+ ("bridge_retry_secs", "0"),
+ ("bridge_retry_secs", "301"),
+ ("carrier_probe_coalesce_ms", "11"),
+ ] {
+ let invalid = WEB_CONFIG.replace(
+ "[[web.vhosts]]",
+ &format!("[web.timeouts]\n{field} = {value}\n\n[[web.vhosts]]"),
+ );
+ assert!(
+ load_config_error_from_temp_toml(&invalid).contains(&format!("web.timeouts.{field}"))
+ );
+ }
+
+ let reversed = WEB_CONFIG.replace(
+ "[[web.vhosts]]",
+ "[web.timeouts]\nbridge_request_secs = 20\nbridge_retry_secs = 10\n\n[[web.vhosts]]",
+ );
+ assert!(
+ load_config_error_from_temp_toml(&reversed)
+ .contains("bridge_request_secs must not exceed bridge_retry_secs")
+ );
}
#[test]
diff --git a/src/config/types/web.rs b/src/config/types/web.rs
index 863b533..42ef24f 100644
--- a/src/config/types/web.rs
+++ b/src/config/types/web.rs
@@ -288,6 +288,15 @@ pub struct WebTimeoutsConfig {
/// Maximum wait for one empty downlink long poll.
#[serde(default = "default_web_long_poll_timeout_secs")]
pub long_poll_secs: u64,
+ /// Deadline for one generated-bridge HTTP attempt and response body.
+ #[serde(default = "default_web_bridge_request_secs")]
+ pub bridge_request_secs: u64,
+ /// Absolute generated-bridge budget for one retryable HTTP operation.
+ #[serde(default = "default_web_bridge_retry_secs")]
+ pub bridge_retry_secs: u64,
+ /// Optional delay for coalescing the first OPEN with immediate DATA.
+ #[serde(default = "default_web_carrier_probe_coalesce_ms")]
+ pub carrier_probe_coalesce_ms: u64,
/// Grace for a canonical downlink poll that races its lane OPEN.
#[serde(default = "default_web_lane_open_wait_secs")]
pub lane_open_wait_secs: u64,
@@ -340,6 +349,9 @@ impl Default for WebTimeoutsConfig {
stream_handshake_secs: default_web_stream_handshake_timeout_secs(),
stream_first_byte_secs: default_web_stream_first_byte_secs(),
long_poll_secs: default_web_long_poll_timeout_secs(),
+ bridge_request_secs: default_web_bridge_request_secs(),
+ bridge_retry_secs: default_web_bridge_retry_secs(),
+ carrier_probe_coalesce_ms: default_web_carrier_probe_coalesce_ms(),
lane_open_wait_secs: default_web_lane_open_wait_secs(),
carrier_health_secs: default_web_carrier_health_secs(),
websocket_upgrade_secs: default_web_websocket_upgrade_secs(),
diff --git a/src/config/types/web/defaults.rs b/src/config/types/web/defaults.rs
index f85b44d..42db74f 100644
--- a/src/config/types/web/defaults.rs
+++ b/src/config/types/web/defaults.rs
@@ -85,6 +85,9 @@ u64_default!(default_web_body_timeout_secs, 30);
u64_default!(default_web_stream_handshake_timeout_secs, 10);
u64_default!(default_web_stream_first_byte_secs, 30);
u64_default!(default_web_long_poll_timeout_secs, 25);
+u64_default!(default_web_bridge_request_secs, 10);
+u64_default!(default_web_bridge_retry_secs, 90);
+u64_default!(default_web_carrier_probe_coalesce_ms, 0);
u64_default!(default_web_lane_open_wait_secs, 2);
u64_default!(default_web_carrier_health_secs, 30);
u64_default!(default_web_websocket_upgrade_secs, 5);
diff --git a/src/metrics.rs b/src/metrics.rs
index 9bd36df..fb35b84 100644
--- a/src/metrics.rs
+++ b/src/metrics.rs
@@ -3572,24 +3572,24 @@ async fn render_metrics(
let _ = writeln!(out, "# TYPE telemt_user_connections_current gauge");
let _ = writeln!(
out,
- "# HELP telemt_user_octets_from_client_total Per-user total bytes received"
+ "# HELP telemt_user_octets_from_client Per-user bytes received"
);
- let _ = writeln!(out, "# TYPE telemt_user_octets_from_client_total counter");
+ let _ = writeln!(out, "# TYPE telemt_user_octets_from_client counter");
let _ = writeln!(
out,
- "# HELP telemt_user_octets_to_client_total Per-user total bytes sent"
+ "# HELP telemt_user_octets_to_client Per-user bytes sent"
);
- let _ = writeln!(out, "# TYPE telemt_user_octets_to_client_total counter");
+ let _ = writeln!(out, "# TYPE telemt_user_octets_to_client counter");
let _ = writeln!(
out,
- "# HELP telemt_user_msgs_from_client_total Per-user total messages received"
+ "# HELP telemt_user_msgs_from_client Per-user messages received"
);
- let _ = writeln!(out, "# TYPE telemt_user_msgs_from_client_total counter");
+ let _ = writeln!(out, "# TYPE telemt_user_msgs_from_client counter");
let _ = writeln!(
out,
- "# HELP telemt_user_msgs_to_client_total Per-user total messages sent"
+ "# HELP telemt_user_msgs_to_client Per-user messages sent"
);
- let _ = writeln!(out, "# TYPE telemt_user_msgs_to_client_total counter");
+ let _ = writeln!(out, "# TYPE telemt_user_msgs_to_client counter");
let _ = writeln!(
out,
"# HELP telemt_ip_reservation_rollback_total IP reservation rollbacks caused by later limit checks"
@@ -3708,28 +3708,28 @@ async fn render_metrics(
);
let _ = writeln!(
out,
- "telemt_user_octets_from_client_total{{user=\"{}\"}} {}",
+ "telemt_user_octets_from_client{{user=\"{}\"}} {}",
user,
s.octets_from_client
.load(std::sync::atomic::Ordering::Relaxed)
);
let _ = writeln!(
out,
- "telemt_user_octets_to_client_total{{user=\"{}\"}} {}",
+ "telemt_user_octets_to_client{{user=\"{}\"}} {}",
user,
s.octets_to_client
.load(std::sync::atomic::Ordering::Relaxed)
);
let _ = writeln!(
out,
- "telemt_user_msgs_from_client_total{{user=\"{}\"}} {}",
+ "telemt_user_msgs_from_client{{user=\"{}\"}} {}",
user,
s.msgs_from_client
.load(std::sync::atomic::Ordering::Relaxed)
);
let _ = writeln!(
out,
- "telemt_user_msgs_to_client_total{{user=\"{}\"}} {}",
+ "telemt_user_msgs_to_client{{user=\"{}\"}} {}",
user,
s.msgs_to_client.load(std::sync::atomic::Ordering::Relaxed)
);
@@ -3990,10 +3990,10 @@ mod tests {
assert!(output.contains("telemt_me_endpoint_quarantine_draining_suppressed_total 1"));
assert!(output.contains("telemt_user_connections_total{user=\"alice\"} 1"));
assert!(output.contains("telemt_user_connections_current{user=\"alice\"} 1"));
- assert!(output.contains("telemt_user_octets_from_client_total{user=\"alice\"} 1024"));
- assert!(output.contains("telemt_user_octets_to_client_total{user=\"alice\"} 2048"));
- assert!(output.contains("telemt_user_msgs_from_client_total{user=\"alice\"} 1"));
- assert!(output.contains("telemt_user_msgs_to_client_total{user=\"alice\"} 2"));
+ assert!(output.contains("telemt_user_octets_from_client{user=\"alice\"} 1024"));
+ assert!(output.contains("telemt_user_octets_to_client{user=\"alice\"} 2048"));
+ assert!(output.contains("telemt_user_msgs_from_client{user=\"alice\"} 1"));
+ assert!(output.contains("telemt_user_msgs_to_client{user=\"alice\"} 2"));
assert!(output.contains("telemt_user_unique_ips_current{user=\"alice\"} 1"));
assert!(output.contains("telemt_user_unique_ips_recent_window{user=\"alice\"} 1"));
assert!(output.contains("telemt_user_unique_ips_limit{user=\"alice\"} 4"));
diff --git a/src/web/bridge.rs b/src/web/bridge.rs
index c262351..7f3759a 100644
--- a/src/web/bridge.rs
+++ b/src/web/bridge.rs
@@ -24,12 +24,17 @@ pub(crate) fn render(
negotiation_enabled: bool,
candidate_count: usize,
carrier_deadlines: [u64; 4],
+ long_poll_secs: u64,
+ bridge_request_secs: u64,
+ bridge_retry_secs: u64,
+ carrier_probe_coalesce_ms: u64,
rng: &SecureRandom,
) -> BridgePage {
let mut nonce = [0u8; 18];
rng.fill(&mut nonce);
let nonce = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(nonce);
let body = DOCUMENT
+ .replace("__RUNTIME__", RUNTIME)
.replace("__NONCE__", &nonce)
.replace("__HOST__", host)
.replace("__BOOTSTRAP__", bootstrap)
@@ -41,6 +46,13 @@ pub(crate) fn render(
if negotiation_enabled { "true" } else { "false" },
)
.replace("__CANDIDATE_COUNT__", &candidate_count.to_string())
+ .replace("__LONG_POLL_SECS__", &long_poll_secs.to_string())
+ .replace("__BRIDGE_REQUEST_SECS__", &bridge_request_secs.to_string())
+ .replace("__BRIDGE_RETRY_SECS__", &bridge_retry_secs.to_string())
+ .replace(
+ "__CARRIER_PROBE_COALESCE_MS__",
+ &carrier_probe_coalesce_ms.to_string(),
+ )
.replace(
"__CARRIER_DEADLINES__",
&carrier_deadlines
@@ -57,461 +69,8 @@ pub(crate) fn render(
}
}
-const DOCUMENT: &str = r##"
-
-
-
-
-Connection
-
-
-
-
-
-"##;
+const DOCUMENT: &str = include_str!("bridge/document.html");
+const RUNTIME: &str = include_str!("bridge/runtime.js");
// Rendered wire-contract tests remain separate from the embedded document.
#[cfg(test)]
diff --git a/src/web/bridge/document.html b/src/web/bridge/document.html
new file mode 100644
index 0000000..a7c25b5
--- /dev/null
+++ b/src/web/bridge/document.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+Connection
+
+
+
+
+
diff --git a/src/web/bridge/runtime.js b/src/web/bridge/runtime.js
new file mode 100644
index 0000000..f684017
--- /dev/null
+++ b/src/web/bridge/runtime.js
@@ -0,0 +1,522 @@
+(()=>{
+'use strict';
+const bootstrap="__BOOTSTRAP__";
+const relayOrigin='https://__HOST__',carrierCapabilities='https,https-lanes,websocket,websocket-lanes';
+const negotiationEnabled=__NEGOTIATION_ENABLED__,candidateCount=__CANDIDATE_COUNT__,candidateDeadlines=[__CARRIER_DEADLINES__];
+const longPollMs=__LONG_POLL_SECS__*1000,bridgeRequestMs=__BRIDGE_REQUEST_SECS__*1000,bridgeRetryMs=__BRIDGE_RETRY_SECS__*1000;
+const probeCoalesceMs=__CARRIER_PROBE_COALESCE_MS__;
+let negotiatedCandidateCount=candidateCount,negotiatedFinalDeadline=candidateDeadlines[3],negotiatedFrozen=false;
+const batchLimit=__BATCH_LIMIT__,queueLimit=__QUEUE_LIMIT__,queueItemLimit=__QUEUE_ITEMS__;
+const laneQueueLimit=Math.min(queueLimit,8388608),laneItemLimit=Math.min(queueItemLimit,1024),closedLaneLimit=4096;
+const fragment=location.hash,androidNonce=/^#android=([A-Za-z0-9_-]{43})$/.exec(fragment)?.[1]||'';
+history.replaceState(null,'',location.pathname);
+let initialized=false,closed=false,port=null,sessionToken='',cleanupToken='',createStarted=false,socket=null,socketReady=false,carrier='';
+let queuedBytes=0,queuedItems=0,upSequence=1,downCursor='0',upRunning=false,upLease=null,pollController=null;
+let helloFrame=null,welcomeSent=false,carrierAttempt=1,carrierFailure='',carrierCommitted=false;
+let negotiationStartedAt=0,carrierTimer=null,probeTimer=null,attemptController=null,attemptEpoch=1,candidateRunning=false,switching=false,currentAttempt=null;
+const pending=[],upPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[];
+const status=state=>{if(port&&!closed)port.postMessage({t:'status',state})};
+const pause=(milliseconds,signal)=>new Promise((resolve,reject)=>{
+ if(signal&&signal.aborted){reject(new Error('request aborted'));return}
+ const timer=setTimeout(done,milliseconds);function done(){if(signal)signal.removeEventListener('abort',abort);resolve()}
+ function abort(){clearTimeout(timer);signal.removeEventListener('abort',abort);reject(new Error('request aborted'))}
+ if(signal)signal.addEventListener('abort',abort,{once:true});
+});
+const socketURL=()=>relayOrigin.replace(/^https:/,'wss:')+'/api/v1/ws';
+const options=(method,token,body,headers,signal,keepalive)=>({
+ method,body,signal,keepalive:!!keepalive,mode:'same-origin',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer',
+ headers:Object.assign(token?{Authorization:'Bearer '+token}:{},body?{'Content-Type':'application/octet-stream'}:{},headers||{})
+});
+const attemptHeaders=(attempt,failure)=>negotiationEnabled?Object.assign({'X-Carrier-Capabilities':carrierCapabilities,'X-Carrier-Attempt':String(attempt)},failure?{'X-Carrier-Failure':failure}:{}):{};
+function reserve(data,lane){
+ let buffered=socket?socket.bufferedAmount:0;for(const value of lanes.values())if(value.socket)buffered+=value.socket.bufferedAmount;
+ if(!data.byteLength||data.byteLength>queueLimit-queuedBytes-buffered||queuedItems>=queueItemLimit)return false;
+ if(lane&&(data.byteLength>laneQueueLimit-lane.bytes-(lane.socket?lane.socket.bufferedAmount:0)||lane.items>=laneItemLimit))return false;
+ queuedBytes+=data.byteLength;queuedItems++;if(lane){lane.bytes+=data.byteLength;lane.items++}return true;
+}
+function release(bytes,items,lane){
+ if(bytes>queuedBytes||items>queuedItems||(lane&&(bytes>lane.bytes||items>lane.items)))throw new Error('queue accounting invariant');
+ queuedBytes-=bytes;queuedItems-=items;if(lane){lane.bytes-=bytes;lane.items-=items}
+}
+function releasePending(values,lane){
+ if(!values.length)return;let bytes=0;for(const value of values)bytes+=value.byteLength;
+ const items=values.length;values.length=0;release(bytes,items,lane);
+}
+function frameBound(value,maxFrames,maxBytes){
+ const view=new DataView(value);let offset=0,frames=0;
+ while(offset1048576||end>value.byteLength)throw new Error('invalid frame');
+ if(frames>0&&(frames>=maxFrames||end>maxBytes))break;
+ frames++;offset=end;
+ }
+ if(!frames)throw new Error('empty frame batch');
+ return {frames,bytes:offset};
+}
+function splitFrames(value){
+ const view=new DataView(value),result=[];let offset=0;
+ while(offset=4096)throw new Error('invalid frame batch');
+ const type=view.getUint8(offset),id=(view.getUint8(offset+1)<<16)|(view.getUint8(offset+2)<<8)|view.getUint8(offset+3);
+ const size=view.getUint32(offset+4),end=offset+8+size;
+ if((type===2&&!size)||size>1048576||end>value.byteLength)throw new Error('invalid frame');
+ result.push({type,id,data:offset===0&&end===value.byteLength?value:value.slice(offset,end)});offset=end;
+ }
+ if(!result.length)throw new Error('empty frame batch');return result;
+}
+function probeFrames(){
+ const result=[];let scanned=0;
+ for(let index=0;index=4096)throw new Error('invalid frame batch');
+ const type=view.getUint8(start),id=(view.getUint8(start+1)<<16)|(view.getUint8(start+2)<<8)|view.getUint8(start+3);
+ const size=view.getUint32(start+4),end=start+8+size,bytes=end-start;
+ if((type===2&&!size)||size>1048576||end>source.byteLength)throw new Error('invalid frame');
+ if(scanned+bytes>batchLimit)return result;
+ result.push({source,index,start,end,type,id});scanned+=bytes;start=end;
+ }
+ }
+ return result;
+}
+function findProbe(includeData){
+ const frames=probeFrames(),first=frames.findIndex(frame=>frame.type===1||frame.type===2);if(first<0)return null;
+ const laneMode=carrier==='https-lanes'||carrier==='websocket-lanes',selected=[];let hasData=frames[first].type===2;
+ if(laneMode){
+ selected.push(frames[first]);
+ if(includeData&&!hasData)for(let index=first+1;indexright-left);
+ for(const index of indexes){
+ const source=pending[index],spans=groups.get(index).sort((left,right)=>left.start-right.start);let removed=0,offset=0;
+ for(const span of spans){if(span.startbatchLimit||frames+bound.frames>4096))break;
+ total+=values[count].byteLength;frames+=bound.frames;count++;
+ }
+ const joined=new Uint8Array(total);let offset=0;
+ for(const data of values.splice(0,count)){joined.set(new Uint8Array(data),offset);offset+=data.byteLength}
+ return {body:joined.buffer,total,count};
+}
+function takeBatch(values,lane){
+ const batch=joinPending(values,lane);
+ return Object.assign(batch,{lane,controller:null,cancelled:false,settled:false});
+}
+function settleBatch(lease){
+ if(!lease||lease.settled)return false;lease.settled=true;
+ if(lease.lane){if(lease.lane.upLease===lease)lease.lane.upLease=null}else if(upLease===lease)upLease=null;
+ release(lease.total,lease.count,lease.lane);return true;
+}
+function cancelBatch(lease){
+ if(!lease||lease.settled)return;lease.cancelled=true;if(lease.controller)lease.controller.abort();settleBatch(lease);
+}
+function retryAfterMs(response){
+ const header=response.headers.get('Retry-After');
+ if(!header)return 0;
+ const seconds=Number(header);
+ if(Number.isFinite(seconds)&&seconds>=0)return Math.min(seconds*1000,30000);
+ const when=Date.parse(header);
+ if(Number.isFinite(when)){const delta=when-Date.now();return delta>0?Math.min(delta,30000):0}
+ return 0;
+}
+function retryableStatus(status){return status===408||status===429||status===502||status===503||status===504}
+async function request(path,frozenOptions){
+ let delay=250,attempt=0;const deadline=Date.now()+bridgeRetryMs,external=frozenOptions.signal;
+ const attemptLimit=path==='/api/v1/down'?longPollMs+bridgeRequestMs:bridgeRequestMs;
+ while(attempt<9){
+ if(closed||(external&&external.aborted))throw new Error('request aborted');
+ const remaining=deadline-Date.now();if(remaining<=0)break;attempt++;
+ const controller=new AbortController(),abort=()=>controller.abort();
+ if(external)external.addEventListener('abort',abort,{once:true});
+ const requestOptions=Object.assign({},frozenOptions,{signal:controller.signal});
+ const timer=setTimeout(abort,Math.max(1,Math.min(attemptLimit,remaining)));
+ let response=null,wait=0;
+ try{
+ const fetched=await fetch(relayOrigin+path,requestOptions),body=await fetched.arrayBuffer();
+ response={status:fetched.status,headers:fetched.headers,body};
+ if(!retryableStatus(response.status))return response;
+ wait=retryAfterMs(response);
+ }catch(error){
+ if(closed||(external&&external.aborted))throw error;
+ }finally{clearTimeout(timer);if(external)external.removeEventListener('abort',abort)}
+ const after=deadline-Date.now();if(attempt>=9||after<=0)break;
+ status('reconnecting');
+ const backoff=wait||delay+Math.floor(Math.random()*Math.max(1,delay/4));
+ await pause(Math.min(backoff,after),external);delay=Math.min(delay*2,5000);
+ }
+ throw new Error('carrier retry limit reached');
+}
+function fail(){if(closed)return;status('failed');if(port)port.postMessage({t:'close'});close(true)}
+function knownCarrier(value){return value==='https'||value==='https-lanes'||value==='websocket'||value==='websocket-lanes'}
+function sessionEcho(response,expectedAttempt,states,exactAttempt){
+ const selected=response.headers.get('X-Carrier-Mode')||'',echo=response.headers.get('X-Carrier-Attempt')||'';
+ if(!knownCarrier(selected))throw new Error('invalid carrier mode');
+ if(!negotiationEnabled){if(echo!=='')throw new Error('unexpected carrier attempt');return {selected,state:''}}
+ const count=response.headers.get('X-Carrier-Candidate-Count')||'',deadline=response.headers.get('X-Carrier-Deadline')||'',state=response.headers.get('X-Carrier-State')||'';
+ if(!/^[1-4]$/.test(count)||!/^[1-9]\d*$/.test(deadline)||!states.includes(state))throw new Error('invalid carrier state');
+ const echoedAttempt=Number(echo),parsedCount=Number(count),parsedDeadline=Number(deadline);
+ if(!Number.isInteger(echoedAttempt)||echoedAttempt<1||(exactAttempt?echoedAttempt!==expectedAttempt:echoedAttempt>expectedAttempt))throw new Error('invalid carrier attempt');
+ if(parsedCount>candidateCount||parsedDeadline>candidateDeadlines[3])throw new Error('invalid carrier bounds');
+ if(!negotiatedFrozen){negotiatedCandidateCount=parsedCount;negotiatedFinalDeadline=parsedDeadline;negotiatedFrozen=true}
+ else if(parsedCount!==negotiatedCandidateCount||parsedDeadline!==negotiatedFinalDeadline)throw new Error('changed carrier bounds');
+ if(echoedAttempt>negotiatedCandidateCount)throw new Error('carrier attempt exceeds candidates');
+ return {selected,state};
+}
+function armCarrierDeadline(epoch){
+ if(!negotiationStartedAt||epoch!==attemptEpoch)return;
+ if(carrierTimer)clearTimeout(carrierTimer);
+ const deadline=carrierAttempt>=negotiatedCandidateCount?negotiatedFinalDeadline:candidateDeadlines[carrierAttempt-1];
+ const remaining=negotiationStartedAt+deadline*1000-Date.now();
+ carrierTimer=setTimeout(()=>advanceCarrier('timeout',epoch),Math.max(0,remaining));
+}
+function clearProbeTimer(){if(probeTimer){clearTimeout(probeTimer.timer);probeTimer=null}}
+function resetCandidate(){
+ clearProbeTimer();
+ if(pollController)pollController.abort();pollController=null;
+ if(socket){const previous=socket;socket=null;previous.close()}socketReady=false;
+ cancelBatch(upLease);releasePending(upPending,null);
+ for(const lane of lanes.values()){
+ if(lane.controller)lane.controller.abort();cancelBatch(lane.upLease);releasePending(lane.pending,lane);if(lane.socket)lane.socket.close();
+ }
+ lanes.clear();closedLanes.clear();closedLaneOrder.length=0;upSequence=1;downCursor='0';upRunning=false;
+ sessionToken='';carrier='';candidateRunning=false;currentAttempt=null;
+}
+function advanceConfirmed(reason,epoch){
+ if(closed||carrierCommitted||epoch!==attemptEpoch)return;
+ resetCandidate();
+ if(carrierAttempt>=negotiatedCandidateCount||Date.now()>=negotiationStartedAt+negotiatedFinalDeadline*1000){switching=false;fail();return}
+ carrierAttempt++;carrierFailure=reason;attemptEpoch++;const nextEpoch=attemptEpoch;switching=false;
+ status('reconnecting');armCarrierDeadline(nextEpoch);createSession(nextEpoch);
+}
+function advanceCarrier(reason,epoch){
+ if(closed||carrierCommitted||epoch!==attemptEpoch||switching)return;
+ if(!negotiationEnabled){fail();return}
+ switching=true;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;clearProbeTimer();
+ const snapshot=currentAttempt;if(attemptController)attemptController.abort();attemptController=null;
+ if(!snapshot||snapshot.epoch!==epoch){switching=false;fail();return}
+ if(snapshot.selected){advanceConfirmed(reason,epoch);return}
+ resolveAttempt(reason,epoch,snapshot);
+}
+async function resolveAttempt(reason,epoch,snapshot){
+ const controller=new AbortController();attemptController=controller;
+ const remaining=negotiationStartedAt+negotiatedFinalDeadline*1000-Date.now();
+ if(remaining<=0){switching=false;fail();return}
+ const timer=setTimeout(()=>controller.abort(),remaining);
+ try{
+ const frozen=options('POST',bootstrap,snapshot.hello,attemptHeaders(snapshot.attempt,snapshot.failure),controller.signal);
+ const response=await request('/api/v1/session',frozen);
+ if(closed||epoch!==attemptEpoch)return
+ if(response.status===409){sessionEcho(response,snapshot.attempt,['committed','healthy'],false);switching=false;fail();return}
+ if(response.status!==200){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=response.body;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 startCandidate(probe,epoch){
+ if(!probe||closed||carrierCommitted||!sessionToken||candidateRunning||epoch!==attemptEpoch)return;
+ clearProbeTimer();candidateRunning=true;
+ if(carrier==='https')probeHttp(probe,null,epoch);
+ else if(carrier==='https-lanes')probeHttp(probe,probe.id,epoch);
+ else if(carrier==='websocket')openCandidateSocket(probe,null,epoch);
+ else if(carrier==='websocket-lanes')openCandidateSocket(probe,probe.id,epoch);
+ else advanceCarrier('protocol',epoch);
+}
+function maybeStartCandidate(){
+ if(closed||carrierCommitted||!sessionToken||candidateRunning)return;const epoch=attemptEpoch;
+ let probe;try{probe=findProbe(probeCoalesceMs>0)}catch(error){fail();return}if(!probe)return;
+ if(!probeCoalesceMs||probe.hasData){startCandidate(probe,epoch);return}
+ if(probeTimer)return;const owner={epoch,timer:null};
+ owner.timer=setTimeout(()=>{if(probeTimer!==owner||closed||owner.epoch!==attemptEpoch)return;probeTimer=null;let current;try{current=findProbe(false)}catch(error){fail();return}startCandidate(current,owner.epoch)},probeCoalesceMs);
+ probeTimer=owner;
+}
+async function createSession(epoch){
+ const controller=new AbortController(),attempt=carrierAttempt,failure=carrierFailure;
+ const snapshot={epoch,attempt,failure,hello:helloFrame,selected:''};currentAttempt=snapshot;attemptController=controller;
+ try{
+ status('connecting');
+ const frozen=options('POST',bootstrap,snapshot.hello,attemptHeaders(attempt,failure),controller.signal);
+ const response=await request('/api/v1/session',frozen);
+ if(closed||epoch!==attemptEpoch)return
+ if(response.status===409){sessionEcho(response,attempt,['committed','healthy'],false);fail();return}
+ if(response.status!==200){advanceCarrier('http',epoch);return}
+ const echo=sessionEcho(response,attempt,['provisional'],true),selected=echo.selected;snapshot.selected=selected;
+ const token=response.headers.get('X-Session-Token')||'',cursor=response.headers.get('X-Down-Cursor')||'';
+ if(!token||cursor!=='0'){advanceCarrier('protocol',epoch);return}
+ const welcome=response.body;if(closed||epoch!==attemptEpoch)return;
+ carrier=selected;sessionToken=token;cleanupToken=token;downCursor=cursor;
+ if(!welcomeSent){welcomeSent=true;port.postMessage(welcome,[welcome])}
+ if(carrier==='websocket')openCandidateSocket(null,null,epoch);
+ maybeStartCandidate();
+ }catch(error){if(closed||epoch!==attemptEpoch)return;advanceCarrier('network',epoch)}
+}
+async function probeHttp(probe,laneID,epoch){
+ try{
+ const headers={'X-Up-Seq':'1'},token=sessionToken,controller=attemptController,body=probe.data;if(laneID!==null)headers['X-Lane-ID']=String(laneID);
+ const response=await request('/api/v1/up',options('POST',token,body,headers,controller.signal));
+ if(closed||epoch!==attemptEpoch)return
+ if(response.status!==204){advanceCarrier('http',epoch);return}
+ if(response.headers.get('X-Up-Ack')!=='1'){advanceCarrier('protocol',epoch);return}
+ if(laneID===null)upSequence=2;else ensureLane(laneID).sequence=2;
+ commitCarrier(probe,epoch);
+ }catch(error){if(!closed&&epoch===attemptEpoch)advanceCarrier('network',epoch)}
+}
+function commitCarrier(probe,epoch){
+ if(closed||carrierCommitted||epoch!==attemptEpoch)return;
+ if(switching){fail();return}
+ clearProbeTimer();try{consumeProbe(probe)}catch(error){fail();return}
+ carrierCommitted=true;candidateRunning=false;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
+ attemptController=null;currentAttempt=null;
+ status('connected');
+ if(carrier==='https')poll();
+ else if(carrier==='https-lanes'){const lane=lanes.get(probe.id);if(lane&&!lane.polling)pollLane(lane)}
+ for(const data of pending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)}
+}
+function queueCarrier(data){
+ try{
+ if(carrier==='https')queueUp(data);
+ else if(carrier==='websocket')queueSocket(data);
+ else for(const value of splitFrames(data))queueLane(value);
+ }catch(error){fail()}
+}
+function queueUp(data){if(!reserve(data,null)){fail();return}upPending.push(data);runUp()}
+async function runUp(){
+ if(upRunning)return;upRunning=true;let lease=null;
+ try{
+ while(!closed&&sessionToken&&upPending.length){
+ lease=takeBatch(upPending,null);upLease=lease;lease.controller=new AbortController();const sequence=String(upSequence);
+ const response=await request('/api/v1/up',options('POST',sessionToken,lease.body,{'X-Up-Seq':sequence},lease.controller.signal));
+ if(response.status!==204||response.headers.get('X-Up-Ack')!==sequence)throw new Error('uplink rejected');
+ if(!settleBatch(lease))return;port.postMessage({t:'traffic',up:lease.total,down:0});upSequence++;lease=null;
+ }
+ }catch(error){if(!closed&&!(lease&&lease.cancelled))fail()}
+ finally{upRunning=false;if(!closed&&sessionToken&&upPending.length)runUp()}
+}
+function sendCandidateSocket(next){
+ const state=next.telemt;if(!state||state.sent||next.readyState!==WebSocket.OPEN||!state.probe)return;
+ let probe=state.probe;
+ try{const fresh=findProbe(true);if(fresh&&fresh.id===probe.id)probe=fresh;next.send(probe.data)}catch(error){advanceCarrier('upgrade',state.epoch);return}
+ state.probe=probe;state.sent=true;if(!negotiationEnabled)commitCarrier(probe,state.epoch);
+}
+function openCandidateSocket(probe,laneID,epoch){
+ let lane=laneID===null?null:ensureLane(laneID),next=lane?lane.socket:socket;
+ if(next){if(!next.telemt||next.telemt.epoch!==epoch){advanceCarrier('protocol',epoch);return}if(probe)next.telemt.probe=probe;sendCandidateSocket(next);return}
+ const token=sessionToken,protocol=laneID===null?(negotiationEnabled?'tproxy-auto-v1.':'tproxy-v1.')+token:(negotiationEnabled?'tproxy-auto-lane-v1.':'tproxy-lane-v1.')+token+'.'+String(laneID);
+ next=new WebSocket(socketURL(),protocol);next.binaryType='arraybuffer';next.telemt={epoch,lane,probe,opened:false,sent:false};
+ if(lane)lane.socket=next;else socket=next;
+ next.onopen=()=>{
+ const state=next.telemt;if(closed||state.epoch!==attemptEpoch){next.close();return}state.opened=true;
+ if(state.lane){state.lane.ready=true}else socketReady=true;sendCandidateSocket(next);
+ };
+ next.onmessage=event=>{
+ const state=next.telemt;if(closed||state.epoch!==attemptEpoch||!(event.data instanceof ArrayBuffer))return;
+ if(!carrierCommitted){if(!state.sent||event.data.byteLength!==0){advanceCarrier('protocol',state.epoch);return}commitCarrier(state.probe,state.epoch);return}
+ try{
+ if(state.lane){const values=splitFrames(event.data);for(const value of values)if(value.id!==state.lane.id)throw new Error('cross-lane frame');if(values.some(value=>value.type===3))state.lane.remoteClosed=true}
+ else{const bound=frameBound(event.data,4096,batchLimit);if(bound.bytes!==event.data.byteLength)throw new Error('invalid frame batch')}
+ }catch(error){if(state.lane)finishLane(state.lane,true);else fail();return}
+ port.postMessage({t:'traffic',up:0,down:event.data.byteLength});port.postMessage(event.data,[event.data]);status('connected');
+ };
+ next.onerror=()=>{};
+ next.onclose=()=>{
+ const state=next.telemt;if(state.epoch!==attemptEpoch||closed)return;
+ if(!carrierCommitted){advanceCarrier(state.opened?'network':'upgrade',state.epoch);return}
+ if(state.lane){state.lane.ready=false;state.lane.socket=null;finishLane(state.lane,true)}else{socketReady=false;fail()}
+ };
+}
+function queueSocket(data){if(!reserve(data,null)){fail();return}upPending.push(data);runSocketUp()}
+async function waitSocket(next,size,limit,signal){
+ while(!closed&&next.readyState===WebSocket.OPEN&&next.bufferedAmount>limit-size)await pause(10,signal);
+ if(closed||(signal&&signal.aborted)||next.readyState!==WebSocket.OPEN)throw new Error('websocket closed');
+}
+async function runSocketUp(){
+ if(upRunning||!socketReady)return;upRunning=true;let lease=null;
+ try{
+ while(!closed&&socketReady&&upPending.length){
+ lease=takeBatch(upPending,null);upLease=lease;lease.controller=new AbortController();
+ await waitSocket(socket,lease.total,queueLimit,lease.controller.signal);socket.send(lease.body);
+ if(!settleBatch(lease))return;port.postMessage({t:'traffic',up:lease.total,down:0});lease=null;
+ }
+ }catch(error){if(!closed&&!(lease&&lease.cancelled))fail()}
+ finally{upRunning=false;if(!closed&&socketReady&&upPending.length)runSocketUp()}
+}
+async function poll(){
+ while(!closed&&sessionToken){
+ try{
+ pollController=new AbortController();
+ const response=await request('/api/v1/down',options('POST',sessionToken,null,{'X-Down-Cursor':downCursor},pollController.signal));
+ if(response.status===204){status('connected');continue}
+ if(response.status!==200)throw new Error('downlink rejected');
+ const next=response.headers.get('X-Down-Cursor')||'',data=response.body;
+ if(!next||!data.byteLength)throw new Error('invalid downlink response');
+ if(closed)return;
+ port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);downCursor=next;status('connected');
+ }catch(error){if(!closed)fail();return}
+ }
+}
+function ensureLane(id){
+ let lane=lanes.get(id);
+ if(!lane){lane={id,sequence:1,cursor:'0',pending:[],bytes:0,items:0,running:false,upLease:null,polling:false,controller:null,socket:null,ready:false,remoteClosed:false};lanes.set(id,lane)}
+ return lane;
+}
+function rememberLaneClosed(id){
+ if(!id||closedLanes.has(id))return;
+ if(closedLaneOrder.length===closedLaneLimit)closedLanes.delete(closedLaneOrder.shift());
+ closedLanes.add(id);closedLaneOrder.push(id);
+}
+function closeFrame(id){const value=new Uint8Array(8);value[0]=3;value[1]=(id>>>16)&255;value[2]=(id>>>8)&255;value[3]=id&255;return value.buffer}
+function finishLane(lane,notifyClient){
+ if(lanes.get(lane.id)!==lane)return;
+ if(lane.controller)lane.controller.abort();lane.controller=null;cancelBatch(lane.upLease);
+ if(lane.socket&&lane.socket.readyState{if(closed||lanes.get(lane.id)!==lane)return;lane.ready=true;status('connected');runLaneSocketUp(lane)};
+ lane.socket.onmessage=event=>{
+ if(closed||lanes.get(lane.id)!==lane||!(event.data instanceof ArrayBuffer)){finishLane(lane,true);return}
+ let values;try{values=splitFrames(event.data);for(const value of values)if(value.id!==lane.id)throw new Error('cross-lane frame')}catch(error){finishLane(lane,true);return}
+ if(values.some(value=>value.type===3))lane.remoteClosed=true;
+ port.postMessage({t:'traffic',up:0,down:event.data.byteLength});port.postMessage(event.data,[event.data]);status('connected');
+ };
+ lane.socket.onerror=()=>{};lane.socket.onclose=()=>{lane.ready=false;lane.socket=null;if(!closed)finishLane(lane,true)};
+}
+async function runLaneSocketUp(lane){
+ if(lane.running||!lane.ready)return;lane.running=true;let lease=null;
+ try{
+ while(!closed&&lane.ready&&lanes.get(lane.id)===lane&&lane.pending.length){
+ lease=takeBatch(lane.pending,lane);lane.upLease=lease;lease.controller=new AbortController();
+ await waitSocket(lane.socket,lease.total,laneQueueLimit,lease.controller.signal);lane.socket.send(lease.body);
+ if(!settleBatch(lease))return;port.postMessage({t:'traffic',up:lease.total,down:0});lease=null;
+ }
+ }catch(error){if(!closed&&lanes.get(lane.id)===lane&&!(lease&&lease.cancelled))finishLane(lane,true)}
+ finally{lane.running=false;if(!closed&&lanes.get(lane.id)===lane&&lane.ready&&lane.pending.length)runLaneSocketUp(lane)}
+}
+async function runLaneUp(lane){
+ if(lane.running)return;lane.running=true;let lease=null;
+ try{
+ while(!closed&&sessionToken&&lane.pending.length){
+ lease=takeBatch(lane.pending,lane);lane.upLease=lease;lease.controller=new AbortController();
+ const sequence=String(lane.sequence),laneID=String(lane.id);
+ const response=await request('/api/v1/up',options('POST',sessionToken,lease.body,{'X-Up-Seq':sequence,'X-Lane-ID':laneID},lease.controller.signal));
+ if(response.status!==204||response.headers.get('X-Up-Ack')!==sequence)throw new Error('lane uplink rejected');
+ if(!settleBatch(lease))return;port.postMessage({t:'traffic',up:lease.total,down:0});lane.sequence++;lease=null;
+ if(!lane.polling)pollLane(lane);
+ }
+ }catch(error){if(!closed&&lanes.get(lane.id)===lane&&!(lease&&lease.cancelled))fail()}
+ finally{lane.running=false;if(!closed&&lanes.get(lane.id)===lane&&sessionToken&&lane.pending.length)runLaneUp(lane)}
+}
+async function pollLane(lane){
+ if(!lane||lane.polling)return;lane.polling=true;
+ try{
+ while(!closed&&sessionToken&&lanes.get(lane.id)===lane){
+ const controller=new AbortController(),laneID=String(lane.id);lane.controller=controller;
+ const response=await request('/api/v1/down',options('POST',sessionToken,null,{'X-Down-Cursor':lane.cursor,'X-Lane-ID':laneID},controller.signal));
+ if(response.status===204){
+ if(response.headers.get('X-Lane-Closed')==='1'){finishLane(lane,false);return}
+ status('connected');continue;
+ }
+ if(response.status!==200)throw new Error('lane downlink rejected');
+ const next=response.headers.get('X-Down-Cursor')||'',data=response.body;
+ if(!next||!data.byteLength)throw new Error('invalid lane downlink response');
+ for(const value of splitFrames(data))if(value.id!==lane.id)throw new Error('cross-lane frame');
+ if(closed)return;
+ port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);lane.cursor=next;status('connected');
+ }
+ }catch(error){if(!closed)fail()}
+ finally{lane.polling=false;lane.controller=null}
+}
+function deleteSession(){
+ const token=cleanupToken||sessionToken;if(token)fetch(relayOrigin+'/api/v1/session',options('DELETE',token,null,null,undefined,true)).catch(()=>{});
+}
+function close(notifyServer){
+ if(closed)return;closed=true;if(carrierTimer)clearTimeout(carrierTimer);clearProbeTimer();if(attemptController)attemptController.abort();if(pollController)pollController.abort();
+ if(socket)socket.close();cancelBatch(upLease);releasePending(upPending,null);
+ for(const lane of lanes.values()){
+ if(lane.controller)lane.controller.abort();cancelBatch(lane.upLease);releasePending(lane.pending,lane);if(lane.socket)lane.socket.close();
+ }
+ if(notifyServer)deleteSession();releasePending(pending,null);lanes.clear();if(port)port.close();
+ if(queuedBytes!==0||queuedItems!==0)throw new Error('queue accounting leak');
+}
+function activatePort(nextPort){
+ initialized=true;port=nextPort;
+ port.onmessage=message=>{
+ if(message.data instanceof ArrayBuffer){
+ if(!createStarted){createStarted=true;helloFrame=message.data;if(negotiationEnabled){negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch)}createSession(attemptEpoch)}
+ else if(!carrierCommitted){if(!reserve(message.data,null)){fail();return}pending.push(message.data);maybeStartCandidate()}
+ else queueCarrier(message.data);
+ }else if(message.data&&message.data.t==='close')close(true);
+ };
+ port.start();status('connecting');
+}
+addEventListener('message',event=>{
+ if(initialized||event.source!==parent||event.data===null||typeof event.data!=='object')return;
+ const keys=Object.keys(event.data).sort();
+ if(keys.length!==2||keys[0]!=='t'||keys[1]!=='v'||event.data.t!=='tproxy-init'||event.data.v!==1||event.ports.length!==1)return;
+ let source;try{source=new URL(event.origin)}catch(error){return}
+ if(source.protocol!=='http:'||source.hostname!=='127.0.0.1'||!source.port||source.origin!==event.origin)return;
+ activatePort(event.ports[0]);
+},{once:false});
+const androidBridge=globalThis.TelegramWebProxy;
+if(!initialized&&androidNonce&&androidBridge&&typeof androidBridge.postMessage==='function'){
+ const androidPort={onmessage:null,start(){},close(){androidBridge.onmessage=null},postMessage(value){
+ if(value instanceof ArrayBuffer){
+ let frames;try{frames=splitFrames(value)}catch(error){fail();return}
+ for(const frame of frames)androidBridge.postMessage(frame.data);
+ }else androidBridge.postMessage(JSON.stringify(value));
+ }};
+ androidBridge.onmessage=event=>{let data=event.data;if(typeof data==='string'){try{data=JSON.parse(data)}catch(error){return}}if(androidPort.onmessage)androidPort.onmessage({data})};
+ activatePort(androidPort);androidBridge.postMessage(JSON.stringify({t:'tproxy-android-init',v:1,nonce:androidNonce}));
+}
+addEventListener('pagehide',()=>close(true),{once:true});
+})();
diff --git a/src/web/bridge/tests.rs b/src/web/bridge/tests.rs
index 58fa517..ddab40f 100644
--- a/src/web/bridge/tests.rs
+++ b/src/web/bridge/tests.rs
@@ -10,6 +10,10 @@ fn render_page(bootstrap: &str, candidate_count: usize) -> BridgePage {
true,
candidate_count,
[3, 5, 8, 12],
+ 25,
+ 10,
+ 90,
+ 0,
&SecureRandom::new(),
)
}
@@ -43,6 +47,30 @@ fn rendered_page_preserves_the_ios_bootstrap_literal() {
);
}
+#[test]
+fn rendered_page_embeds_the_configured_bridge_timing_policy() {
+ let page = render(
+ "proxy.example.com",
+ "GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG",
+ 2 * 1024 * 1024,
+ 32 * 1024 * 1024,
+ 16 * 1024,
+ true,
+ 4,
+ [3, 5, 8, 12],
+ 17,
+ 7,
+ 41,
+ 4,
+ &SecureRandom::new(),
+ );
+
+ assert!(page.body.contains("const longPollMs=17*1000"));
+ assert!(page.body.contains("bridgeRequestMs=7*1000"));
+ assert!(page.body.contains("bridgeRetryMs=41*1000"));
+ assert!(page.body.contains("const probeCoalesceMs=4"));
+}
+
#[test]
fn effective_deadline_formula_uses_the_final_checkpoint() {
let page = render_page("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", 3);
@@ -67,6 +95,10 @@ fn disabled_negotiation_does_not_arm_a_carrier_deadline() {
false,
1,
[3, 5, 8, 12],
+ 25,
+ 10,
+ 90,
+ 0,
&SecureRandom::new(),
);
assert!(page.body.contains(
@@ -98,6 +130,10 @@ fn retry_and_attempt_state_are_frozen_before_fetch() {
#[test]
fn ambiguous_commit_is_resolved_before_carrier_advance() {
let page = render_page("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 4);
+ assert!(
+ page.body
+ .contains("if(snapshot.selected){advanceConfirmed(reason,epoch);return}")
+ );
assert!(page.body.contains("resolveAttempt(reason,epoch,snapshot)"));
assert!(page.body.contains(
"sessionEcho(response,snapshot.attempt,['provisional','committed','healthy'],true)"
diff --git a/src/web/http.rs b/src/web/http.rs
index c7f1946..9066df5 100644
--- a/src/web/http.rs
+++ b/src/web/http.rs
@@ -245,15 +245,20 @@ async fn handle_root(
trace.register_redaction(bootstrap.token.as_bytes());
}
let generation = runtime.active_generation();
+ let config = generation.config();
let page = bridge::render(
&vhost.host,
&bootstrap.token,
- generation.config().web.limits.carrier_batch_bytes,
- generation.config().web.limits.pending_bytes_per_session,
- generation.config().web.limits.pending_items_per_session,
+ config.web.limits.carrier_batch_bytes,
+ config.web.limits.pending_bytes_per_session,
+ config.web.limits.pending_items_per_session,
profile.carrier_negotiation_enabled,
profile.carriers.len(),
profile.carrier_negotiation_deadlines_secs,
+ config.web.timeouts.long_poll_secs,
+ config.web.timeouts.bridge_request_secs,
+ config.web.timeouts.bridge_retry_secs,
+ config.web.timeouts.carrier_probe_coalesce_ms,
&generation.rng,
);
let mut response = full_response(StatusCode::OK, Bytes::from(page.body));
@@ -348,7 +353,15 @@ async fn handle_up(
request,
body,
_body_budget,
- } = match collect_body(request, &runtime, limit, false).await {
+ } = match collect_body(
+ request,
+ &runtime,
+ Duration::from_secs(session.timeouts().body_secs),
+ limit,
+ false,
+ )
+ .await
+ {
Ok(result) => result,
Err(CollectBodyError::Limit) => return service_unavailable(),
Err(CollectBodyError::Invalid(request)) => {
diff --git a/src/web/http/body.rs b/src/web/http/body.rs
index 7550cda..38b4b64 100644
--- a/src/web/http/body.rs
+++ b/src/web/http/body.rs
@@ -114,6 +114,7 @@ pub(super) enum CollectBodyError {
pub(super) async fn collect_body(
request: Request,
runtime: &WebProcessRuntime,
+ body_timeout: Duration,
limit: usize,
allow_empty: bool,
) -> Result {
@@ -133,8 +134,6 @@ pub(super) async fn collect_body(
let Some((reader_budget, body_budget)) = runtime.try_body_budget(limit) else {
return Err(CollectBodyError::Limit);
};
- let body_timeout =
- Duration::from_secs(runtime.active_generation().config().web.timeouts.body_secs);
let body = match tokio::time::timeout(body_timeout, Limited::new(body, limit).collect()).await {
Ok(Ok(body)) => body.to_bytes(),
_ => {
diff --git a/src/web/http/down.rs b/src/web/http/down.rs
index 4796aaa..a95a36d 100644
--- a/src/web/http/down.rs
+++ b/src/web/http/down.rs
@@ -1,4 +1,5 @@
use std::sync::Arc;
+use std::time::Duration;
use hyper::header::{self, HeaderName, HeaderValue};
use hyper::{Request, StatusCode};
@@ -46,7 +47,15 @@ pub(super) async fn handle_down(
request,
body,
_body_budget,
- } = match collect_body(request, &runtime, 1, true).await {
+ } = match collect_body(
+ request,
+ &runtime,
+ Duration::from_secs(session.timeouts().body_secs),
+ 1,
+ true,
+ )
+ .await
+ {
Ok(result) => result,
Err(CollectBodyError::Limit) => return service_unavailable(),
Err(CollectBodyError::Invalid(request)) => {
diff --git a/src/web/http/negotiation_tests.rs b/src/web/http/negotiation_tests.rs
index 2ce9244..65e49ca 100644
--- a/src/web/http/negotiation_tests.rs
+++ b/src/web/http/negotiation_tests.rs
@@ -178,7 +178,7 @@ async fn metadata_free_native_client_uses_fallback_when_candidates_are_enabled()
}
#[tokio::test]
-async fn explicit_native_capabilities_participate_in_automatic_selection() {
+async fn explicit_native_capabilities_are_limited_to_https() {
let capability = [56; 32];
let generation = test_runtime_generation(
1,
@@ -202,12 +202,9 @@ async fn explicit_native_capabilities_participate_in_automatic_selection() {
.await;
let (headers, _) = split_response(&response);
assert!(headers.starts_with(b"HTTP/1.1 200"));
- assert_eq!(
- response_header(headers, "x-carrier-mode"),
- "websocket-lanes"
- );
+ assert_eq!(response_header(headers, "x-carrier-mode"), "https");
assert_eq!(response_header(headers, "x-carrier-attempt"), "1");
- assert_eq!(response_header(headers, "x-carrier-candidate-count"), "2");
+ assert_eq!(response_header(headers, "x-carrier-candidate-count"), "1");
runtime.shutdown().await;
generation.stop_sessions().await;
diff --git a/src/web/http/request.rs b/src/web/http/request.rs
index 4c677da..a0a1897 100644
--- a/src/web/http/request.rs
+++ b/src/web/http/request.rs
@@ -140,6 +140,11 @@ pub(super) fn carrier_request(request: &Request, host: &str) -> Option {
let capabilities = parse_capabilities(capabilities)?;
+ let capabilities = if native_ios {
+ capabilities.intersection(CarrierCapabilities::ios())?
+ } else {
+ capabilities
+ };
if (attempt == 1) != failure.is_none() {
return None;
}
@@ -156,15 +161,14 @@ pub(super) fn carrier_request(request: &Request, host: &str) -> Option {
+ if native_ios {
+ return None;
+ }
if (attempt == 1) != failure.is_none() {
return None;
}
Some(CarrierRequest::automatic(
- if native_ios {
- CarrierClientClass::Ios
- } else {
- CarrierClientClass::BrowserHint
- },
+ CarrierClientClass::BrowserHint,
CarrierCapabilities::all(),
attempt,
failure,
@@ -259,284 +263,7 @@ fn single_header(request: &Request, name: impl header::AsHeaderName) -> Op
values.next().is_none().then_some(value)
}
+// Canonical request parsing and client-classification tests.
#[cfg(test)]
-mod tests {
- use super::*;
- use ipnetwork::IpNetwork;
-
- use crate::config::{WebCarrier, WebClientIpSource};
-
- #[test]
- fn canonical_bridge_query_rejects_aliases() {
- let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 32]);
- assert!(bridge_candidate(Some(&format!("bridge={token}"))).1);
- assert!(!bridge_candidate(Some(&format!("x=1&bridge={token}"))).1);
- assert!(!bridge_candidate(Some(&format!("bridge={token}="))).1);
- }
-
- #[test]
- fn host_is_canonical_and_forwarded_identity_is_single_parseable_ip() {
- let request = Request::builder()
- .header(header::HOST, "proxy.example.com:443")
- .header("x-forwarded-for", "192.0.2.10")
- .body(())
- .unwrap();
- assert_eq!(canonical_request_host(&request), Some("proxy.example.com"));
- let trusted: [IpNetwork; 1] = ["127.0.0.1/32".parse().unwrap()];
- assert_eq!(
- client_ip(
- &request,
- "127.0.0.1:40000".parse().unwrap(),
- WebClientIpSource::XForwardedFor,
- &trusted,
- ),
- Some("192.0.2.10".parse().unwrap())
- );
-
- let expanded_ipv6 = Request::builder()
- .header("x-forwarded-for", "2001:0db8:0:0:0:0:0:10")
- .body(())
- .unwrap();
- assert_eq!(
- client_ip(
- &expanded_ipv6,
- "127.0.0.1:40000".parse().unwrap(),
- WebClientIpSource::XForwardedFor,
- &trusted,
- ),
- Some("2001:db8::10".parse().unwrap())
- );
-
- let without_forwarded_address = Request::builder().body(()).unwrap();
- assert_eq!(
- client_ip(
- &without_forwarded_address,
- "127.0.0.1:40000".parse().unwrap(),
- WebClientIpSource::XForwardedFor,
- &trusted,
- ),
- Some("127.0.0.1".parse().unwrap())
- );
-
- let empty_forwarded_address = Request::builder()
- .header("x-forwarded-for", "")
- .body(())
- .unwrap();
- assert_eq!(
- client_ip(
- &empty_forwarded_address,
- "127.0.0.1:40000".parse().unwrap(),
- WebClientIpSource::XForwardedFor,
- &trusted,
- ),
- Some("127.0.0.1".parse().unwrap())
- );
-
- let uppercase = Request::builder()
- .header(header::HOST, "Proxy.Example.com")
- .body(())
- .unwrap();
- assert!(canonical_request_host(&uppercase).is_none());
- let appended = Request::builder()
- .header("x-forwarded-for", "192.0.2.10, 198.51.100.4")
- .body(())
- .unwrap();
- assert!(
- client_ip(
- &appended,
- "127.0.0.1:40000".parse().unwrap(),
- WebClientIpSource::XForwardedFor,
- &trusted,
- )
- .is_none()
- );
- }
-
- #[test]
- fn bearer_and_sequence_headers_reject_noncanonical_aliases() {
- let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 32]);
- let request = Request::builder()
- .header(header::AUTHORIZATION, format!("Bearer {token}"))
- .header("x-up-seq", "17")
- .body(())
- .unwrap();
- assert_eq!(
- bearer_token_hash(&request),
- Some(Sha256::digest([1u8; 32]).into())
- );
- assert_eq!(canonical_u64_header(&request, "x-up-seq"), Some(17));
-
- let leading_zero = Request::builder()
- .header("x-up-seq", "017")
- .body(())
- .unwrap();
- assert!(canonical_u64_header(&leading_zero, "x-up-seq").is_none());
- }
-
- #[test]
- fn cookie_header_accepts_only_absent_or_one_empty_value() {
- let absent = Request::new(());
- assert!(compatible_cookie_header(&absent));
-
- let empty = Request::builder()
- .header(header::COOKIE, "")
- .body(())
- .unwrap();
- assert!(compatible_cookie_header(&empty));
-
- let nonempty = Request::builder()
- .header(header::COOKIE, "state=unexpected")
- .body(())
- .unwrap();
- assert!(!compatible_cookie_header(&nonempty));
-
- let whitespace = Request::builder()
- .header(header::COOKIE, " ")
- .body(())
- .unwrap();
- assert!(!compatible_cookie_header(&whitespace));
-
- let mut duplicate_empty = Request::new(());
- duplicate_empty
- .headers_mut()
- .append(header::COOKIE, "".parse().unwrap());
- duplicate_empty
- .headers_mut()
- .append(header::COOKIE, "".parse().unwrap());
- assert!(!compatible_cookie_header(&duplicate_empty));
-
- let mut duplicate_mixed = Request::new(());
- duplicate_mixed
- .headers_mut()
- .append(header::COOKIE, "".parse().unwrap());
- duplicate_mixed
- .headers_mut()
- .append(header::COOKIE, "state=unexpected".parse().unwrap());
- assert!(!compatible_cookie_header(&duplicate_mixed));
- }
-
- #[test]
- fn carrier_metadata_is_canonical_and_legacy_safe() {
- let automatic = Request::builder()
- .header(
- "x-carrier-capabilities",
- "https,https-lanes,websocket,websocket-lanes",
- )
- .header("x-carrier-attempt", "2")
- .header("x-carrier-failure", "timeout")
- .header(header::USER_AGENT, "Example Browser")
- .body(())
- .unwrap();
- let parsed = carrier_request(&automatic, "proxy.example.com").unwrap();
- assert!(parsed.is_automatic());
- assert_eq!(parsed.attempt(), Some(2));
- assert_eq!(parsed.failure(), Some(CarrierFailure::Timeout));
-
- let missing_failure = Request::builder()
- .header(
- "x-carrier-capabilities",
- "https,https-lanes,websocket,websocket-lanes",
- )
- .header("x-carrier-attempt", "2")
- .body(())
- .unwrap();
- assert!(carrier_request(&missing_failure, "proxy.example.com").is_none());
-
- let legacy = Request::builder()
- .header(header::USER_AGENT, "Native")
- .body(())
- .unwrap();
- assert!(
- !carrier_request(&legacy, "proxy.example.com")
- .unwrap()
- .is_automatic()
- );
-
- let reordered = Request::builder()
- .header("x-carrier-capabilities", "websocket,https")
- .header("x-carrier-attempt", "1")
- .body(())
- .unwrap();
- assert!(carrier_request(&reordered, "proxy.example.com").is_none());
- }
-
- #[test]
- fn native_ios_user_agent_classifies_without_overriding_capabilities() {
- let metadata_free = Request::builder()
- .header(
- header::USER_AGENT,
- "Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
- )
- .body(())
- .unwrap();
- let parsed = carrier_request(&metadata_free, "proxy.example.com").unwrap();
- assert_eq!(parsed.class(), CarrierClientClass::Ios);
- assert!(!parsed.is_automatic());
- assert!(!parsed.uses_capabilities());
-
- let automatic = Request::builder()
- .header("x-carrier-capabilities", "https,https-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(&automatic, "proxy.example.com").unwrap();
- assert_eq!(parsed.class(), CarrierClientClass::Ios);
- assert!(parsed.is_automatic());
- assert!(parsed.supports(WebCarrier::Https));
- assert!(parsed.supports(WebCarrier::HttpsLanes));
- assert!(!parsed.supports(WebCarrier::Websocket));
- assert!(!parsed.supports(WebCarrier::WebsocketLanes));
- }
-
- #[test]
- fn mapped_private_addresses_are_not_learning_evidence() {
- for address in ["::ffff:127.0.0.1", "::ffff:10.0.0.1"] {
- let effective_ip = address.parse().unwrap();
- let request = Request::builder()
- .header("x-forwarded-for", address)
- .body(())
- .unwrap();
- assert!(!carrier_ip_learning_eligible(&request, effective_ip));
- }
- let effective_ip = "::ffff:8.8.8.8".parse().unwrap();
- let request = Request::builder()
- .header("x-forwarded-for", "::ffff:8.8.8.8")
- .body(())
- .unwrap();
- assert!(carrier_ip_learning_eligible(&request, effective_ip));
- }
-
- #[test]
- fn strict_browser_metadata_recovers_a_stripped_capability_marker() {
- let request = Request::builder()
- .header("x-carrier-attempt", "1")
- .header(header::ORIGIN, "https://proxy.example.com")
- .header("sec-fetch-site", "same-origin")
- .header("sec-fetch-mode", "cors")
- .header("sec-fetch-dest", "empty")
- .body(())
- .unwrap();
- let parsed = carrier_request(&request, "proxy.example.com").unwrap();
- assert_eq!(parsed.class(), CarrierClientClass::BrowserHint);
- }
-
- #[test]
- fn user_agent_learning_key_is_case_and_whitespace_normalized() {
- let first = Request::builder()
- .header(header::USER_AGENT, " Example\t Browser ")
- .body(())
- .unwrap();
- let second = Request::builder()
- .header(header::USER_AGENT, "example browser")
- .body(())
- .unwrap();
- assert_eq!(
- normalized_user_agent_hash(&first),
- normalized_user_agent_hash(&second)
- );
- }
-}
+#[path = "request/tests.rs"]
+mod tests;
diff --git a/src/web/http/request/tests.rs b/src/web/http/request/tests.rs
new file mode 100644
index 0000000..f1b536e
--- /dev/null
+++ b/src/web/http/request/tests.rs
@@ -0,0 +1,303 @@
+use super::*;
+use ipnetwork::IpNetwork;
+
+use crate::config::{WebCarrier, WebClientIpSource};
+
+#[test]
+fn canonical_bridge_query_rejects_aliases() {
+ let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 32]);
+ assert!(bridge_candidate(Some(&format!("bridge={token}"))).1);
+ assert!(!bridge_candidate(Some(&format!("x=1&bridge={token}"))).1);
+ assert!(!bridge_candidate(Some(&format!("bridge={token}="))).1);
+}
+
+#[test]
+fn host_is_canonical_and_forwarded_identity_is_single_parseable_ip() {
+ let request = Request::builder()
+ .header(header::HOST, "proxy.example.com:443")
+ .header("x-forwarded-for", "192.0.2.10")
+ .body(())
+ .unwrap();
+ assert_eq!(canonical_request_host(&request), Some("proxy.example.com"));
+ let trusted: [IpNetwork; 1] = ["127.0.0.1/32".parse().unwrap()];
+ assert_eq!(
+ client_ip(
+ &request,
+ "127.0.0.1:40000".parse().unwrap(),
+ WebClientIpSource::XForwardedFor,
+ &trusted,
+ ),
+ Some("192.0.2.10".parse().unwrap())
+ );
+
+ let expanded_ipv6 = Request::builder()
+ .header("x-forwarded-for", "2001:0db8:0:0:0:0:0:10")
+ .body(())
+ .unwrap();
+ assert_eq!(
+ client_ip(
+ &expanded_ipv6,
+ "127.0.0.1:40000".parse().unwrap(),
+ WebClientIpSource::XForwardedFor,
+ &trusted,
+ ),
+ Some("2001:db8::10".parse().unwrap())
+ );
+
+ let without_forwarded_address = Request::builder().body(()).unwrap();
+ assert_eq!(
+ client_ip(
+ &without_forwarded_address,
+ "127.0.0.1:40000".parse().unwrap(),
+ WebClientIpSource::XForwardedFor,
+ &trusted,
+ ),
+ Some("127.0.0.1".parse().unwrap())
+ );
+
+ let empty_forwarded_address = Request::builder()
+ .header("x-forwarded-for", "")
+ .body(())
+ .unwrap();
+ assert_eq!(
+ client_ip(
+ &empty_forwarded_address,
+ "127.0.0.1:40000".parse().unwrap(),
+ WebClientIpSource::XForwardedFor,
+ &trusted,
+ ),
+ Some("127.0.0.1".parse().unwrap())
+ );
+
+ let uppercase = Request::builder()
+ .header(header::HOST, "Proxy.Example.com")
+ .body(())
+ .unwrap();
+ assert!(canonical_request_host(&uppercase).is_none());
+ let appended = Request::builder()
+ .header("x-forwarded-for", "192.0.2.10, 198.51.100.4")
+ .body(())
+ .unwrap();
+ assert!(
+ client_ip(
+ &appended,
+ "127.0.0.1:40000".parse().unwrap(),
+ WebClientIpSource::XForwardedFor,
+ &trusted,
+ )
+ .is_none()
+ );
+}
+
+#[test]
+fn bearer_and_sequence_headers_reject_noncanonical_aliases() {
+ let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 32]);
+ let request = Request::builder()
+ .header(header::AUTHORIZATION, format!("Bearer {token}"))
+ .header("x-up-seq", "17")
+ .body(())
+ .unwrap();
+ assert_eq!(
+ bearer_token_hash(&request),
+ Some(Sha256::digest([1u8; 32]).into())
+ );
+ assert_eq!(canonical_u64_header(&request, "x-up-seq"), Some(17));
+
+ let leading_zero = Request::builder()
+ .header("x-up-seq", "017")
+ .body(())
+ .unwrap();
+ assert!(canonical_u64_header(&leading_zero, "x-up-seq").is_none());
+}
+
+#[test]
+fn cookie_header_accepts_only_absent_or_one_empty_value() {
+ let absent = Request::new(());
+ assert!(compatible_cookie_header(&absent));
+
+ let empty = Request::builder()
+ .header(header::COOKIE, "")
+ .body(())
+ .unwrap();
+ assert!(compatible_cookie_header(&empty));
+
+ let nonempty = Request::builder()
+ .header(header::COOKIE, "state=unexpected")
+ .body(())
+ .unwrap();
+ assert!(!compatible_cookie_header(&nonempty));
+
+ let whitespace = Request::builder()
+ .header(header::COOKIE, " ")
+ .body(())
+ .unwrap();
+ assert!(!compatible_cookie_header(&whitespace));
+
+ let mut duplicate_empty = Request::new(());
+ duplicate_empty
+ .headers_mut()
+ .append(header::COOKIE, "".parse().unwrap());
+ duplicate_empty
+ .headers_mut()
+ .append(header::COOKIE, "".parse().unwrap());
+ assert!(!compatible_cookie_header(&duplicate_empty));
+
+ let mut duplicate_mixed = Request::new(());
+ duplicate_mixed
+ .headers_mut()
+ .append(header::COOKIE, "".parse().unwrap());
+ duplicate_mixed
+ .headers_mut()
+ .append(header::COOKIE, "state=unexpected".parse().unwrap());
+ assert!(!compatible_cookie_header(&duplicate_mixed));
+}
+
+#[test]
+fn carrier_metadata_is_canonical_and_legacy_safe() {
+ let automatic = Request::builder()
+ .header(
+ "x-carrier-capabilities",
+ "https,https-lanes,websocket,websocket-lanes",
+ )
+ .header("x-carrier-attempt", "2")
+ .header("x-carrier-failure", "timeout")
+ .header(header::USER_AGENT, "Example Browser")
+ .body(())
+ .unwrap();
+ let parsed = carrier_request(&automatic, "proxy.example.com").unwrap();
+ assert!(parsed.is_automatic());
+ assert_eq!(parsed.attempt(), Some(2));
+ assert_eq!(parsed.failure(), Some(CarrierFailure::Timeout));
+
+ let missing_failure = Request::builder()
+ .header(
+ "x-carrier-capabilities",
+ "https,https-lanes,websocket,websocket-lanes",
+ )
+ .header("x-carrier-attempt", "2")
+ .body(())
+ .unwrap();
+ assert!(carrier_request(&missing_failure, "proxy.example.com").is_none());
+
+ let legacy = Request::builder()
+ .header(header::USER_AGENT, "Native")
+ .body(())
+ .unwrap();
+ assert!(
+ !carrier_request(&legacy, "proxy.example.com")
+ .unwrap()
+ .is_automatic()
+ );
+
+ let reordered = Request::builder()
+ .header("x-carrier-capabilities", "websocket,https")
+ .header("x-carrier-attempt", "1")
+ .body(())
+ .unwrap();
+ assert!(carrier_request(&reordered, "proxy.example.com").is_none());
+}
+
+#[test]
+fn native_ios_user_agent_enforces_the_https_capability_ceiling() {
+ let metadata_free = Request::builder()
+ .header(
+ header::USER_AGENT,
+ "Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
+ )
+ .body(())
+ .unwrap();
+ let parsed = carrier_request(&metadata_free, "proxy.example.com").unwrap();
+ assert_eq!(parsed.class(), CarrierClientClass::Ios);
+ assert!(!parsed.is_automatic());
+ assert!(!parsed.uses_capabilities());
+
+ let automatic = Request::builder()
+ .header("x-carrier-capabilities", "https,https-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(&automatic, "proxy.example.com").unwrap();
+ assert_eq!(parsed.class(), CarrierClientClass::Ios);
+ assert!(parsed.is_automatic());
+ assert!(parsed.supports(WebCarrier::Https));
+ assert!(!parsed.supports(WebCarrier::HttpsLanes));
+ assert!(!parsed.supports(WebCarrier::Websocket));
+ assert!(!parsed.supports(WebCarrier::WebsocketLanes));
+
+ let incompatible = Request::builder()
+ .header("x-carrier-capabilities", "websocket")
+ .header("x-carrier-attempt", "1")
+ .header(
+ header::USER_AGENT,
+ "Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
+ )
+ .body(())
+ .unwrap();
+ assert!(carrier_request(&incompatible, "proxy.example.com").is_none());
+
+ let stripped = Request::builder()
+ .header("x-carrier-attempt", "1")
+ .header(header::ORIGIN, "https://proxy.example.com")
+ .header("sec-fetch-site", "same-origin")
+ .header("sec-fetch-mode", "cors")
+ .header("sec-fetch-dest", "empty")
+ .header(
+ header::USER_AGENT,
+ "Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
+ )
+ .body(())
+ .unwrap();
+ assert!(carrier_request(&stripped, "proxy.example.com").is_none());
+}
+
+#[test]
+fn mapped_private_addresses_are_not_learning_evidence() {
+ for address in ["::ffff:127.0.0.1", "::ffff:10.0.0.1"] {
+ let effective_ip = address.parse().unwrap();
+ let request = Request::builder()
+ .header("x-forwarded-for", address)
+ .body(())
+ .unwrap();
+ assert!(!carrier_ip_learning_eligible(&request, effective_ip));
+ }
+ let effective_ip = "::ffff:8.8.8.8".parse().unwrap();
+ let request = Request::builder()
+ .header("x-forwarded-for", "::ffff:8.8.8.8")
+ .body(())
+ .unwrap();
+ assert!(carrier_ip_learning_eligible(&request, effective_ip));
+}
+
+#[test]
+fn strict_browser_metadata_recovers_a_stripped_capability_marker() {
+ let request = Request::builder()
+ .header("x-carrier-attempt", "1")
+ .header(header::ORIGIN, "https://proxy.example.com")
+ .header("sec-fetch-site", "same-origin")
+ .header("sec-fetch-mode", "cors")
+ .header("sec-fetch-dest", "empty")
+ .body(())
+ .unwrap();
+ let parsed = carrier_request(&request, "proxy.example.com").unwrap();
+ assert_eq!(parsed.class(), CarrierClientClass::BrowserHint);
+}
+
+#[test]
+fn user_agent_learning_key_is_case_and_whitespace_normalized() {
+ let first = Request::builder()
+ .header(header::USER_AGENT, " Example\t Browser ")
+ .body(())
+ .unwrap();
+ let second = Request::builder()
+ .header(header::USER_AGENT, "example browser")
+ .body(())
+ .unwrap();
+ assert_eq!(
+ normalized_user_agent_hash(&first),
+ normalized_user_agent_hash(&second)
+ );
+}
diff --git a/src/web/http/session.rs b/src/web/http/session.rs
index cd3aa94..9712e1f 100644
--- a/src/web/http/session.rs
+++ b/src/web/http/session.rs
@@ -1,5 +1,6 @@
use std::net::IpAddr;
use std::sync::Arc;
+use std::time::Duration;
use hyper::header::{self, HeaderName, HeaderValue};
use hyper::{Method, Request, StatusCode};
@@ -33,17 +34,22 @@ pub(super) async fn handle_session(
if request.headers().contains_key(header::CONTENT_TYPE) {
return serve_decoy(request, vhost, true, &runtime).await;
}
+ let session = runtime.get_session(token_hash, &vhost.host).ok();
if let Some(trace) = request_trace(&request)
- && let Ok(session) = runtime.get_session(token_hash, &vhost.host)
+ && let Some(session) = &session
{
trace.set_route(TraceRoute::Session);
trace.bind_identity(session.trace_identity());
}
+ let body_timeout = session.as_ref().map_or_else(
+ || Duration::from_secs(runtime.active_generation().config().web.timeouts.body_secs),
+ |session| Duration::from_secs(session.timeouts().body_secs),
+ );
let CollectedBody {
request,
body,
_body_budget,
- } = match collect_body(request, &runtime, 1, true).await {
+ } = match collect_body(request, &runtime, body_timeout, 1, true).await {
Ok(result) => result,
Err(CollectBodyError::Limit) => return service_unavailable(),
Err(CollectBodyError::Invalid(request)) => {
@@ -62,7 +68,7 @@ pub(super) async fn handle_session(
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, frozen_body_timeout)) =
runtime.bootstrap_trace_identity(token_hash, &vhost.host)
else {
return serve_decoy(request, vhost, true, &runtime).await;
@@ -71,11 +77,14 @@ pub(super) async fn handle_session(
trace.set_route(TraceRoute::Session);
trace.bind_profile(&profile, trace_session_id);
}
+ let body_timeout = frozen_body_timeout.unwrap_or_else(|| {
+ Duration::from_secs(runtime.active_generation().config().web.timeouts.body_secs)
+ });
let CollectedBody {
request,
body,
_body_budget,
- } = match collect_body(request, &runtime, CREATE_BODY_LIMIT, false).await {
+ } = match collect_body(request, &runtime, body_timeout, CREATE_BODY_LIMIT, false).await {
Ok(result) => result,
Err(CollectBodyError::Limit) => return service_unavailable(),
Err(CollectBodyError::Invalid(request)) => {
diff --git a/src/web/http/session_policy_tests.rs b/src/web/http/session_policy_tests.rs
new file mode 100644
index 0000000..795ca8a
--- /dev/null
+++ b/src/web/http/session_policy_tests.rs
@@ -0,0 +1,102 @@
+use super::*;
+
+async fn request_with_body_delay(
+ listener: &TcpListener,
+ runtime: &Arc,
+ head: Vec,
+ body: &[u8],
+ delay: std::time::Duration,
+) -> Vec {
+ let addr = listener.local_addr().unwrap();
+ let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(addr));
+ let (server, peer) = accepted.unwrap();
+ let mut client = client.unwrap();
+ let permit = runtime.try_http_connection().unwrap();
+ let task = tokio::spawn(serve_connection(
+ server,
+ peer,
+ WebClientIpSource::XForwardedFor,
+ Arc::from(["127.0.0.1/32".parse().unwrap()]),
+ Arc::clone(runtime),
+ CancellationToken::new(),
+ permit,
+ ));
+ client.write_all(&head).await.unwrap();
+ tokio::time::sleep(delay).await;
+ let _ = client.write_all(body).await;
+ let mut response = Vec::new();
+ client.read_to_end(&mut response).await.unwrap();
+ task.await.unwrap();
+ response
+}
+
+#[tokio::test]
+async fn live_session_body_and_closed_token_timeouts_survive_reload() {
+ let capability = [21u8; 32];
+ let mut initial_config = runtime_config(capability, WebCarrier::Https);
+ initial_config.web.timeouts.body_secs = 3;
+ initial_config.web.timeouts.bootstrap_lifetime_secs = 5;
+ let generation = test_runtime_generation(1, initial_config);
+ let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
+ let runtime = WebProcessRuntime::start(Arc::clone(&active_runtime));
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
+ let root = format!(
+ "GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n"
+ )
+ .into_bytes();
+ let root_response = request(&listener, &runtime, root).await;
+ let (_, root_body) = split_response(&root_response);
+ let bootstrap = std::str::from_utf8(root_body)
+ .unwrap()
+ .split_once("bootstrap=\"")
+ .and_then(|(_, suffix)| suffix.split_once('"'))
+ .map(|(token, _)| token.to_string())
+ .unwrap();
+ let hello = frame::encode(FrameType::Hello, 0, &[1]);
+ let create_head = format!(
+ "POST /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+ hello.len()
+ )
+ .into_bytes();
+ let mut create = create_head.clone();
+ create.extend_from_slice(&hello);
+ let create_response = request(&listener, &runtime, create).await;
+ let (create_headers, _) = split_response(&create_response);
+ assert!(create_headers.starts_with(b"HTTP/1.1 200"));
+ let session = response_header(create_headers, "x-session-token").to_string();
+
+ let mut replacement_config = runtime_config(capability, WebCarrier::Https);
+ replacement_config.web.timeouts.body_secs = 1;
+ replacement_config.web.timeouts.bootstrap_lifetime_secs = 1;
+ let replacement = test_runtime_generation(2, replacement_config);
+ active_runtime.store(Arc::clone(&replacement));
+
+ let retry_response = request_with_body_delay(
+ &listener,
+ &runtime,
+ create_head,
+ &hello,
+ std::time::Duration::from_millis(1200),
+ )
+ .await;
+ let (retry_headers, _) = split_response(&retry_response);
+ assert!(retry_headers.starts_with(b"HTTP/1.1 200"));
+ assert_eq!(response_header(retry_headers, "x-session-token"), session);
+
+ let close = format!(
+ "DELETE /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
+ )
+ .into_bytes();
+ let close_response = request(&listener, &runtime, close.clone()).await;
+ assert!(close_response.starts_with(b"HTTP/1.1 204"));
+ tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
+ let close_retry_response = request(&listener, &runtime, close).await;
+ assert!(close_retry_response.starts_with(b"HTTP/1.1 204"));
+
+ runtime.shutdown().await;
+ generation.stop_sessions().await;
+ generation.stop_background_tasks().await;
+ replacement.stop_sessions().await;
+ replacement.stop_background_tasks().await;
+}
diff --git a/src/web/http/tests.rs b/src/web/http/tests.rs
index f1493e8..2be00ab 100644
--- a/src/web/http/tests.rs
+++ b/src/web/http/tests.rs
@@ -21,6 +21,9 @@ use crate::web::manager::WebProcessRuntime;
mod legacy_tests;
#[path = "negotiation_tests.rs"]
mod negotiation_tests;
+// Reload-stability tests for session-owned timeout policy.
+#[path = "session_policy_tests.rs"]
+mod session_policy_tests;
const TEST_CARRIER_DEADLINES_SECS: [u64; 4] = [3, 5, 8, 12];
diff --git a/src/web/manager/credentials.rs b/src/web/manager/credentials.rs
index e5c26fc..cdfdb4b 100644
--- a/src/web/manager/credentials.rs
+++ b/src/web/manager/credentials.rs
@@ -110,19 +110,28 @@ impl WebProcessRuntime {
})
}
- /// Resolves non-secret bootstrap trace identity without exposing its credential.
+ /// Resolves bootstrap trace identity and the frozen live-session body timeout.
pub(crate) fn bootstrap_trace_identity(
&self,
hash: TokenHash,
host: &str,
- ) -> Option<(u64, Arc)> {
+ ) -> Option<(u64, Arc, Option)> {
let now = Instant::now();
self.state
.lock()
.bootstraps
.get(&hash)
.filter(|entry| entry.profile.host == host && now <= entry.expires_at)
- .map(|entry| (entry.trace_session_id, Arc::clone(&entry.profile)))
+ .map(|entry| {
+ (
+ entry.trace_session_id,
+ Arc::clone(&entry.profile),
+ entry
+ .session
+ .as_ref()
+ .map(|session| Duration::from_secs(session.timeouts().body_secs)),
+ )
+ })
}
/// Resolves an authenticated session token.
diff --git a/src/web/manager/learning.rs b/src/web/manager/learning.rs
deleted file mode 100644
index 636c07f..0000000
--- a/src/web/manager/learning.rs
+++ /dev/null
@@ -1,346 +0,0 @@
-/// Telemt Carrier Selection and Failure Dampening - Copyright 2077
-/// anhand des Kundenverhaltens Rückschlüsse gegen DSGVO ziehen...?!
-use std::collections::HashMap;
-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;
-
-const PROFILE_WEIGHT: i16 = 4;
-const USER_AGENT_WEIGHT: i16 = 4;
-const IP_WEIGHT: i16 = 1;
-const SCORE_MIN: i8 = -8;
-const SCORE_MAX: i8 = 8;
-const PROFILE_MIN_OUTCOMES: u8 = 8;
-const PROFILE_MIN_COHORTS: usize = 4;
-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),
-}
-
-struct Evidence {
- created_at: Instant,
- lifetime: Duration,
- scores: [i8; 4],
- outcomes: u8,
- cohorts: [Option<[u8; 32]>; PROFILE_MIN_COHORTS],
-}
-
-impl Evidence {
- fn new(created_at: Instant, lifetime: Duration) -> Self {
- Self {
- created_at,
- lifetime,
- scores: [0; 4],
- outcomes: 0,
- cohorts: [None; PROFILE_MIN_COHORTS],
- }
- }
-
- fn update(&mut self, carrier: WebCarrier, delta: i8, cohort: Option<[u8; 32]>) {
- let score = &mut self.scores[carrier.index()];
- *score = score.saturating_add(delta).clamp(SCORE_MIN, SCORE_MAX);
- self.outcomes = self.outcomes.saturating_add(1).min(PROFILE_MIN_OUTCOMES);
- 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);
- }
- }
-}
-
-/// Process-local bounded fixed-window carrier evidence store.
-pub(super) struct CarrierLearning {
- entries: HashMap,
- capacity: usize,
-}
-
-impl CarrierLearning {
- /// Creates an empty store under the restart-owned capacity ceiling.
- pub(super) fn new(capacity: usize) -> Self {
- Self {
- entries: HashMap::with_capacity(capacity),
- capacity,
- }
- }
-
- /// Ranks supported configured candidates using only unexpired evidence.
- pub(super) fn rank(
- &mut self,
- now: Instant,
- configured: &[WebCarrier],
- request: super::CarrierRequest,
- profile_key: ProfileKey,
- client_ip: IpAddr,
- ) -> (Vec, [i16; 4]) {
- self.prune(now);
- let mut scores = [0i16; 4];
- let profile = self.entries.get(&EvidenceKey::Profile(profile_key));
- let profile_ready = profile.is_some_and(|entry| {
- entry.outcomes >= PROFILE_MIN_OUTCOMES
- && entry.cohorts.iter().flatten().count() >= PROFILE_MIN_COHORTS
- });
- let user_agent = self.entries.get(&EvidenceKey::UserAgent(
- profile_key,
- request.class(),
- request.user_agent_hash(),
- ));
- let ip = self.entries.get(&EvidenceKey::Ip(profile_key, client_ip));
- for carrier in WebCarrier::ALL {
- let index = carrier.index();
- if profile_ready {
- scores[index] += i16::from(profile.map_or(0, |entry| entry.scores[index]))
- * PROFILE_WEIGHT;
- }
- scores[index] += i16::from(user_agent.map_or(0, |entry| entry.scores[index]))
- * USER_AGENT_WEIGHT;
- scores[index] +=
- i16::from(ip.map_or(0, |entry| entry.scores[index])) * IP_WEIGHT;
- }
- let mut ranked = configured
- .iter()
- .copied()
- .filter(|carrier| request.supports(*carrier))
- .collect::>();
- ranked.sort_by_key(|carrier| std::cmp::Reverse(scores[carrier.index()]));
- (ranked, scores)
- }
-
- /// Records one committed success or one server-accepted supersession failure.
- pub(super) fn record(
- &mut self,
- now: Instant,
- lifetime: Duration,
- context: CarrierLearningContext,
- carrier: WebCarrier,
- success: bool,
- ) {
- self.prune(now);
- let delta = if success { 1 } else { -1 };
- let cohort = cohort_hash(context);
- self.update(
- EvidenceKey::Profile(context.profile_key),
- now,
- lifetime,
- carrier,
- delta,
- Some(cohort),
- );
- self.update(
- EvidenceKey::UserAgent(
- context.profile_key,
- context.class,
- context.user_agent_hash,
- ),
- now,
- lifetime,
- carrier,
- delta,
- None,
- );
- self.update(
- EvidenceKey::Ip(context.profile_key, context.client_ip),
- now,
- lifetime,
- carrier,
- delta,
- None,
- );
- }
-
- /// Removes fixed-window entries after their creation-time expiry.
- pub(super) fn prune(&mut self, now: Instant) {
- self.entries.retain(|_, entry| {
- now.saturating_duration_since(entry.created_at) <= entry.lifetime
- });
- }
-
- fn update(
- &mut self,
- key: EvidenceKey,
- now: Instant,
- lifetime: Duration,
- carrier: WebCarrier,
- delta: i8,
- cohort: Option<[u8; 32]>,
- ) {
- if !self.entries.contains_key(&key) && self.entries.len() >= self.capacity {
- let oldest = self
- .entries
- .iter()
- .min_by_key(|(_, entry)| entry.created_at)
- .map(|(key, _)| *key);
- if let Some(oldest) = oldest {
- self.entries.remove(&oldest);
- }
- }
- self.entries
- .entry(key)
- .or_insert_with(|| Evidence::new(now, lifetime))
- .update(carrier, delta, cohort);
- }
-}
-
-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,
- }]);
- 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)]
-mod tests {
- use super::*;
- use crate::web::manager::{CarrierCapabilities, CarrierRequest};
-
- fn request(hash: u8) -> CarrierRequest {
- CarrierRequest::automatic(
- CarrierClientClass::Bridge,
- CarrierCapabilities::all(),
- 1,
- None,
- [hash; 32],
- )
- }
-
- #[test]
- fn evidence_is_bounded_and_expires_without_sliding() {
- let start = Instant::now();
- let mut learning = CarrierLearning::new(3);
- let context = CarrierLearningContext {
- profile_key: [1; 32],
- client_ip: "192.0.2.1".parse().unwrap(),
- class: CarrierClientClass::Bridge,
- user_agent_hash: [2; 32],
- };
- learning.record(
- start,
- Duration::from_secs(10),
- context,
- WebCarrier::Websocket,
- true,
- );
- assert_eq!(learning.entries.len(), 3);
- learning.record(
- start + Duration::from_secs(5),
- Duration::from_secs(10),
- context,
- WebCarrier::Websocket,
- true,
- );
- learning.prune(start + Duration::from_secs(11));
- assert!(learning.entries.is_empty());
- }
-
- #[test]
- fn user_agent_and_ip_evidence_rank_stably() {
- let now = Instant::now();
- let mut learning = CarrierLearning::new(16);
- let context = CarrierLearningContext {
- profile_key: [1; 32],
- client_ip: "192.0.2.1".parse().unwrap(),
- class: CarrierClientClass::Bridge,
- user_agent_hash: [2; 32],
- };
- learning.record(
- now,
- Duration::from_secs(10),
- context,
- WebCarrier::Websocket,
- true,
- );
- let (ranked, scores) = learning.rank(
- now,
- &[WebCarrier::Https, WebCarrier::Websocket],
- request(2),
- context.profile_key,
- context.client_ip,
- );
- assert_eq!(ranked, [WebCarrier::Websocket, WebCarrier::Https]);
- assert_eq!(scores[WebCarrier::Websocket.index()], 5);
- }
-
- #[test]
- fn profile_evidence_requires_outcome_and_cohort_thresholds() {
- let now = Instant::now();
- let profile_key = [1; 32];
- let configured = [WebCarrier::Https, WebCarrier::Websocket];
- let unrelated_ip = "198.51.100.10".parse().unwrap();
- let mut learning = CarrierLearning::new(64);
- for cohort in 1..=3u8 {
- let context = CarrierLearningContext {
- profile_key,
- client_ip: IpAddr::V4(std::net::Ipv4Addr::new(192, 0, 2, cohort)),
- class: CarrierClientClass::Bridge,
- user_agent_hash: [cohort; 32],
- };
- for _ in 0..2 {
- learning.record(
- now,
- Duration::from_secs(10),
- context,
- WebCarrier::Websocket,
- true,
- );
- }
- }
- let (ranked, _) = learning.rank(
- now,
- &configured,
- request(99),
- profile_key,
- unrelated_ip,
- );
- assert_eq!(ranked, configured);
-
- let fourth = CarrierLearningContext {
- profile_key,
- client_ip: "192.0.2.4".parse().unwrap(),
- class: CarrierClientClass::Bridge,
- user_agent_hash: [4; 32],
- };
- for _ in 0..2 {
- learning.record(
- now,
- Duration::from_secs(10),
- fourth,
- WebCarrier::Websocket,
- true,
- );
- }
- let (ranked, scores) = learning.rank(
- now,
- &configured,
- request(99),
- profile_key,
- unrelated_ip,
- );
- assert_eq!(ranked, [WebCarrier::Websocket, WebCarrier::Https]);
- assert_eq!(scores[WebCarrier::Websocket.index()], 32);
- }
-}
diff --git a/src/web/manager/lifecycle.rs b/src/web/manager/lifecycle.rs
index 259f87c..3832d78 100644
--- a/src/web/manager/lifecycle.rs
+++ b/src/web/manager/lifecycle.rs
@@ -17,6 +17,7 @@ impl WebProcessRuntime {
client_ip: IpAddr,
profile_key: ProfileKey,
profile_host: &str,
+ closed_token_lifetime: Duration,
) {
let mut state = self.state.lock();
if state.sessions.remove(&hash).is_none() {
@@ -28,14 +29,7 @@ impl WebProcessRuntime {
&mut state,
hash,
profile_host,
- Duration::from_secs(
- self.active_runtime
- .load()
- .config()
- .web
- .timeouts
- .bootstrap_lifetime_secs,
- ),
+ closed_token_lifetime,
self.limits.max_sessions_global.saturating_mul(16),
);
let bootstrap_hashes = state
diff --git a/src/web/manager/negotiation.rs b/src/web/manager/negotiation.rs
index 13b3321..27252bd 100644
--- a/src/web/manager/negotiation.rs
+++ b/src/web/manager/negotiation.rs
@@ -77,6 +77,11 @@ impl CarrierCapabilities {
Self(0b1111)
}
+ /// Returns the current server-authoritative native iOS capability ceiling.
+ pub(crate) const fn ios() -> Self {
+ Self(1 << WebCarrier::Https.index())
+ }
+
/// Builds a set from a validated bit representation.
pub(crate) const fn from_bits(bits: u8) -> Option {
if bits != 0 && bits & !0b1111 == 0 {
@@ -90,6 +95,11 @@ impl CarrierCapabilities {
pub(crate) const fn contains(self, carrier: WebCarrier) -> bool {
self.0 & (1 << carrier.index()) != 0
}
+
+ /// Intersects declared capabilities with an authoritative server ceiling.
+ pub(crate) const fn intersection(self, ceiling: Self) -> Option {
+ Self::from_bits(self.0 & ceiling.0)
+ }
}
/// Immutable metadata attached to one session-creation attempt.
diff --git a/src/web/manager/session_creation.rs b/src/web/manager/session_creation.rs
index 6207a2e..0c41b5f 100644
--- a/src/web/manager/session_creation.rs
+++ b/src/web/manager/session_creation.rs
@@ -376,178 +376,7 @@ impl WebProcessRuntime {
);
Ok(result)
}
-
- fn replace_session(
- self: &Arc,
- bootstrap_hash: TokenHash,
- client_ip: IpAddr,
- replacement: Replacement,
- ) -> std::result::Result {
- if !replacement.old_session.begin_carrier_supersede() {
- let committed = replacement.old_session.is_carrier_committed();
- self.cancel_replacement(bootstrap_hash, &replacement.old_session);
- return Err(if committed {
- ManagerError::Committed
- } else {
- ManagerError::Closed
- });
- }
- let generation = self.active_generation();
- let config = generation.config();
- let now = Instant::now();
- let mut state = self.state.lock();
- remove_expired_locked(&mut state, now);
- let valid = state.bootstraps.get(&bootstrap_hash).is_some_and(|entry| {
- entry.carrier_transitioning
- && entry.carrier_phase == CarrierChainPhase::Provisional
- && !entry.close_requested
- && entry.carrier_attempt.saturating_add(1) == replacement.attempt
- && now < replacement.carrier_deadline_at
- && entry
- .session
- .as_ref()
- .is_some_and(|session| Arc::ptr_eq(session, &replacement.old_session))
- }) && state
- .sessions
- .get(&replacement.old_session.token_hash())
- .is_some_and(|session| Arc::ptr_eq(session, &replacement.old_session));
- if !valid
- || state.closed
- || !config.web.enabled
- || !generation
- .proxy_shared
- .is_user_enabled(&replacement.profile.user)
- {
- drop(state);
- self.cancel_replacement(bootstrap_hash, &replacement.old_session);
- return Err(ManagerError::Closed);
- }
- let Some((session_token, session_hash)) = new_unique_token(&generation, &state) else {
- self.limit_hits.fetch_add(1, Ordering::Relaxed);
- drop(state);
- self.cancel_replacement(bootstrap_hash, &replacement.old_session);
- return Err(ManagerError::Limit);
- };
- let learning_context = (replacement.profile.carrier_learning
- && replacement.learning_epoch != 0)
- .then_some(CarrierLearningContext {
- profile_key: replacement.profile_key,
- client_ip,
- class: replacement.request.class(),
- user_agent_hash: replacement.request.user_agent_hash(),
- epoch: replacement.learning_epoch,
- ip_learning_eligible: replacement.ip_learning_eligible,
- });
- let session = WebSession::new(
- Arc::downgrade(self),
- session_hash,
- client_ip,
- replacement.trace_session_id,
- Arc::clone(&replacement.profile),
- replacement.profile_key,
- replacement.carrier,
- replacement.attempt,
- bootstrap_hash,
- Some(replacement.carrier_deadline_at),
- replacement.request.class(),
- learning_context,
- true,
- self.limits.clone(),
- replacement.old_session.timeouts().clone(),
- );
- let Some(supersede) = replacement.old_session.prepare_carrier_supersede() else {
- drop(state);
- self.cancel_replacement(bootstrap_hash, &replacement.old_session);
- session.close();
- return Err(ManagerError::Closed);
- };
- let old_hash = replacement.old_session.token_hash();
- state.sessions.remove(&old_hash);
- remember_closed_token_locked(
- &mut state,
- old_hash,
- &replacement.profile.host,
- Duration::from_secs(config.web.timeouts.bootstrap_lifetime_secs),
- self.limits.max_sessions_global.saturating_mul(16),
- );
- state.sessions.insert(session_hash, Arc::clone(&session));
- let entry = state
- .bootstraps
- .get_mut(&bootstrap_hash)
- .ok_or(ManagerError::Authentication)?;
- entry.session_token = Zeroizing::new(session_token.clone());
- entry.session = Some(Arc::clone(&session));
- entry.carrier_request = Some(replacement.request);
- entry.carrier_attempt = replacement.attempt;
- entry.carrier_transitioning = false;
- entry.carrier_phase = CarrierChainPhase::Provisional;
- if let Some(slot) = entry
- .carrier_failures
- .get_mut(usize::from(replacement.attempt.saturating_sub(2)))
- {
- *slot = Some(replacement.old_session.carrier());
- }
- self.sessions_created.fetch_add(1, Ordering::Relaxed);
- self.sessions_closed.fetch_add(1, Ordering::Relaxed);
- let result = CreateResult {
- token: session_token,
- carrier: replacement.carrier,
- attempt: Some(replacement.attempt),
- candidate_count: Some(u8::try_from(entry.carrier_candidates.len()).unwrap_or(4)),
- deadline_secs: Some(entry.profile.carrier_negotiation_deadlines_secs[3]),
- carrier_state: Some(CarrierChainPhase::Provisional.as_str()),
- };
- let identity = session.trace_identity();
- let old_identity = replacement.old_session.trace_identity();
- drop(state);
- supersede.finish();
- self.trace.record_carrier_lifecycle(
- client_ip,
- old_identity.clone(),
- TraceLifecycleEvent::CarrierFailed,
- replacement.request.class().as_str(),
- replacement.old_session.carrier(),
- replacement.attempt - 1,
- replacement.scores,
- replacement
- .request
- .failure()
- .map(|failure| failure.as_str()),
- );
- self.trace.record_carrier_lifecycle(
- client_ip,
- old_identity,
- TraceLifecycleEvent::CarrierSuperseded,
- replacement.request.class().as_str(),
- replacement.old_session.carrier(),
- replacement.attempt - 1,
- replacement.scores,
- replacement
- .request
- .failure()
- .map(|failure| failure.as_str()),
- );
- self.trace.record_carrier_lifecycle(
- client_ip,
- identity.clone(),
- TraceLifecycleEvent::CarrierSelected,
- replacement.request.class().as_str(),
- replacement.carrier,
- replacement.attempt,
- replacement.scores,
- None,
- );
- self.trace.record_lifecycle(
- None,
- Some(client_ip),
- identity,
- TraceLifecycleEvent::SessionCreated,
- None,
- replacement
- .request
- .failure()
- .map(|failure| failure.as_str()),
- );
- Ok(result)
- }
}
+
+// Atomic pre-commit carrier replacement and frozen-policy transfer.
+mod replacement;
diff --git a/src/web/manager/session_creation/replacement.rs b/src/web/manager/session_creation/replacement.rs
new file mode 100644
index 0000000..b7483db
--- /dev/null
+++ b/src/web/manager/session_creation/replacement.rs
@@ -0,0 +1,177 @@
+use super::*;
+
+impl WebProcessRuntime {
+ pub(super) fn replace_session(
+ self: &Arc,
+ bootstrap_hash: TokenHash,
+ client_ip: IpAddr,
+ replacement: Replacement,
+ ) -> std::result::Result {
+ if !replacement.old_session.begin_carrier_supersede() {
+ let committed = replacement.old_session.is_carrier_committed();
+ self.cancel_replacement(bootstrap_hash, &replacement.old_session);
+ return Err(if committed {
+ ManagerError::Committed
+ } else {
+ ManagerError::Closed
+ });
+ }
+ let generation = self.active_generation();
+ let config = generation.config();
+ let now = Instant::now();
+ let mut state = self.state.lock();
+ remove_expired_locked(&mut state, now);
+ let valid = state.bootstraps.get(&bootstrap_hash).is_some_and(|entry| {
+ entry.carrier_transitioning
+ && entry.carrier_phase == CarrierChainPhase::Provisional
+ && !entry.close_requested
+ && entry.carrier_attempt.saturating_add(1) == replacement.attempt
+ && now < replacement.carrier_deadline_at
+ && entry
+ .session
+ .as_ref()
+ .is_some_and(|session| Arc::ptr_eq(session, &replacement.old_session))
+ }) && state
+ .sessions
+ .get(&replacement.old_session.token_hash())
+ .is_some_and(|session| Arc::ptr_eq(session, &replacement.old_session));
+ if !valid
+ || state.closed
+ || !config.web.enabled
+ || !generation
+ .proxy_shared
+ .is_user_enabled(&replacement.profile.user)
+ {
+ drop(state);
+ self.cancel_replacement(bootstrap_hash, &replacement.old_session);
+ return Err(ManagerError::Closed);
+ }
+ let Some((session_token, session_hash)) = new_unique_token(&generation, &state) else {
+ self.limit_hits.fetch_add(1, Ordering::Relaxed);
+ drop(state);
+ self.cancel_replacement(bootstrap_hash, &replacement.old_session);
+ return Err(ManagerError::Limit);
+ };
+ let learning_context = (replacement.profile.carrier_learning
+ && replacement.learning_epoch != 0)
+ .then_some(CarrierLearningContext {
+ profile_key: replacement.profile_key,
+ client_ip,
+ class: replacement.request.class(),
+ user_agent_hash: replacement.request.user_agent_hash(),
+ epoch: replacement.learning_epoch,
+ ip_learning_eligible: replacement.ip_learning_eligible,
+ });
+ let session = WebSession::new(
+ Arc::downgrade(self),
+ session_hash,
+ client_ip,
+ replacement.trace_session_id,
+ Arc::clone(&replacement.profile),
+ replacement.profile_key,
+ replacement.carrier,
+ replacement.attempt,
+ bootstrap_hash,
+ Some(replacement.carrier_deadline_at),
+ replacement.request.class(),
+ learning_context,
+ true,
+ self.limits.clone(),
+ replacement.old_session.timeouts().clone(),
+ );
+ let Some(supersede) = replacement.old_session.prepare_carrier_supersede() else {
+ drop(state);
+ self.cancel_replacement(bootstrap_hash, &replacement.old_session);
+ session.close();
+ return Err(ManagerError::Closed);
+ };
+ let old_hash = replacement.old_session.token_hash();
+ state.sessions.remove(&old_hash);
+ remember_closed_token_locked(
+ &mut state,
+ old_hash,
+ &replacement.profile.host,
+ Duration::from_secs(replacement.old_session.timeouts().bootstrap_lifetime_secs),
+ self.limits.max_sessions_global.saturating_mul(16),
+ );
+ state.sessions.insert(session_hash, Arc::clone(&session));
+ let entry = state
+ .bootstraps
+ .get_mut(&bootstrap_hash)
+ .ok_or(ManagerError::Authentication)?;
+ entry.session_token = Zeroizing::new(session_token.clone());
+ entry.session = Some(Arc::clone(&session));
+ entry.carrier_request = Some(replacement.request);
+ entry.carrier_attempt = replacement.attempt;
+ entry.carrier_transitioning = false;
+ entry.carrier_phase = CarrierChainPhase::Provisional;
+ if let Some(slot) = entry
+ .carrier_failures
+ .get_mut(usize::from(replacement.attempt.saturating_sub(2)))
+ {
+ *slot = Some(replacement.old_session.carrier());
+ }
+ self.sessions_created.fetch_add(1, Ordering::Relaxed);
+ self.sessions_closed.fetch_add(1, Ordering::Relaxed);
+ let result = CreateResult {
+ token: session_token,
+ carrier: replacement.carrier,
+ attempt: Some(replacement.attempt),
+ candidate_count: Some(u8::try_from(entry.carrier_candidates.len()).unwrap_or(4)),
+ deadline_secs: Some(entry.profile.carrier_negotiation_deadlines_secs[3]),
+ carrier_state: Some(CarrierChainPhase::Provisional.as_str()),
+ };
+ let identity = session.trace_identity();
+ let old_identity = replacement.old_session.trace_identity();
+ drop(state);
+ supersede.finish();
+ self.trace.record_carrier_lifecycle(
+ client_ip,
+ old_identity.clone(),
+ TraceLifecycleEvent::CarrierFailed,
+ replacement.request.class().as_str(),
+ replacement.old_session.carrier(),
+ replacement.attempt - 1,
+ replacement.scores,
+ replacement
+ .request
+ .failure()
+ .map(|failure| failure.as_str()),
+ );
+ self.trace.record_carrier_lifecycle(
+ client_ip,
+ old_identity,
+ TraceLifecycleEvent::CarrierSuperseded,
+ replacement.request.class().as_str(),
+ replacement.old_session.carrier(),
+ replacement.attempt - 1,
+ replacement.scores,
+ replacement
+ .request
+ .failure()
+ .map(|failure| failure.as_str()),
+ );
+ self.trace.record_carrier_lifecycle(
+ client_ip,
+ identity.clone(),
+ TraceLifecycleEvent::CarrierSelected,
+ replacement.request.class().as_str(),
+ replacement.carrier,
+ replacement.attempt,
+ replacement.scores,
+ None,
+ );
+ self.trace.record_lifecycle(
+ None,
+ Some(client_ip),
+ identity,
+ TraceLifecycleEvent::SessionCreated,
+ None,
+ replacement
+ .request
+ .failure()
+ .map(|failure| failure.as_str()),
+ );
+ Ok(result)
+ }
+}
diff --git a/src/web/session/backend.rs b/src/web/session/backend.rs
index c751b2e..bd3d20f 100644
--- a/src/web/session/backend.rs
+++ b/src/web/session/backend.rs
@@ -42,26 +42,13 @@ impl WebSession {
drop(completion);
return false;
}
- let Ok(connection_permit) = generation.max_connections.clone().try_acquire_owned() else {
- manager.record_stream_rejected();
- self.trace_lifecycle(
- crate::web::trace::TraceLifecycleEvent::StreamRejected,
- Some(stream.id),
- Some("connection_limit"),
- );
- completion
- .retain_rejected
- .store(retain_reservation_on_reject, Ordering::Release);
- drop(completion);
- return false;
- };
+ let connection_permits = Arc::clone(&generation.max_connections);
let deps = generation.client_runtime_deps();
let replay_checker = Arc::clone(&generation.replay_checker);
let session = Arc::clone(self);
let cancel = self.cancel.clone();
let retain_rejected = Arc::clone(&completion.retain_rejected);
let future = async move {
- let _connection_permit = connection_permit;
let _completion = completion;
session.trace_lifecycle(
crate::web::trace::TraceLifecycleEvent::StreamAdmitted,
@@ -77,6 +64,7 @@ impl WebSession {
logical_stream,
deps,
replay_checker,
+ connection_permits,
peer_port,
) => {}
}
@@ -219,6 +207,7 @@ async fn run_stream(
stream: WebLogicalStream,
deps: crate::proxy::authenticated::ClientRuntimeDeps,
replay_checker: Arc,
+ connection_permits: Arc,
peer_port: u16,
) {
use tokio::io::AsyncReadExt;
@@ -267,6 +256,15 @@ async fn run_stream(
let Some(manager) = session.manager.upgrade() else {
return;
};
+ let Ok(_connection_permit) = connection_permits.try_acquire_owned() else {
+ manager.record_stream_rejected();
+ session.trace_lifecycle(
+ crate::web::trace::TraceLifecycleEvent::StreamRejected,
+ Some(stream_identity.id),
+ Some("connection_limit_after_first_byte"),
+ );
+ return;
+ };
let Some(handshake_permit) = manager.try_stream_handshake() else {
session.trace_lifecycle(
crate::web::trace::TraceLifecycleEvent::StreamRejected,
diff --git a/src/web/session/backend_tests.rs b/src/web/session/backend_tests.rs
index 077ca85..6c9d5a3 100644
--- a/src/web/session/backend_tests.rs
+++ b/src/web/session/backend_tests.rs
@@ -206,6 +206,58 @@ async fn open_without_data_does_not_start_the_inner_handshake_timeout() {
}
}
+#[tokio::test(start_paused = true)]
+async fn silent_open_does_not_consume_generation_connection_capacity() {
+ let runtime = test_runtime(WebCarrier::Https, 1);
+ let available = runtime.generation.max_connections.available_permits();
+
+ assert_eq!(runtime.process_frame(1, 1, FrameType::Open, &[]), Ok(1));
+ settle_tasks().await;
+
+ assert_eq!(
+ runtime.generation.max_connections.available_permits(),
+ available
+ );
+
+ assert_eq!(runtime.process_frame(1, 2, FrameType::Data, &[0x5a]), Ok(2));
+ settle_tasks().await;
+ assert_eq!(
+ runtime.generation.max_connections.available_permits(),
+ available - 1
+ );
+
+ runtime.shutdown().await;
+}
+
+#[tokio::test(start_paused = true)]
+async fn first_inner_byte_fails_closed_when_connection_capacity_is_exhausted() {
+ let runtime = test_runtime(WebCarrier::Https, 1);
+ let capacity = runtime.generation.max_connections.available_permits();
+ let permit = runtime
+ .generation
+ .max_connections
+ .clone()
+ .try_acquire_many_owned(capacity as u32)
+ .unwrap();
+
+ assert_eq!(runtime.process_frame(1, 1, FrameType::Open, &[]), Ok(1));
+ settle_tasks().await;
+ assert!(runtime.session.state.lock().streams.contains_key(&1));
+
+ assert_eq!(runtime.process_frame(1, 2, FrameType::Data, &[0x5a]), Ok(2));
+ settle_tasks().await;
+
+ assert!(!runtime.session.state.lock().streams.contains_key(&1));
+ assert_eq!(runtime.generation.max_connections.available_permits(), 0);
+
+ drop(permit);
+ assert_eq!(
+ runtime.generation.max_connections.available_permits(),
+ capacity
+ );
+ runtime.shutdown().await;
+}
+
#[tokio::test(start_paused = true)]
async fn the_first_inner_byte_starts_the_handshake_timeout() {
let runtime = test_runtime(WebCarrier::Https, 1);
diff --git a/src/web/session/lifecycle.rs b/src/web/session/lifecycle.rs
index bde6f1a..29c6d8b 100644
--- a/src/web/session/lifecycle.rs
+++ b/src/web/session/lifecycle.rs
@@ -206,6 +206,7 @@ impl WebSession {
self.client_ip,
self.profile_key,
&self.profile.host,
+ Duration::from_secs(self.timeouts.bootstrap_lifetime_secs),
);
}
}
From f73f52a0332b8f1f0d8f32619e14f30c8b0a0bb0 Mon Sep 17 00:00:00 2001
From: Alexey <247128645+axkurcom@users.noreply.github.com>
Date: Thu, 27 Aug 2026 09:02:19 +0300
Subject: [PATCH 2/7] WEB: Lifecycle + Lane ownership + Diag fixes
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
---
src/maestro/listeners/accept.rs | 57 ++++-
src/maestro/listeners/control.rs | 69 +++++-
src/maestro/listeners/unix.rs | 33 ++-
src/web/http.rs | 28 ++-
src/web/http/activity.rs | 334 +++++++++++++++++++++++++-
src/web/http/body.rs | 9 +
src/web/http/decoy.rs | 26 ++
src/web/http/down.rs | 16 ++
src/web/http/session_policy_tests.rs | 138 +++++++++++
src/web/http/websocket.rs | 37 ++-
src/web/http/websocket/driver.rs | 27 ++-
src/web/http/websocket/driver/lane.rs | 13 +-
src/web/manager.rs | 15 +-
src/web/manager/budget.rs | 34 ++-
src/web/manager/lifecycle.rs | 308 +++++++++++++++++++++---
src/web/manager/websocket.rs | 71 ++----
src/web/manager/websocket/policy.rs | 113 +++++++++
src/web/manager/websocket/tests.rs | 304 ++++++++++++++++++++---
src/web/session.rs | 25 +-
src/web/session/downlink_tests.rs | 6 +-
src/web/session/lanes.rs | 46 +++-
src/web/session/lifecycle.rs | 5 +
src/web/session/uplink.rs | 2 +
src/web/session/websocket.rs | 291 +++++++++++++++++-----
src/web/session/websocket/tests.rs | 288 +++++++++++++++++++++-
25 files changed, 2050 insertions(+), 245 deletions(-)
create mode 100644 src/web/manager/websocket/policy.rs
diff --git a/src/maestro/listeners/accept.rs b/src/maestro/listeners/accept.rs
index 3ceae5d..75e768d 100644
--- a/src/maestro/listeners/accept.rs
+++ b/src/maestro/listeners/accept.rs
@@ -284,7 +284,7 @@ impl ListenerSlot {
}
pub(super) async fn stop(&mut self) -> Result<(), String> {
- self.cancellation.cancel();
+ self.request_stop();
if let Some(task) = self.task.take() {
task.await.map_err(|error_value| {
format!("listener {} task failed: {error_value}", self.spec.addr)
@@ -305,6 +305,61 @@ impl ListenerSlot {
Ok(())
}
+ /// Cancels admission synchronously before the shared shutdown deadline starts draining.
+ pub(super) fn request_stop(&self) {
+ self.cancellation.cancel();
+ }
+
+ /// Joins this acceptor and its WEB connections by one process shutdown deadline.
+ pub(super) async fn stop_until(
+ &mut self,
+ deadline: tokio::time::Instant,
+ ) -> Result<(), String> {
+ self.request_stop();
+ let mut errors = Vec::new();
+ if let Some(mut task) = self.task.take() {
+ let joined = if task.is_finished() {
+ Some(task.await)
+ } else {
+ match tokio::time::timeout_at(deadline, &mut task).await {
+ Ok(result) => Some(result),
+ Err(_) => {
+ task.abort();
+ let _ = task.await;
+ None
+ }
+ }
+ };
+ match joined {
+ Some(Ok(())) => {}
+ Some(Err(error_value)) => errors.push(format!(
+ "listener {} task failed: {error_value}",
+ self.spec.addr
+ )),
+ None => errors.push(format!(
+ "listener {} accept shutdown timed out",
+ self.spec.addr
+ )),
+ }
+ }
+ self.connections.close();
+ if !self.connections.is_empty()
+ && tokio::time::timeout_at(deadline, self.connections.wait())
+ .await
+ .is_err()
+ {
+ errors.push(format!(
+ "listener {} connection shutdown timed out",
+ self.spec.addr
+ ));
+ }
+ if errors.is_empty() {
+ Ok(())
+ } else {
+ Err(errors.join("; "))
+ }
+ }
+
pub(super) fn restart(&mut self, active_runtime: Arc>) {
self.active_runtime = active_runtime.clone();
self.cancellation = CancellationToken::new();
diff --git a/src/maestro/listeners/control.rs b/src/maestro/listeners/control.rs
index 8da4955..a8fc0fb 100644
--- a/src/maestro/listeners/control.rs
+++ b/src/maestro/listeners/control.rs
@@ -1,6 +1,7 @@
use std::collections::{BTreeMap, BTreeSet};
use std::net::SocketAddr;
use std::sync::Arc;
+use std::time::Duration;
use arc_swap::ArcSwap;
@@ -13,7 +14,7 @@ use super::bind::{BoundListeners, BoundTcpListener, PreparedTcpListener, prepare
use super::plan::{ListenerBindSpec, listener_bind_plan};
#[cfg(unix)]
use super::unix::UnixAcceptHandle;
-use crate::web::manager::WebProcessRuntime;
+use crate::web::manager::{WebProcessRuntime, WebShutdownOutcome};
use crate::web::trace::WebTraceStore;
/// Process-owned listener inventory and accept-task lifecycle controller.
@@ -214,24 +215,76 @@ impl ListenerManager {
);
}
- /// Stops and joins every accept task before sockets are released.
+ /// Stops every accept task and applies one deadline to the complete WEB ingress.
pub(crate) async fn shutdown(&mut self) -> Result<(), String> {
+ if self.web_runtime.is_none() {
+ let mut errors = Vec::new();
+ for slot in self.slots.values_mut() {
+ if let Err(error_value) = slot.stop().await {
+ errors.push(error_value);
+ }
+ }
+ #[cfg(unix)]
+ if let Some(unix) = &mut self.unix
+ && let Err(error_value) = unix.stop().await
+ {
+ errors.push(error_value);
+ }
+ self.slots.clear();
+ #[cfg(unix)]
+ {
+ self.unix = None;
+ }
+ return if errors.is_empty() {
+ Ok(())
+ } else {
+ Err(errors.join("; "))
+ };
+ }
+ let timeout_secs = self
+ .active_runtime
+ .load()
+ .config()
+ .web
+ .timeouts
+ .shutdown_secs;
+ let now = tokio::time::Instant::now();
+ let deadline = now
+ .checked_add(Duration::from_secs(timeout_secs))
+ .unwrap_or(now);
+ for slot in self.slots.values() {
+ slot.request_stop();
+ }
+ #[cfg(unix)]
+ if let Some(unix) = &self.unix {
+ unix.request_stop();
+ }
+ let Some(web_runtime) = self.web_runtime.take() else {
+ return Err("WEB runtime disappeared during shutdown orchestration".to_string());
+ };
+ let drain = web_runtime.begin_shutdown();
let mut errors = Vec::new();
- for slot in self.slots.values_mut() {
- if let Err(error_value) = slot.stop().await {
+ let slot_waits = futures_util::future::join_all(
+ self.slots
+ .values_mut()
+ .map(|slot| slot.stop_until(deadline)),
+ );
+ let (slot_results, web_outcome) = tokio::join!(slot_waits, drain.wait_until(deadline));
+ for result in slot_results {
+ if let Err(error_value) = result {
errors.push(error_value);
}
}
#[cfg(unix)]
if let Some(unix) = &mut self.unix
- && let Err(error_value) = unix.stop().await
+ && let Err(error_value) = unix.stop_until(deadline).await
{
errors.push(error_value);
}
- self.slots.clear();
- if let Some(web_runtime) = self.web_runtime.take() {
- web_runtime.shutdown().await;
+ if web_outcome == WebShutdownOutcome::DeadlineExceeded {
+ errors.push("WEB ingress shutdown deadline exceeded".to_string());
}
+ self.slots.clear();
#[cfg(unix)]
{
self.unix = None;
diff --git a/src/maestro/listeners/unix.rs b/src/maestro/listeners/unix.rs
index 30f08df..8159361 100644
--- a/src/maestro/listeners/unix.rs
+++ b/src/maestro/listeners/unix.rs
@@ -148,11 +148,42 @@ impl UnixAcceptHandle {
}
pub(super) async fn stop(&mut self) -> Result<(), String> {
- self.cancellation.cancel();
+ self.request_stop();
if let Some(task) = self.task.take() {
task.await
.map_err(|error_value| format!("Unix listener task failed: {error_value}"))?;
}
Ok(())
}
+
+ /// Cancels Unix admission before process-owned listener waits begin.
+ pub(super) fn request_stop(&self) {
+ self.cancellation.cancel();
+ }
+
+ /// Joins the Unix acceptor by the process listener deadline.
+ pub(super) async fn stop_until(
+ &mut self,
+ deadline: tokio::time::Instant,
+ ) -> Result<(), String> {
+ self.request_stop();
+ let Some(mut task) = self.task.take() else {
+ return Ok(());
+ };
+ if task.is_finished() {
+ return task
+ .await
+ .map_err(|error_value| format!("Unix listener task failed: {error_value}"));
+ }
+ match tokio::time::timeout_at(deadline, &mut task).await {
+ Ok(result) => {
+ result.map_err(|error_value| format!("Unix listener task failed: {error_value}"))
+ }
+ Err(_) => {
+ task.abort();
+ let _ = task.await;
+ Err("Unix listener shutdown timed out".to_string())
+ }
+ }
+ }
}
diff --git a/src/web/http.rs b/src/web/http.rs
index 9066df5..1810074 100644
--- a/src/web/http.rs
+++ b/src/web/http.rs
@@ -2,7 +2,7 @@ use std::convert::Infallible;
use std::error::Error;
use std::net::SocketAddr;
use std::sync::Arc;
-use std::time::{Duration, Instant};
+use std::time::Duration;
use bytes::Bytes;
use http_body_util::BodyExt;
@@ -13,7 +13,6 @@ use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::{TokioIo, TokioTimer};
use ipnetwork::IpNetwork;
-use parking_lot::Mutex;
use tokio::net::TcpStream;
use tokio_util::sync::CancellationToken;
@@ -45,7 +44,7 @@ mod websocket;
mod trace_tests;
use crate::web::trace::{HttpTraceExchange, TraceDirection, TraceLifecycleEvent, TraceRoute};
-use activity::{ActivityBody, RequestActivity};
+use activity::{ActivityBody, ConnectionActivity, RequestActivity, RequestDeadlineHandle};
use body::{CollectBodyError, CollectedBody, RequestBody, collect_body};
use decoy::serve_decoy;
use down::handle_down;
@@ -80,15 +79,22 @@ pub(crate) async fn serve_connection(
let max_header_bytes = config.web.limits.max_header_bytes;
let header_timeout = Duration::from_secs(config.web.timeouts.header_secs);
let idle_timeout = Duration::from_secs(config.web.timeouts.http_idle_secs);
- let last_activity = Arc::new(Mutex::new(Instant::now()));
- let service_last_activity = Arc::clone(&last_activity);
+ let connection_activity = ConnectionActivity::new();
+ let service_activity = connection_activity.clone();
let service = service_fn(move |mut request| {
let runtime = Arc::clone(&runtime);
let trusted_proxy_cidrs = Arc::clone(&trusted_proxy_cidrs);
- let last_activity = Arc::clone(&service_last_activity);
+ let connection_activity = service_activity.clone();
let client_ip_source = client_ip_source;
async move {
- let activity = RequestActivity::begin(last_activity);
+ let Some(activity) = RequestActivity::begin(connection_activity) else {
+ let mut response = service_unavailable();
+ response
+ .headers_mut()
+ .insert(header::CONNECTION, HeaderValue::from_static("close"));
+ return Ok::<_, Infallible>(response);
+ };
+ request.extensions_mut().insert(activity.deadline_handle());
let trace = runtime.trace().begin_http(&request, peer.ip());
if let Some(trace) = &trace {
request.extensions_mut().insert(Arc::clone(trace));
@@ -133,9 +139,7 @@ pub(crate) async fn serve_connection(
_ = cancellation.cancelled() => break,
_ = &mut connection => break,
_ = idle_check.tick() => {
- if Instant::now().saturating_duration_since(*last_activity.lock())
- >= idle_timeout
- {
+ if connection_activity.should_close(tokio::time::Instant::now(), idle_timeout) {
break;
}
}
@@ -404,6 +408,10 @@ fn request_trace(request: &Request) -> Option<&Arc> {
request.extensions().get::>()
}
+fn request_deadline(request: &Request) -> Option {
+ request.extensions().get::().cloned()
+}
+
fn set_trace_route(request: &Request, route: TraceRoute) {
if let Some(trace) = request_trace(request) {
trace.set_route(route);
diff --git a/src/web/http/activity.rs b/src/web/http/activity.rs
index 3b7d3fc..c84d9e9 100644
--- a/src/web/http/activity.rs
+++ b/src/web/http/activity.rs
@@ -1,31 +1,291 @@
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
-use std::time::Instant;
+use std::time::Duration;
use bytes::Bytes;
use hyper::body::{Body, Frame, SizeHint};
use parking_lot::Mutex;
+use tokio::time::Instant;
use super::{BoxError, HttpBody};
use crate::web::trace::{HttpTraceExchange, TraceBodyState, TraceDirection};
+#[derive(Clone, Copy)]
+struct DeadlineSlot {
+ id: u64,
+ deadline: Instant,
+}
+
+struct RequestSlot {
+ id: u64,
+ deadline: Option,
+}
+
+struct UpgradeSlot {
+ request_id: u64,
+ deadline: DeadlineSlot,
+}
+
+struct ActivityState {
+ last_progress: Instant,
+ next_request_id: u64,
+ next_deadline_id: u64,
+ request: Option,
+ upgrade: Option,
+ failed: bool,
+}
+
+/// Shared liveness state for one accepted HTTP connection.
+#[derive(Clone)]
+pub(super) struct ConnectionActivity {
+ state: Arc>,
+}
+
+impl ConnectionActivity {
+ /// Creates activity state at the connection acceptance boundary.
+ pub(super) fn new() -> Self {
+ Self {
+ state: Arc::new(Mutex::new(ActivityState {
+ last_progress: Instant::now(),
+ next_request_id: 1,
+ next_deadline_id: 1,
+ request: None,
+ upgrade: None,
+ failed: false,
+ })),
+ }
+ }
+
+ /// Returns whether the connection has no protected operation or recent progress.
+ pub(super) fn should_close(&self, now: Instant, idle: Duration) -> bool {
+ let state = self.state.lock();
+ if state.failed {
+ return true;
+ }
+ let request_protected = state
+ .request
+ .as_ref()
+ .and_then(|request| request.deadline)
+ .is_some_and(|deadline| now <= deadline.deadline);
+ let upgrade_protected = state
+ .upgrade
+ .as_ref()
+ .is_some_and(|upgrade| now <= upgrade.deadline.deadline);
+ !request_protected
+ && !upgrade_protected
+ && now.saturating_duration_since(state.last_progress) >= idle
+ }
+
+ fn fail(&self) {
+ self.state.lock().failed = true;
+ }
+}
+
+/// Cloneable authority for protecting one request's explicitly bounded awaits.
+#[derive(Clone)]
+pub(super) struct RequestDeadlineHandle {
+ activity: ConnectionActivity,
+ request_id: u64,
+}
+
+impl RequestDeadlineHandle {
+ /// Protects the current bounded request operation until its absolute deadline.
+ pub(super) fn lease_until(&self, deadline: Instant) -> Option {
+ let now = Instant::now();
+ let mut state = self.activity.state.lock();
+ if state.failed {
+ return None;
+ }
+ let Some(current) = state.request.as_ref() else {
+ state.failed = true;
+ return None;
+ };
+ if current.id != self.request_id
+ || current
+ .deadline
+ .is_some_and(|active| now <= active.deadline)
+ {
+ state.failed = true;
+ return None;
+ }
+ let id = state.next_deadline_id;
+ let Some(next) = id.checked_add(1) else {
+ state.failed = true;
+ return None;
+ };
+ state.next_deadline_id = next;
+ let Some(current) = state.request.as_mut() else {
+ state.failed = true;
+ return None;
+ };
+ current.deadline = Some(DeadlineSlot { id, deadline });
+ Some(RequestDeadlineLease {
+ handle: self.clone(),
+ deadline_id: id,
+ })
+ }
+
+ /// Protects the current bounded request operation for one checked duration.
+ pub(super) fn lease_for(&self, duration: Duration) -> Option {
+ let Some(deadline) = Instant::now().checked_add(duration) else {
+ self.activity.fail();
+ return None;
+ };
+ self.lease_until(deadline)
+ }
+
+ /// Transfers idle protection to a pending Hyper upgrade operation.
+ pub(super) fn upgrade_until(&self, deadline: Instant) -> Option {
+ let now = Instant::now();
+ let mut state = self.activity.state.lock();
+ if state.failed
+ || state
+ .request
+ .as_ref()
+ .is_none_or(|request| request.id != self.request_id)
+ || state
+ .upgrade
+ .as_ref()
+ .is_some_and(|upgrade| now <= upgrade.deadline.deadline)
+ {
+ state.failed = true;
+ return None;
+ }
+ let id = state.next_deadline_id;
+ let Some(next) = id.checked_add(1) else {
+ state.failed = true;
+ return None;
+ };
+ state.next_deadline_id = next;
+ state.upgrade = Some(UpgradeSlot {
+ request_id: self.request_id,
+ deadline: DeadlineSlot { id, deadline },
+ });
+ Some(UpgradeDeadlineLease {
+ activity: self.activity.clone(),
+ request_id: self.request_id,
+ deadline_id: id,
+ deadline,
+ })
+ }
+}
+
+/// Exact request-operation lease that cannot clear a newer deadline.
+pub(super) struct RequestDeadlineLease {
+ handle: RequestDeadlineHandle,
+ deadline_id: u64,
+}
+
+impl Drop for RequestDeadlineLease {
+ fn drop(&mut self) {
+ let mut state = self.handle.activity.state.lock();
+ let matches = state.request.as_ref().is_some_and(|request| {
+ request.id == self.handle.request_id
+ && request
+ .deadline
+ .is_some_and(|deadline| deadline.id == self.deadline_id)
+ });
+ if matches {
+ if let Some(request) = state.request.as_mut() {
+ request.deadline = None;
+ }
+ state.last_progress = Instant::now();
+ }
+ }
+}
+
+/// Exact pending-upgrade lease retained by the spawned upgrade future.
+pub(super) struct UpgradeDeadlineLease {
+ activity: ConnectionActivity,
+ request_id: u64,
+ deadline_id: u64,
+ deadline: Instant,
+}
+
+impl UpgradeDeadlineLease {
+ /// Returns the absolute deadline shared with the upgrade timeout.
+ pub(super) fn deadline(&self) -> Instant {
+ self.deadline
+ }
+}
+
+impl Drop for UpgradeDeadlineLease {
+ fn drop(&mut self) {
+ let mut state = self.activity.state.lock();
+ let matches = state.upgrade.as_ref().is_some_and(|upgrade| {
+ upgrade.request_id == self.request_id && upgrade.deadline.id == self.deadline_id
+ });
+ if matches {
+ state.upgrade = None;
+ state.last_progress = Instant::now();
+ }
+ }
+}
+
/// Request lifecycle guard that refreshes HTTP connection activity on completion.
pub(super) struct RequestActivity {
- last_activity: Arc>,
+ handle: RequestDeadlineHandle,
}
impl RequestActivity {
/// Starts activity accounting for one HTTP request.
- pub(super) fn begin(last_activity: Arc>) -> Self {
- *last_activity.lock() = Instant::now();
- Self { last_activity }
+ pub(super) fn begin(activity: ConnectionActivity) -> Option {
+ let mut state = activity.state.lock();
+ if state.failed || state.request.is_some() {
+ state.failed = true;
+ return None;
+ }
+ let id = state.next_request_id;
+ let Some(next) = id.checked_add(1) else {
+ state.failed = true;
+ return None;
+ };
+ state.next_request_id = next;
+ state.last_progress = Instant::now();
+ state.request = Some(RequestSlot { id, deadline: None });
+ drop(state);
+ Some(Self {
+ handle: RequestDeadlineHandle {
+ activity,
+ request_id: id,
+ },
+ })
+ }
+
+ /// Returns the authority copied into request extensions for bounded awaits.
+ pub(super) fn deadline_handle(&self) -> RequestDeadlineHandle {
+ self.handle.clone()
+ }
+
+ fn progress(&self) {
+ self.handle.activity.state.lock().last_progress = Instant::now();
+ }
+
+ fn enter_response(&mut self) {
+ let mut state = self.handle.activity.state.lock();
+ if let Some(request) = state
+ .request
+ .as_mut()
+ .filter(|request| request.id == self.handle.request_id)
+ {
+ request.deadline = None;
+ state.last_progress = Instant::now();
+ }
}
}
impl Drop for RequestActivity {
fn drop(&mut self) {
- *self.last_activity.lock() = Instant::now();
+ let mut state = self.handle.activity.state.lock();
+ if state
+ .request
+ .as_ref()
+ .is_some_and(|request| request.id == self.handle.request_id)
+ {
+ state.request = None;
+ state.last_progress = Instant::now();
+ }
}
}
@@ -41,9 +301,10 @@ impl ActivityBody {
/// Binds one response body to its request activity guard.
pub(super) fn new(
inner: HttpBody,
- activity: RequestActivity,
+ mut activity: RequestActivity,
trace: Option>,
) -> Self {
+ activity.enter_response();
Self {
inner,
activity,
@@ -88,7 +349,7 @@ impl Body for ActivityBody {
Poll::Pending => {}
}
if result.is_ready() {
- *self.activity.last_activity.lock() = Instant::now();
+ self.activity.progress();
}
result
}
@@ -107,3 +368,60 @@ impl Drop for ActivityBody {
self.finish(TraceBodyState::Aborted);
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn bounded_request_deadline_suspends_only_idle_expiry() {
+ let activity = ConnectionActivity::new();
+ let request = RequestActivity::begin(activity.clone()).unwrap();
+ let now = Instant::now();
+ let lease = request
+ .deadline_handle()
+ .lease_until(now + Duration::from_secs(5))
+ .unwrap();
+
+ assert!(!activity.should_close(now + Duration::from_secs(4), Duration::from_secs(1)));
+ assert!(activity.should_close(now + Duration::from_secs(6), Duration::from_secs(1)));
+
+ drop(lease);
+ assert!(!activity.should_close(Instant::now(), Duration::from_secs(1)));
+ }
+
+ #[test]
+ fn stale_request_lease_cannot_clear_a_new_request_deadline() {
+ let activity = ConnectionActivity::new();
+ let request_a = RequestActivity::begin(activity.clone()).unwrap();
+ let now = Instant::now();
+ let lease_a = request_a
+ .deadline_handle()
+ .lease_until(now - Duration::from_secs(1))
+ .unwrap();
+ drop(request_a);
+ let request_b = RequestActivity::begin(activity.clone()).unwrap();
+ let _lease_b = request_b
+ .deadline_handle()
+ .lease_until(now + Duration::from_secs(5))
+ .unwrap();
+
+ drop(lease_a);
+
+ assert!(!activity.should_close(now + Duration::from_secs(4), Duration::from_secs(1)));
+ }
+
+ #[test]
+ fn stale_upgrade_lease_cannot_clear_its_replacement() {
+ let activity = ConnectionActivity::new();
+ let request = RequestActivity::begin(activity.clone()).unwrap();
+ let handle = request.deadline_handle();
+ let now = Instant::now();
+ let lease_a = handle.upgrade_until(now - Duration::from_secs(1)).unwrap();
+ let _lease_b = handle.upgrade_until(now + Duration::from_secs(5)).unwrap();
+
+ drop(lease_a);
+
+ assert!(!activity.should_close(now + Duration::from_secs(4), Duration::from_secs(1)));
+ }
+}
diff --git a/src/web/http/body.rs b/src/web/http/body.rs
index 38b4b64..80c727c 100644
--- a/src/web/http/body.rs
+++ b/src/web/http/body.rs
@@ -118,6 +118,7 @@ pub(super) async fn collect_body(
limit: usize,
allow_empty: bool,
) -> Result {
+ let request_deadline = super::request_deadline(&request);
let exceeds_limit = request.body().size_hint().lower() > limit as u64
|| request
.body()
@@ -134,6 +135,14 @@ pub(super) async fn collect_body(
let Some((reader_budget, body_budget)) = runtime.try_body_budget(limit) else {
return Err(CollectBodyError::Limit);
};
+ let _deadline_lease = match request_deadline {
+ Some(deadline) => Some(
+ deadline
+ .lease_for(body_timeout)
+ .ok_or(CollectBodyError::Limit)?,
+ ),
+ None => None,
+ };
let body = match tokio::time::timeout(body_timeout, Limited::new(body, limit).collect()).await {
Ok(Ok(body)) => body.to_bytes(),
_ => {
diff --git a/src/web/http/decoy.rs b/src/web/http/decoy.rs
index b868e21..f7652d4 100644
--- a/src/web/http/decoy.rs
+++ b/src/web/http/decoy.rs
@@ -184,6 +184,7 @@ async fn proxy_to_upstream(
header_timeout: Duration,
runtime: &WebProcessRuntime,
) -> HttpResponse {
+ let request_deadline = super::request_deadline(&request);
remove_hop_by_hop(request.headers_mut());
if let Ok(host) = HeaderValue::from_str(authority) {
request.headers_mut().insert(header::HOST, host);
@@ -197,10 +198,15 @@ async fn proxy_to_upstream(
return bad_gateway();
};
*request.uri_mut() = uri;
+ let _deadline_lease = match lease_deadline(request_deadline.as_ref(), header_timeout) {
+ Ok(lease) => lease,
+ Err(()) => return bad_gateway(),
+ };
let stream = match tokio::time::timeout(header_timeout, TcpStream::connect(addr)).await {
Ok(Ok(stream)) => stream,
_ => return bad_gateway(),
};
+ drop(_deadline_lease);
let max_header_bytes = runtime
.active_generation()
.config()
@@ -209,19 +215,29 @@ async fn proxy_to_upstream(
.max_header_bytes;
let mut builder = hyper::client::conn::http1::Builder::new();
builder.max_buf_size(max_header_bytes);
+ let _deadline_lease = match lease_deadline(request_deadline.as_ref(), header_timeout) {
+ Ok(lease) => lease,
+ Err(()) => return bad_gateway(),
+ };
let (mut sender, connection) =
match tokio::time::timeout(header_timeout, builder.handshake(TokioIo::new(stream))).await {
Ok(Ok(parts)) => parts,
_ => return bad_gateway(),
};
+ drop(_deadline_lease);
runtime.spawn_auxiliary(async move {
let _ = connection.await;
});
+ let _deadline_lease = match lease_deadline(request_deadline.as_ref(), header_timeout) {
+ Ok(lease) => lease,
+ Err(()) => return bad_gateway(),
+ };
let mut response =
match tokio::time::timeout(header_timeout, sender.send_request(request)).await {
Ok(Ok(response)) => response,
_ => return bad_gateway(),
};
+ drop(_deadline_lease);
remove_hop_by_hop(response.headers_mut());
response.map(|body| {
body.map_err(|error| -> BoxError { Box::new(error) })
@@ -229,6 +245,16 @@ async fn proxy_to_upstream(
})
}
+fn lease_deadline(
+ deadline: Option<&super::activity::RequestDeadlineHandle>,
+ timeout: Duration,
+) -> Result