WEB Session Lifecycle Observability + Bridge Recovery Drafts

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-09-01 19:19:52 +03:00
parent aec7d1619a
commit 01ffca5d34
47 changed files with 1678 additions and 441 deletions
+16 -1
View File
@@ -21,6 +21,7 @@ mod request;
mod observability;
use observability::{
WebCapacityStatus, WebCarrierNegotiationStatus, WebDecoyUpstreamStatus, WebIngressStatus,
WebLifecycleCountersStatus,
};
use request::{
CloseRequest, DrainRequest, RuntimeInstanceRequest, parse_session_query, parse_session_ref,
@@ -92,12 +93,20 @@ pub(super) async fn handle(
let trace_session_id = parse_session_ref(&runtime, session_ref)?;
match runtime.session_detail(trace_session_id) {
SessionDetail::Active(row) => Ok(success_response(StatusCode::OK, row, revision)),
SessionDetail::Gone { attempt } => Ok(success_response(
SessionDetail::Gone {
attempt,
carrier,
reason,
closed_age_ms,
} => Ok(success_response(
StatusCode::GONE,
GoneSessionData {
session_ref: session_ref.to_string(),
state: "closed",
attempt,
carrier,
reason,
closed_age_ms,
},
revision,
)),
@@ -272,6 +281,7 @@ struct WebStatusData {
capacity: WebCapacityStatus,
decoy_upstream: WebDecoyUpstreamStatus,
carrier_negotiation: WebCarrierNegotiationStatus,
lifecycle_counters: WebLifecycleCountersStatus,
#[serde(skip_serializing_if = "Option::is_none")]
operator_lifecycle: Option<crate::web::manager::OperatorLifecycleStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -306,6 +316,7 @@ impl WebStatusData {
let capacity = WebCapacityStatus::new(&publication, runtime, config);
let decoy_upstream = WebDecoyUpstreamStatus::new(&publication);
let carrier_negotiation = WebCarrierNegotiationStatus::new(&publication);
let lifecycle_counters = WebLifecycleCountersStatus::new(&publication, config);
Self {
lifecycle: publication.lifecycle.as_str(),
lifecycle_epoch: publication.epoch,
@@ -322,6 +333,7 @@ impl WebStatusData {
capacity,
decoy_upstream,
carrier_negotiation,
lifecycle_counters,
operator_lifecycle,
runtime: runtime.map(WebProcessRuntime::try_status),
}
@@ -333,6 +345,9 @@ struct GoneSessionData {
session_ref: String,
state: &'static str,
attempt: u8,
carrier: crate::config::WebCarrier,
reason: &'static str,
closed_age_ms: u64,
}
#[derive(Serialize)]
+45 -1
View File
@@ -5,7 +5,8 @@ use crate::web::control::{WebRuntimeLifecycle, WebRuntimePublication};
use crate::web::manager::{WebCapacityResourceStatus, WebCapacitySnapshot, WebProcessRuntime};
use crate::web::telemetry::{WebOutcomeCounter, WebRejectionCounter};
use crate::web::telemetry::{
WebCarrierFailureCounter, WebCarrierLearningCounter, WebCarrierSelectionCounter,
WebBridgeRecoveryCounter, WebCarrierFailureCounter, WebCarrierLearningCounter,
WebCarrierSelectionCounter, WebSessionCloseCounter, WebSessionLifecycleObservationCounter,
};
/// Private WEB ingress state owned by this Telemt process.
@@ -139,6 +140,27 @@ impl WebCarrierNegotiationStatus {
}
}
/// Fixed-cardinality process-lifetime WEB lifecycle counters.
#[derive(Serialize)]
pub(super) struct WebLifecycleCountersStatus {
bridge_recovery_secs: u64,
session_closures: Vec<WebSessionCloseCounter>,
session_observations: Vec<WebSessionLifecycleObservationCounter>,
bridge_recovery_events: Vec<WebBridgeRecoveryCounter>,
}
impl WebLifecycleCountersStatus {
/// Builds a complete counter set from process-owned telemetry.
pub(super) fn new(publication: &WebRuntimePublication, config: &ProxyConfig) -> Self {
Self {
bridge_recovery_secs: config.web.timeouts.bridge_recovery_secs,
session_closures: publication.telemetry.session_close_counters(),
session_observations: publication.telemetry.session_observation_counters(),
bridge_recovery_events: publication.telemetry.bridge_recovery_counters(),
}
}
}
#[cfg(test)]
mod tests {
use crate::config::ProxyConfig;
@@ -167,6 +189,11 @@ mod tests {
let decoy = serde_json::to_value(super::WebDecoyUpstreamStatus::new(&publication)).unwrap();
let carrier =
serde_json::to_value(super::WebCarrierNegotiationStatus::new(&publication)).unwrap();
let lifecycle = serde_json::to_value(super::WebLifecycleCountersStatus::new(
&publication,
&config,
))
.unwrap();
assert_eq!(
capacity["rejections"].as_array().unwrap().len(),
@@ -200,5 +227,22 @@ mod tests {
crate::config::WebCarrier::ALL.len()
* crate::web::telemetry::WebCarrierLearningOutcome::ALL.len()
);
assert_eq!(
lifecycle["session_closures"].as_array().unwrap().len(),
crate::config::WebCarrier::ALL.len()
* crate::web::session::SessionCloseReason::ALL.len()
);
assert_eq!(
lifecycle["session_observations"].as_array().unwrap().len(),
crate::config::WebCarrier::ALL.len()
* crate::web::telemetry::WebSessionLifecycleObservation::ALL.len()
);
assert_eq!(
lifecycle["bridge_recovery_events"]
.as_array()
.unwrap()
.len(),
crate::web::telemetry::WebBridgeRecoveryEvent::ALL.len()
);
}
}
+1
View File
@@ -344,6 +344,7 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
"long_poll_secs",
"bridge_request_secs",
"bridge_retry_secs",
"bridge_recovery_secs",
"carrier_probe_coalesce_ms",
"lane_open_wait_secs",
"carrier_health_secs",
+3
View File
@@ -42,6 +42,9 @@ pub(super) fn validate(timeouts: &WebTimeoutsConfig) -> Result<()> {
if !(1..=300).contains(&timeouts.bridge_retry_secs) {
return config_error("web.timeouts.bridge_retry_secs must be within [1, 300]");
}
if !(1..=60).contains(&timeouts.bridge_recovery_secs) {
return config_error("web.timeouts.bridge_recovery_secs must be within [1, 60]");
}
if timeouts.bridge_request_secs > timeouts.bridge_retry_secs {
return config_error("web.timeouts.bridge_request_secs must not exceed bridge_retry_secs");
}
@@ -194,7 +194,7 @@ fn web_carriers_reject_true_empty_and_duplicates() {
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\nbridge_request_secs = 7\nbridge_retry_secs = 41\ncarrier_probe_coalesce_ms = 4\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\nbridge_recovery_secs = 13\ncarrier_probe_coalesce_ms = 4\n\n[[web.vhosts]]",
);
let config = load_config_from_temp_toml(&configured);
assert_eq!(
@@ -204,6 +204,7 @@ fn web_carrier_and_bridge_deadlines_are_configurable() {
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.bridge_recovery_secs, 13);
assert_eq!(config.web.timeouts.carrier_probe_coalesce_ms, 4);
}
@@ -211,13 +212,14 @@ fn web_carrier_and_bridge_deadlines_are_configurable() {
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]]",
"[web.timeouts]\nbridge_request_secs = 7\nbridge_retry_secs = 41\nbridge_recovery_secs = 13\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.bridge_recovery_secs, 13);
assert_eq!(config.web.timeouts.carrier_probe_coalesce_ms, 4);
}
@@ -228,6 +230,8 @@ fn web_bridge_deadlines_are_bounded_and_ordered() {
("bridge_request_secs", "61"),
("bridge_retry_secs", "0"),
("bridge_retry_secs", "301"),
("bridge_recovery_secs", "0"),
("bridge_recovery_secs", "61"),
("carrier_probe_coalesce_ms", "11"),
] {
let invalid = WEB_CONFIG.replace(
+4
View File
@@ -301,6 +301,9 @@ pub struct WebTimeoutsConfig {
/// Absolute generated-bridge budget for one retryable HTTP operation.
#[serde(default = "default_web_bridge_retry_secs")]
pub bridge_retry_secs: u64,
/// Absolute post-commit budget for one surviving bridge recovery epoch.
#[serde(default = "default_web_bridge_recovery_secs")]
pub bridge_recovery_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,
@@ -361,6 +364,7 @@ impl Default for WebTimeoutsConfig {
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(),
bridge_recovery_secs: default_web_bridge_recovery_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(),
+1
View File
@@ -88,6 +88,7 @@ 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_bridge_recovery_secs, 15);
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);
+4 -50
View File
@@ -11,6 +11,9 @@ use crate::web::telemetry::{
WebDecoyUpstreamOutcome, WebHttpConnectionOverloadOutcome, WebRejectionReason,
};
// Session lifecycle and aggregate families stay isolated from capacity rendering.
mod lifecycle;
/// Renders fixed-cardinality process-owned WEB observability families.
pub(super) fn render(out: &mut String, publication: &WebRuntimePublication, config: &ProxyConfig) {
let runtime = publication.runtime.upgrade();
@@ -197,7 +200,7 @@ pub(super) fn render(out: &mut String, publication: &WebRuntimePublication, conf
}
render_carrier_negotiation(out, publication, runtime.as_deref(), config);
render_aggregate_totals(out, publication);
lifecycle::render(out, publication, config);
}
fn render_carrier_negotiation(
@@ -413,55 +416,6 @@ fn render_capacity(out: &mut String, snapshot: &crate::web::manager::WebCapacity
}
}
fn render_aggregate_totals(out: &mut String, publication: &WebRuntimePublication) {
let totals = publication.telemetry.aggregates();
let _ = writeln!(
out,
"# HELP telemt_web_session_incarnations_total Process-owned WEB session lifecycle totals"
);
let _ = writeln!(out, "# TYPE telemt_web_session_incarnations_total counter");
let _ = writeln!(
out,
"telemt_web_session_incarnations_total{{event=\"created\"}} {}",
totals.sessions_created
);
let _ = writeln!(
out,
"telemt_web_session_incarnations_total{{event=\"closed\"}} {}",
totals.sessions_closed
);
let _ = writeln!(
out,
"# HELP telemt_web_streams_total Process-owned WEB logical stream totals"
);
let _ = writeln!(out, "# TYPE telemt_web_streams_total counter");
let _ = writeln!(
out,
"telemt_web_streams_total{{event=\"opened\"}} {}",
totals.streams_opened
);
let _ = writeln!(
out,
"telemt_web_streams_total{{event=\"rejected\"}} {}",
totals.streams_rejected
);
let _ = writeln!(
out,
"# HELP telemt_web_carrier_bytes_total Process-owned WEB carrier payload bytes"
);
let _ = writeln!(out, "# TYPE telemt_web_carrier_bytes_total counter");
let _ = writeln!(
out,
"telemt_web_carrier_bytes_total{{direction=\"up\"}} {}",
totals.bytes_up
);
let _ = writeln!(
out,
"telemt_web_carrier_bytes_total{{direction=\"down\"}} {}",
totals.bytes_down
);
}
fn operator_state_token(state: OperatorLifecycleState) -> &'static str {
match state {
OperatorLifecycleState::Running => "running",
+130
View File
@@ -0,0 +1,130 @@
use std::fmt::Write;
use crate::config::{ProxyConfig, WebCarrier};
use crate::web::control::WebRuntimePublication;
use crate::web::session::SessionCloseReason;
use crate::web::telemetry::{WebBridgeRecoveryEvent, WebSessionLifecycleObservation};
pub(super) fn render(
out: &mut String,
publication: &WebRuntimePublication,
config: &ProxyConfig,
) {
let _ = writeln!(
out,
"# HELP telemt_web_session_closures_total Closed WEB session incarnations by carrier and terminal reason"
);
let _ = writeln!(out, "# TYPE telemt_web_session_closures_total counter");
for carrier in WebCarrier::ALL {
for reason in SessionCloseReason::ALL {
let _ = writeln!(
out,
"telemt_web_session_closures_total{{carrier=\"{}\",reason=\"{}\"}} {}",
carrier.as_str(),
reason.as_str(),
publication.telemetry.session_close_total(carrier, reason)
);
}
}
let _ = writeln!(
out,
"# HELP telemt_web_session_lifecycle_observations_total Authenticated activity after bounded lifecycle gaps"
);
let _ = writeln!(
out,
"# TYPE telemt_web_session_lifecycle_observations_total counter"
);
for carrier in WebCarrier::ALL {
for observation in WebSessionLifecycleObservation::ALL {
let _ = writeln!(
out,
"telemt_web_session_lifecycle_observations_total{{carrier=\"{}\",observation=\"{}\"}} {}",
carrier.as_str(),
observation.as_str(),
publication
.telemetry
.session_observation_total(carrier, observation)
);
}
}
let _ = writeln!(
out,
"# HELP telemt_web_bridge_recovery_events_total Server-observed bridge recovery milestones"
);
let _ = writeln!(
out,
"# TYPE telemt_web_bridge_recovery_events_total counter"
);
for event in WebBridgeRecoveryEvent::ALL {
let _ = writeln!(
out,
"telemt_web_bridge_recovery_events_total{{event=\"{}\"}} {}",
event.as_str(),
publication.telemetry.bridge_recovery_total(event)
);
}
let _ = writeln!(
out,
"# HELP telemt_web_bridge_recovery_seconds Effective bridge recovery deadline"
);
let _ = writeln!(out, "# TYPE telemt_web_bridge_recovery_seconds gauge");
let _ = writeln!(
out,
"telemt_web_bridge_recovery_seconds {}",
config.web.timeouts.bridge_recovery_secs
);
render_aggregates(out, publication);
}
fn render_aggregates(out: &mut String, publication: &WebRuntimePublication) {
let totals = publication.telemetry.aggregates();
let _ = writeln!(
out,
"# HELP telemt_web_session_incarnations_total Process-owned WEB session lifecycle totals"
);
let _ = writeln!(out, "# TYPE telemt_web_session_incarnations_total counter");
let _ = writeln!(
out,
"telemt_web_session_incarnations_total{{event=\"created\"}} {}",
totals.sessions_created
);
let _ = writeln!(
out,
"telemt_web_session_incarnations_total{{event=\"closed\"}} {}",
totals.sessions_closed
);
let _ = writeln!(
out,
"# HELP telemt_web_streams_total Process-owned WEB logical stream totals"
);
let _ = writeln!(out, "# TYPE telemt_web_streams_total counter");
let _ = writeln!(
out,
"telemt_web_streams_total{{event=\"opened\"}} {}",
totals.streams_opened
);
let _ = writeln!(
out,
"telemt_web_streams_total{{event=\"rejected\"}} {}",
totals.streams_rejected
);
let _ = writeln!(
out,
"# HELP telemt_web_carrier_bytes_total Process-owned WEB carrier payload bytes"
);
let _ = writeln!(out, "# TYPE telemt_web_carrier_bytes_total counter");
let _ = writeln!(
out,
"telemt_web_carrier_bytes_total{{direction=\"up\"}} {}",
totals.bytes_up
);
let _ = writeln!(
out,
"telemt_web_carrier_bytes_total{{direction=\"down\"}} {}",
totals.bytes_down
);
}
+17
View File
@@ -21,12 +21,16 @@ pub(crate) fn render(
batch_limit: usize,
queue_limit: usize,
queue_items: usize,
max_streams: usize,
negotiation_enabled: bool,
candidate_count: usize,
carrier_deadlines: [u64; 4],
long_poll_secs: u64,
bridge_request_secs: u64,
bridge_retry_secs: u64,
bridge_recovery_secs: u64,
websocket_open_secs: u64,
reconnect_grace_secs: u64,
carrier_probe_coalesce_ms: u64,
rng: &SecureRandom,
) -> BridgePage {
@@ -35,6 +39,9 @@ pub(crate) fn render(
let nonce = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(nonce);
let body = DOCUMENT
.replace("__RESPONSE_RUNTIME__", RESPONSE_RUNTIME)
.replace("__REQUEST_RUNTIME__", REQUEST_RUNTIME)
.replace("__BUFFER_RUNTIME__", BUFFER_RUNTIME)
.replace("__RECOVERY_RUNTIME__", RECOVERY_RUNTIME)
.replace("__RUNTIME__", RUNTIME)
.replace("__NONCE__", &nonce)
.replace("__HOST__", host)
@@ -42,6 +49,7 @@ pub(crate) fn render(
.replace("__BATCH_LIMIT__", &batch_limit.to_string())
.replace("__QUEUE_LIMIT__", &queue_limit.to_string())
.replace("__QUEUE_ITEMS__", &queue_items.to_string())
.replace("__MAX_STREAMS__", &max_streams.to_string())
.replace(
"__NEGOTIATION_ENABLED__",
if negotiation_enabled { "true" } else { "false" },
@@ -50,6 +58,12 @@ pub(crate) fn render(
.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(
"__BRIDGE_RECOVERY_SECS__",
&bridge_recovery_secs.to_string(),
)
.replace("__WEBSOCKET_OPEN_SECS__", &websocket_open_secs.to_string())
.replace("__RECONNECT_GRACE_SECS__", &reconnect_grace_secs.to_string())
.replace(
"__CARRIER_PROBE_COALESCE_MS__",
&carrier_probe_coalesce_ms.to_string(),
@@ -72,6 +86,9 @@ pub(crate) fn render(
const DOCUMENT: &str = include_str!("bridge/document.html");
const RESPONSE_RUNTIME: &str = include_str!("bridge/response.js");
const REQUEST_RUNTIME: &str = include_str!("bridge/request.js");
const BUFFER_RUNTIME: &str = include_str!("bridge/buffers.js");
const RECOVERY_RUNTIME: &str = include_str!("bridge/recovery.js");
const RUNTIME: &str = include_str!("bridge/runtime.js");
// Rendered wire-contract tests remain separate from the embedded document.
+143
View File
@@ -0,0 +1,143 @@
(()=>{'use strict';
function create(settings){
let queuedBytes=0,queuedItems=0;
const retiredLimit=4096,activeStreams=new Set(),retiredStreams=new Set(),retiredStreamOrder=[];
function reserve(data,lane){
const limits=settings.limits(),buffered=settings.buffered();
if(!data.byteLength||data.byteLength>limits.queueBytes-queuedBytes-buffered||queuedItems>=limits.queueItems)return false;
if(lane&&(data.byteLength>limits.laneBytes-lane.bytes-(lane.socket?lane.socket.bufferedAmount:0)||lane.items>=limits.laneItems))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(offset<value.byteLength){
if(value.byteLength-offset<8)throw new Error('invalid frame batch');
const size=view.getUint32(offset+4),end=offset+8+size;
if(size>1048576||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<value.byteLength){
if(value.byteLength-offset<8||result.length>=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 rememberStreamRetired(id){
if(!id||retiredStreams.has(id))return;
if(retiredStreamOrder.length===retiredLimit)retiredStreams.delete(retiredStreamOrder.shift());
retiredStreams.add(id);retiredStreamOrder.push(id);
}
function acceptNativeFrames(data){
const values=splitFrames(data),accepted=[];
for(const value of values){
if(retiredStreams.has(value.id)&&!activeStreams.has(value.id))continue;
if(value.type===1){
if(!activeStreams.has(value.id)&&activeStreams.size>=settings.maxStreams())throw settings.failure('capacity','stream capacity exhausted');
activeStreams.add(value.id);accepted.push(value.data);continue;
}
if(value.type===3){activeStreams.delete(value.id);rememberStreamRetired(value.id)}
accepted.push(value.data);
}
if(accepted.length===values.length)return data;if(!accepted.length)return null;
let total=0;for(const value of accepted)total+=value.byteLength;
const joined=new Uint8Array(total);let offset=0;
for(const value of accepted){joined.set(new Uint8Array(value),offset);offset+=value.byteLength}
return joined.buffer;
}
function observeServerFrames(data){
for(const value of splitFrames(data))if(value.type===3){activeStreams.delete(value.id);rememberStreamRetired(value.id)}
}
function probeFrames(){
const result=[];let scanned=0,pending=settings.pending(),batchLimit=settings.limits().batchBytes;
for(let index=0;index<pending.length;index++){
const source=pending[index],view=new DataView(source);let start=0;
while(start<source.byteLength){
if(source.byteLength-start<8||result.length>=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=settings.laneMode(),selected=[];let hasData=frames[first].type===2;
if(laneMode){
selected.push(frames[first]);
if(includeData&&!hasData)for(let index=first+1;index<frames.length;index++)if(frames[index].id===frames[first].id){selected.push(frames[index]);if(frames[index].type===2){hasData=true;break}}
}else{
let last=first;
if(includeData&&!hasData)for(let index=first+1;index<frames.length;index++){if(frames[index].id===frames[first].id&&frames[index].type===2){last=index;hasData=true;break}}
for(let index=0;index<=(hasData?last:first);index++)selected.push(frames[index]);
}
const spans=[];let total=0;
for(const frame of selected){
const previous=spans[spans.length-1];total+=frame.end-frame.start;
if(previous&&previous.source===frame.source&&previous.end===frame.start)previous.end=frame.end;
else spans.push({source:frame.source,index:frame.index,start:frame.start,end:frame.end});
}
const joined=new Uint8Array(total);let offset=0;
for(const span of spans){const part=new Uint8Array(span.source,span.start,span.end-span.start);joined.set(part,offset);offset+=part.byteLength}
return {spans,id:frames[first].id,data:joined.buffer,hasData};
}
function consumeProbe(probe){
const pending=settings.pending(),groups=new Map();
for(const span of probe.spans){if(pending[span.index]!==span.source)throw new Error('stale carrier probe');const values=groups.get(span.index)||[];values.push(span);groups.set(span.index,values)}
const indexes=Array.from(groups.keys()).sort((left,right)=>right-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.start<offset)throw new Error('overlapping carrier probe');removed+=span.end-span.start;offset=span.end}
if(removed===source.byteLength){pending.splice(index,1);release(removed,1,null);continue}
const merged=new Uint8Array(source.byteLength-removed);let write=0;offset=0;
for(const span of spans){merged.set(new Uint8Array(source,offset,span.start-offset),write);write+=span.start-offset;offset=span.end}
merged.set(new Uint8Array(source,offset),write);pending[index]=merged.buffer;release(removed,0,null);
}
}
function joinPending(values,lane){
const batchLimit=settings.limits().batchBytes;let total=0,count=0,frames=0;
while(count<values.length){
const bound=frameBound(values[count],4096,batchLimit),whole=bound.bytes===values[count].byteLength;
if(count===0&&!whole){
const head=new Uint8Array(values[0],0,bound.bytes).slice();
values[0]=values[0].slice(bound.bytes);queuedItems++;if(lane)lane.items++;
return {body:head.buffer,total:bound.bytes,count:1};
}
if(count&&(total+values[count].byteLength>batchLimit||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){return Object.assign(joinPending(values,lane),{lane,controller:null,cancelled:false,settled:false})}
function settleBatch(lease){if(!lease||lease.settled)return false;lease.settled=true;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 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 retireStream(id){activeStreams.delete(id);rememberStreamRetired(id)}
function retireAllStreams(){const ids=Array.from(activeStreams);activeStreams.clear();for(const id of ids)rememberStreamRetired(id);return ids}
function clearStreams(){activeStreams.clear();retiredStreams.clear();retiredStreamOrder.length=0}
function assertEmpty(){if(queuedBytes!==0||queuedItems!==0)throw new Error('queue accounting leak')}
return Object.freeze({reserve,release,releasePending,frameBound,splitFrames,acceptNativeFrames,observeServerFrames,findProbe,consumeProbe,takeBatch,settleBatch,cancelBatch,closeFrame,retireStream,retireAllStreams,clearStreams,assertEmpty});
}
globalThis.TelemtBridgeBuffers=Object.freeze({create});
})();
+9
View File
@@ -10,6 +10,15 @@
__RESPONSE_RUNTIME__
</script>
<script nonce="__NONCE__">
__REQUEST_RUNTIME__
</script>
<script nonce="__NONCE__">
__BUFFER_RUNTIME__
</script>
<script nonce="__NONCE__">
__RECOVERY_RUNTIME__
</script>
<script nonce="__NONCE__">
__RUNTIME__
</script>
</body>
+75
View File
@@ -0,0 +1,75 @@
(()=>{
'use strict';
const mediaType='application/vnd.telemt.web-recovery+json',maxBytes=1024,heartbeatMs=2500;
const exactKeys=(value,keys)=>value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).sort().join(',')===keys.slice().sort().join(',');
const integer=(value,min,max)=>Number.isSafeInteger(value)&&value>=min&&value<=max;
function parsePolicy(bytes){
let value;try{value=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes))}catch(error){throw new Error('invalid recovery document')}
if(!exactKeys(value,['v','bootstrap','limits','timeouts','negotiation'])||value.v!==1||!/^[A-Za-z0-9_-]{43}$/.test(value.bootstrap))throw new Error('invalid recovery document');
const limits=value.limits,timeouts=value.timeouts,negotiation=value.negotiation;
if(!exactKeys(limits,['carrier_batch_bytes','pending_bytes_per_session','pending_items_per_session','max_streams_per_session'])
||!integer(limits.carrier_batch_bytes,8,16777216)||!integer(limits.pending_bytes_per_session,limits.carrier_batch_bytes,4294967296)
||!integer(limits.pending_items_per_session,1,1048576)||!integer(limits.max_streams_per_session,1,16777215))throw new Error('invalid recovery limits');
if(!exactKeys(timeouts,['long_poll_secs','bridge_request_secs','bridge_retry_secs','bridge_recovery_secs','websocket_open_secs','reconnect_grace_secs'])
||!integer(timeouts.long_poll_secs,1,3600)||!integer(timeouts.bridge_request_secs,1,60)||!integer(timeouts.bridge_retry_secs,1,300)
||!integer(timeouts.bridge_recovery_secs,1,60)||!integer(timeouts.websocket_open_secs,1,300)||!integer(timeouts.reconnect_grace_secs,1,3600))throw new Error('invalid recovery timeouts');
if(!exactKeys(negotiation,['enabled','candidate_count','deadlines_secs','carrier_probe_coalesce_ms'])||typeof negotiation.enabled!=='boolean'
||!integer(negotiation.candidate_count,1,4)||!Array.isArray(negotiation.deadlines_secs)||negotiation.deadlines_secs.length!==4
||!negotiation.deadlines_secs.every((entry,index)=>integer(entry,1,3600)&&(index===0||entry>negotiation.deadlines_secs[index-1]))
||!integer(negotiation.carrier_probe_coalesce_ms,0,10))throw new Error('invalid recovery negotiation');
return value;
}
function create(settings){
let current=null,nextEpoch=1;
const remaining=owner=>Math.max(0,Math.min(owner.wall-Date.now(),owner.monotonic-performance.now()));
function stop(owner){
if(owner.heartbeat)clearTimeout(owner.heartbeat);owner.heartbeat=null;
if(owner.deadlineTimer)clearTimeout(owner.deadlineTimer);owner.deadlineTimer=null;
}
function heartbeat(owner){
if(current!==owner||owner.controller.signal.aborted)return;
const left=remaining(owner);settings.status(left);
if(left>0)owner.heartbeat=setTimeout(()=>heartbeat(owner),Math.min(heartbeatMs,left));
}
async function load(owner){
const left=remaining(owner);if(left<=0)throw new Error('recovery deadline');
const requestController=new AbortController(),abort=()=>requestController.abort();
owner.controller.signal.addEventListener('abort',abort,{once:true});
const timer=setTimeout(abort,Math.max(1,Math.min(settings.requestMs(),left)));
try{
const token=settings.token();
const response=await fetch(settings.url(),{
method:'GET',signal:requestController.signal,mode:'same-origin',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer',
headers:Object.assign({Accept:mediaType},token?{Authorization:'Bearer '+token}:{})
});
if(response.status!==200||response.headers.get('Content-Type')!==mediaType){settings.cancel(response);throw new Error('recovery representation rejected')}
const bytes=await settings.read(response,maxBytes,false,requestController.signal);
return parsePolicy(bytes);
}finally{
clearTimeout(timer);owner.controller.signal.removeEventListener('abort',abort);
}
}
async function run(owner,replay){
heartbeat(owner);
if(replay){
try{await replay(owner.controller.signal,()=>remaining(owner));settings.restored();return true}
catch(error){if(!settings.replaceable(error))throw error}
}
const policy=await load(owner);
if(remaining(owner)<=0)throw new Error('recovery deadline');
await settings.replace(policy,owner.controller.signal,()=>remaining(owner),owner.epoch);
return true;
}
function recover(reason,replay){
if(current)return current.promise;
const budget=settings.budgetMs(),owner={epoch:nextEpoch++,controller:new AbortController(),heartbeat:null,deadlineTimer:null,promise:null};
owner.wall=Date.now()+budget;owner.monotonic=performance.now()+budget;current=owner;
owner.deadlineTimer=setTimeout(()=>owner.controller.abort(),Math.max(1,remaining(owner)));
owner.promise=run(owner,replay).catch(error=>{settings.terminal(settings.reason(error,reason));return false}).finally(()=>{stop(owner);if(current===owner)current=null});
return owner.promise;
}
function cancel(){if(current){const owner=current;current=null;owner.controller.abort();stop(owner)}}
return Object.freeze({recover,cancel,active:()=>current!==null,remaining:()=>current?remaining(current):0});
}
globalThis.TelemtBridgeRecovery=Object.freeze({create});
})();
+66
View File
@@ -0,0 +1,66 @@
(()=>{'use strict';
function create(settings){
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 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||{})
});
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}
function responsePolicy(path,status){
if(path==='/api/v1/session'&&status===200)return {limit:8,exact:true};
if(path==='/api/v1/down'&&status===200)return {limit:settings.batchLimit(),exact:false};
return {limit:0,exact:true};
}
async function send(path,frozenOptions,remainingBudget,maxAttempts){
let delay=250,attempt=0,lastReason='network';maxAttempts=maxAttempts||9;
const initialBudget=remainingBudget?Math.min(settings.retryMs(),remainingBudget()):settings.retryMs();
const deadline=Date.now()+Math.max(0,initialBudget),external=frozenOptions.signal;
const attemptLimit=path==='/api/v1/down'?settings.longPollMs()+settings.requestMs():settings.requestMs();
while(attempt<maxAttempts){
if(settings.closed()||(external&&external.aborted))throw new Error('request aborted');
const remaining=Math.min(deadline-Date.now(),remainingBudget?remainingBudget():Infinity);if(remaining<=0)break;attempt++;
const controller=new AbortController(),abort=()=>controller.abort();
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(settings.origin()+path,requestOptions);
if(retryableStatus(fetched.status)){
lastReason='http';wait=retryAfterMs(fetched);settings.cancel(fetched);
}else{
const policy=responsePolicy(path,fetched.status);let body;
try{body=await settings.read(fetched,policy.limit,policy.exact,controller.signal)}
catch(error){controller.abort();throw settings.failure('protocol',error&&error.message)}
response={status:fetched.status,headers:fetched.headers,body};return response;
}
}catch(error){
controller.abort();
if(settings.closed()||(external&&external.aborted))throw error;
if(settings.reason(error,'')==='protocol')throw error;
}finally{clearTimeout(timer);if(external)external.removeEventListener('abort',abort)}
const after=Math.min(deadline-Date.now(),remainingBudget?remainingBudget():Infinity);if(attempt>=maxAttempts||after<=0)break;
settings.retrying();
const backoff=wait||delay+Math.floor(Math.random()*Math.max(1,delay/4));
await pause(Math.min(backoff,after),external);delay=Math.min(delay*2,2000);
}
throw settings.failure(lastReason,'carrier retry limit reached');
}
return Object.freeze({options,pause,send});
}
globalThis.TelemtBridgeRequest=Object.freeze({create});
})();
+183 -205
View File
@@ -1,20 +1,25 @@
(()=>{'use strict';
const bootstrap="__BOOTSTRAP__";
let bootstrap="__BOOTSTRAP__";
const relayOrigin='https://__HOST__',carrierCapabilities='https,https-lanes,websocket,websocket-lanes';
const responseBody=globalThis.TelemtBridgeResponse;if(!responseBody)throw new Error('missing response runtime');
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__;
const requestSupport=globalThis.TelemtBridgeRequest;if(!requestSupport)throw new Error('missing request runtime');
const bufferSupport=globalThis.TelemtBridgeBuffers;if(!bufferSupport)throw new Error('missing buffer runtime');
const recoverySupport=globalThis.TelemtBridgeRecovery;if(!recoverySupport)throw new Error('missing recovery runtime');
let negotiationEnabled=__NEGOTIATION_ENABLED__,candidateCount=__CANDIDATE_COUNT__,candidateDeadlines=[__CARRIER_DEADLINES__];
let longPollMs=__LONG_POLL_SECS__*1000,bridgeRequestMs=__BRIDGE_REQUEST_SECS__*1000,bridgeRetryMs=__BRIDGE_RETRY_SECS__*1000;
let bridgeRecoveryMs=__BRIDGE_RECOVERY_SECS__*1000,websocketOpenMs=__WEBSOCKET_OPEN_SECS__*1000,reconnectGraceMs=__RECONNECT_GRACE_SECS__*1000;
let 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]||'';
let batchLimit=__BATCH_LIMIT__,queueLimit=__QUEUE_LIMIT__,queueItemLimit=__QUEUE_ITEMS__,maxStreams=__MAX_STREAMS__;
let laneQueueLimit=Math.min(queueLimit,8388608),laneItemLimit=Math.min(queueItemLimit,1024);const closedLaneLimit=4096;
const fragment=location.hash,androidNonce=/^#android=([A-Za-z0-9_-]{43})$/.exec(fragment)?.[1]||'',recoveryPath=location.pathname+location.search;
history.replaceState(null,'',location.pathname);
let initialized=false,closed=false,port=null,sessionToken='',cleanupToken='',createStarted=false,socket=null,socketReady=false,carrier='';
let queuedBytes=0,queuedItems=0,upSequence=1,downCursor='0',upRunning=false,upLease=null,pollController=null;
let upSequence=1,downCursor='0',upRunning=false,upLease=null,pollController=null;
let helloFrame=null,helloTimer=null,welcomeSent=false,carrierAttempt=1,carrierFailure='',carrierCommitted=false,terminalFailure='';
let negotiationStartedAt=0,carrierTimer=null,probeTimer=null,attemptController=null,attemptEpoch=1,candidateRunning=false,switching=false,currentAttempt=null;
const pending=[],upPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[];
let recoveryController=null,recoveryCommit=null,recoveryReplaced=false,lastSchedulerWall=Date.now(),lastSchedulerMonotonic=performance.now();
const pending=[],upPending=[],recoveryPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[];
const canonicalFailures=['timeout','network','upgrade','http','protocol'];
const failure=(reason,message)=>Object.assign(new Error(message||reason),{telemtReason:reason});
const failureReason=(error,fallback)=>error&&canonicalFailures.includes(error.telemtReason)?error.telemtReason:fallback;
@@ -25,182 +30,84 @@ const status=(state,phase,reason,deadlineMs)=>{
if(currentDeadline===undefined)currentDeadline=negotiationStartedAt?Math.max(0,negotiationStartedAt+negotiatedFinalDeadline*1000-Date.now()):0;
port.postMessage({t:'status',state,phase:currentPhase,reason:reason||'',deadline_ms:Math.max(0,Math.ceil(currentDeadline))});
};
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 requestClient=requestSupport.create({
origin:()=>relayOrigin,closed:()=>closed,retryMs:()=>bridgeRetryMs,longPollMs:()=>longPollMs,requestMs:()=>bridgeRequestMs,
batchLimit:()=>batchLimit,read:(response,limit,exact,signal)=>responseBody.read(response,limit,exact,signal),cancel:responseBody.cancel,
failure,reason:failureReason,retrying:()=>status('reconnecting')
});
const options=requestClient.options,pause=requestClient.pause,request=requestClient.send;
const buffers=bufferSupport.create({
limits:()=>({batchBytes:batchLimit,queueBytes:queueLimit,queueItems:queueItemLimit,laneBytes:laneQueueLimit,laneItems:laneItemLimit}),
buffered:()=>{let total=socket?socket.bufferedAmount:0;for(const value of lanes.values())if(value.socket)total+=value.socket.bufferedAmount;return total},
pending:()=>pending,laneMode:()=>carrier==='https-lanes'||carrier==='websocket-lanes',maxStreams:()=>maxStreams,failure
});
const {reserve,release,releasePending,frameBound,splitFrames,acceptNativeFrames,observeServerFrames,findProbe,consumeProbe,takeBatch,closeFrame,retireStream,retireAllStreams,clearStreams}=buffers;
function detachLease(lease){if(lease.lane){if(lease.lane.upLease===lease)lease.lane.upLease=null}else if(upLease===lease)upLease=null}
function settleBatch(lease){if(!buffers.settleBatch(lease))return false;detachLease(lease);return true}
function cancelBatch(lease){if(!lease||lease.settled)return;buffers.cancelBatch(lease);detachLease(lease)}
const attemptHeaders=(attempt,failure)=>negotiationEnabled?Object.assign({'X-Carrier-Capabilities':carrierCapabilities,'X-Carrier-Attempt':String(attempt)},failure?{'X-Carrier-Failure':failure}:{}):{};
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 finishOldRecovery(){
recoveryReplaced=false;status('connected','committed','',0);
for(const data of recoveryPending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)}
}
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 rejectRecoveryCommit(error){
const commit=recoveryCommit;if(!commit)return;recoveryCommit=null;
commit.signal.removeEventListener('abort',commit.abort);commit.reject(error);
}
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 resolveRecoveryCommit(){
const commit=recoveryCommit;if(!commit)return;recoveryCommit=null;recoveryReplaced=false;
commit.signal.removeEventListener('abort',commit.abort);commit.resolve();
}
function frameBound(value,maxFrames,maxBytes){
const view=new DataView(value);let offset=0,frames=0;
while(offset<value.byteLength){
if(value.byteLength-offset<8)throw new Error('invalid frame batch');
const size=view.getUint32(offset+4),end=offset+8+size;
if(size>1048576||end>value.byteLength)throw new Error('invalid frame');
if(frames>0&&(frames>=maxFrames||end>maxBytes))break;
frames++;offset=end;
function retireCarrier(policy){
recoveryReplaced=true;attemptEpoch++;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;clearProbeTimer();
if(attemptController)attemptController.abort();attemptController=null;if(pollController)pollController.abort();pollController=null;
if(socket){const previous=socket;socket=null;previous.close()}socketReady=false;cancelBatch(upLease);releasePending(upPending,null);
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(!frames)throw new Error('empty frame batch');
return {frames,bytes:offset};
lanes.clear();closedLanes.clear();closedLaneOrder.length=0;releasePending(pending,null);releasePending(recoveryPending,null);
for(const id of retireAllStreams())if(port){const frame=closeFrame(id);port.postMessage(frame,[frame])}
bootstrap=policy.bootstrap;batchLimit=policy.limits.carrier_batch_bytes;queueLimit=policy.limits.pending_bytes_per_session;
queueItemLimit=policy.limits.pending_items_per_session;maxStreams=policy.limits.max_streams_per_session;
laneQueueLimit=Math.min(queueLimit,8388608);laneItemLimit=Math.min(queueItemLimit,1024);
longPollMs=policy.timeouts.long_poll_secs*1000;bridgeRequestMs=policy.timeouts.bridge_request_secs*1000;
bridgeRetryMs=policy.timeouts.bridge_retry_secs*1000;bridgeRecoveryMs=policy.timeouts.bridge_recovery_secs*1000;
websocketOpenMs=policy.timeouts.websocket_open_secs*1000;reconnectGraceMs=policy.timeouts.reconnect_grace_secs*1000;
negotiationEnabled=policy.negotiation.enabled;candidateCount=policy.negotiation.candidate_count;
candidateDeadlines=policy.negotiation.deadlines_secs;probeCoalesceMs=policy.negotiation.carrier_probe_coalesce_ms;
negotiatedCandidateCount=candidateCount;negotiatedFinalDeadline=candidateDeadlines[3];negotiatedFrozen=false;
sessionToken='';cleanupToken='';carrier='';carrierAttempt=1;carrierFailure='';carrierCommitted=false;
candidateRunning=false;switching=false;currentAttempt=null;upSequence=1;downCursor='0';upRunning=false;
}
function splitFrames(value){
const view=new DataView(value),result=[];let offset=0;
while(offset<value.byteLength){
if(value.byteLength-offset<8||result.length>=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 replaceCarrier(policy,signal,remaining){
if(closed||!helloFrame||!port)throw failure('protocol','missing recovery owner');
retireCarrier(policy);negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch);
return new Promise((resolve,reject)=>{
const abort=()=>rejectRecoveryCommit(failure('timeout','recovery deadline'));
recoveryCommit={resolve,reject,signal,abort};signal.addEventListener('abort',abort,{once:true});
if(remaining()<=0){abort();return}createSession(attemptEpoch);
});
}
function probeFrames(){
const result=[];let scanned=0;
for(let index=0;index<pending.length;index++){
const source=pending[index],view=new DataView(source);let start=0;
while(start<source.byteLength){
if(source.byteLength-start<8||result.length>=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 recoverTransport(error,replay){
if(closed)return Promise.resolve(false);
const reason=failureReason(error,'network');
if(reason==='protocol'){fail(reason);return Promise.resolve(false)}
return recoveryController.recover(reason,replay);
}
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;index<frames.length;index++)if(frames[index].id===frames[first].id){selected.push(frames[index]);if(frames[index].type===2){hasData=true;break}}
}else{
let last=first;
if(includeData&&!hasData)for(let index=first+1;index<frames.length;index++){if(frames[index].id===frames[first].id&&frames[index].type===2){last=index;hasData=true;break}}
for(let index=0;index<=(hasData?last:first);index++)selected.push(frames[index]);
}
const spans=[];let total=0;
for(const frame of selected){
const previous=spans[spans.length-1];total+=frame.end-frame.start;
if(previous&&previous.source===frame.source&&previous.end===frame.start)previous.end=frame.end;
else spans.push({source:frame.source,index:frame.index,start:frame.start,end:frame.end});
}
const joined=new Uint8Array(total);let offset=0;
for(const span of spans){const part=new Uint8Array(span.source,span.start,span.end-span.start);joined.set(part,offset);offset+=part.byteLength}
return {spans,id:frames[first].id,data:joined.buffer,hasData};
function schedulerGap(){
const wall=Date.now(),monotonic=performance.now();
const gap=Math.max(0,wall-lastSchedulerWall,monotonic-lastSchedulerMonotonic);
lastSchedulerWall=wall;lastSchedulerMonotonic=monotonic;return gap;
}
function consumeProbe(probe){
const groups=new Map();
for(const span of probe.spans){if(pending[span.index]!==span.source)throw new Error('stale carrier probe');const values=groups.get(span.index)||[];values.push(span);groups.set(span.index,values)}
const indexes=Array.from(groups.keys()).sort((left,right)=>right-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.start<offset)throw new Error('overlapping carrier probe');removed+=span.end-span.start;offset=span.end}
if(removed===source.byteLength){pending.splice(index,1);release(removed,1,null);continue}
const merged=new Uint8Array(source.byteLength-removed);let write=0;offset=0;
for(const span of spans){merged.set(new Uint8Array(source,offset,span.start-offset),write);write+=span.start-offset;offset=span.end}
merged.set(new Uint8Array(source,offset),write);pending[index]=merged.buffer;release(removed,0,null);
}
}
function joinPending(values,lane){
let total=0,count=0,frames=0;
while(count<values.length){
const bound=frameBound(values[count],4096,batchLimit),whole=bound.bytes===values[count].byteLength;
if(count===0&&!whole){
const head=new Uint8Array(values[0],0,bound.bytes).slice();
values[0]=values[0].slice(bound.bytes);queuedItems++;if(lane)lane.items++;
return {body:head.buffer,total:bound.bytes,count:1};
}
if(count&&(total+values[count].byteLength>batchLimit||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}
function responsePolicy(path,status){
if(path==='/api/v1/session'&&status===200)return {limit:8,exact:true};
if(path==='/api/v1/down'&&status===200)return {limit:batchLimit,exact:false};
return {limit:0,exact:true};
}
async function request(path,frozenOptions){
let delay=250,attempt=0,lastReason='network';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);
if(retryableStatus(fetched.status)){
lastReason='http';wait=retryAfterMs(fetched);responseBody.cancel(fetched);
}else{
const policy=responsePolicy(path,fetched.status);let body;
try{body=await responseBody.read(fetched,policy.limit,policy.exact,controller.signal)}
catch(error){controller.abort();throw failure('protocol',error&&error.message)}
response={status:fetched.status,headers:fetched.headers,body};return response;
}
}catch(error){
controller.abort();
if(closed||(external&&external.aborted))throw error;
if(failureReason(error,'')==='protocol')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 failure(lastReason,'carrier retry limit reached');
function observeResumeTrigger(){
const gap=schedulerGap();if(!carrierCommitted||closed)return;
if(gap>=2*longPollMs)status('reconnecting','retrying','',bridgeRecoveryMs);
if(gap>=reconnectGraceMs&&!recoveryController.active())recoveryController.recover('timeout',null);
}
function fail(reason){
if(closed)return;reason=reason||'protocol';if(canonicalFailures.includes(reason))terminalFailure=reason;
rejectRecoveryCommit(failure(reason));
status('failed','terminal',reason,0);if(port)port.postMessage({t:'close'});close(true);
}
function knownCarrier(value){return value==='https'||value==='https-lanes'||value==='websocket'||value==='websocket-lanes'}
@@ -333,6 +240,7 @@ function commitCarrier(probe,epoch){
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)}
resolveRecoveryCommit();
}
function queueCarrier(data){
try{
@@ -346,10 +254,22 @@ 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)throw failure('http','uplink rejected');
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','uplink acknowledgement rejected');
lease=takeBatch(upPending,null);upLease=lease;lease.controller=new AbortController();const sequence=String(upSequence),token=sessionToken;
for(;;){
try{
const response=await request('/api/v1/up',options('POST',token,lease.body,{'X-Up-Seq':sequence},lease.controller.signal),null,1);
if(response.status!==204)throw failure('http','uplink rejected');
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','uplink acknowledgement rejected');
break;
}catch(error){
const recovered=await recoverTransport(error,async(signal,remaining)=>{
const response=await request('/api/v1/up',options('POST',token,lease.body,{'X-Up-Seq':sequence},signal),remaining,2);
if(response.status!==204)throw failure('http','uplink replay rejected');
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','uplink replay acknowledgement rejected');
});
if(!recovered||closed||lease.cancelled||sessionToken!==token)return;
}
}
if(!settleBatch(lease))return;port.postMessage({t:'traffic',up:lease.total,down:0});upSequence++;lease=null;
}
}catch(error){if(!closed&&!(lease&&lease.cancelled))fail(failureReason(error,'network'))}
@@ -359,13 +279,17 @@ 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);
state.probe=probe;state.sent=true;if(!negotiationEnabled){if(state.openTimer)clearTimeout(state.openTimer);state.openTimer=null;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};
next=new WebSocket(socketURL(),protocol);next.binaryType='arraybuffer';next.telemt={epoch,lane,probe,opened:false,sent:false,openTimer:null};
next.telemt.openTimer=setTimeout(()=>{
const state=next.telemt;if(closed||state.epoch!==attemptEpoch)return;
next.close();advanceCarrier('timeout',state.epoch);
},websocketOpenMs);
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;
@@ -373,18 +297,19 @@ function openCandidateSocket(probe,laneID,epoch){
};
next.onmessage=event=>{
const state=next.telemt;if(closed||state.epoch!==attemptEpoch||!(event.data instanceof ArrayBuffer))return;
if(state.openTimer)clearTimeout(state.openTimer);state.openTimer=null;
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('protocol');return}
port.postMessage({t:'traffic',up:0,down:event.data.byteLength});port.postMessage(event.data,[event.data]);status('connected');
observeServerFrames(event.data);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;
const state=next.telemt;if(state.openTimer)clearTimeout(state.openTimer);state.openTimer=null;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('network')}
if(state.lane){state.lane.ready=false;state.lane.socket=null;finishLane(state.lane,true)}else{socketReady=false;recoveryController.recover('network',null)}
};
}
function queueSocket(data){if(!reserve(data,null)){fail('capacity');return}upPending.push(data);runSocketUp()}
@@ -400,21 +325,30 @@ async function runSocketUp(){
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(failureReason(error,'network'))}
}catch(error){if(!closed&&!(lease&&lease.cancelled))recoverTransport(error,null)}
finally{upRunning=false;if(!closed&&socketReady&&upPending.length)runSocketUp()}
}
async function poll(){
while(!closed&&sessionToken){
const token=sessionToken,cursor=downCursor;
try{
pollController=new AbortController();
const response=await request('/api/v1/down',options('POST',sessionToken,null,{'X-Down-Cursor':downCursor},pollController.signal));
const response=await request('/api/v1/down',options('POST',token,null,{'X-Down-Cursor':cursor},pollController.signal),null,1);
if(response.status===204){status('connected');continue}
if(response.status!==200)throw failure('http','downlink rejected');
const next=response.headers.get('X-Down-Cursor')||'',data=response.body;
if(!next||!data.byteLength)throw failure('protocol','invalid downlink response');
if(closed)return;
port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);downCursor=next;status('connected');
}catch(error){if(!closed)fail(failureReason(error,'network'));return}
observeServerFrames(data);port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);downCursor=next;status('connected');
}catch(error){
if(closed)return;
const recovered=await recoverTransport(error,async(signal,remaining)=>{
const response=await request('/api/v1/down',options('POST',token,null,{'X-Down-Cursor':cursor},signal),remaining,2);
if(response.status===204)return;
if(response.status!==200||!response.body.byteLength||!response.headers.get('X-Down-Cursor'))throw failure('http','downlink replay rejected');
});
if(!recovered||closed||sessionToken!==token)return;
}
}
}
function ensureLane(id){
@@ -427,13 +361,12 @@ function rememberLaneClosed(id){
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<WebSocket.CLOSING)lane.socket.close();
releasePending(lane.pending,lane);lanes.delete(lane.id);rememberLaneClosed(lane.id);
if(notifyClient&&!lane.remoteClosed&&port){const frame=closeFrame(lane.id);port.postMessage(frame,[frame])}
if(notifyClient&&!lane.remoteClosed&&port){retireStream(lane.id);const frame=closeFrame(lane.id);port.postMessage(frame,[frame])}
}
function queueLane(value){
let lane=lanes.get(value.id);
@@ -447,14 +380,16 @@ function queueLane(value){
}
function openLaneSocket(lane){
if(lane.socket||closed)return;lane.socket=new WebSocket(socketURL(),'tproxy-lane-v1.'+sessionToken+'.'+String(lane.id));lane.socket.binaryType='arraybuffer';
const opened=lane.socket,openTimer=setTimeout(()=>{if(!closed&&lanes.get(lane.id)===lane&&lane.socket===opened)finishLane(lane,true)},websocketOpenMs);
lane.socket.onopen=()=>{if(closed||lanes.get(lane.id)!==lane)return;lane.ready=true;status('connected');runLaneSocketUp(lane)};
lane.socket.onmessage=event=>{
clearTimeout(openTimer);
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');
observeServerFrames(event.data);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)};
lane.socket.onerror=()=>{};lane.socket.onclose=()=>{clearTimeout(openTimer);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;
@@ -472,10 +407,22 @@ async function runLaneUp(lane){
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)throw failure('http','lane uplink rejected');
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','lane uplink acknowledgement rejected');
const sequence=String(lane.sequence),laneID=String(lane.id),token=sessionToken;
for(;;){
try{
const response=await request('/api/v1/up',options('POST',token,lease.body,{'X-Up-Seq':sequence,'X-Lane-ID':laneID},lease.controller.signal),null,1);
if(response.status!==204)throw failure('http','lane uplink rejected');
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','lane uplink acknowledgement rejected');
break;
}catch(error){
const recovered=await recoverTransport(error,async(signal,remaining)=>{
const response=await request('/api/v1/up',options('POST',token,lease.body,{'X-Up-Seq':sequence,'X-Lane-ID':laneID},signal),remaining,2);
if(response.status!==204)throw failure('http','lane uplink replay rejected');
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','lane uplink replay acknowledgement rejected');
});
if(!recovered||closed||lease.cancelled||sessionToken!==token||lanes.get(lane.id)!==lane)return;
}
}
if(!settleBatch(lease))return;port.postMessage({t:'traffic',up:lease.total,down:0});lane.sequence++;lease=null;
if(!lane.polling)pollLane(lane);
}
@@ -483,11 +430,12 @@ async function runLaneUp(lane){
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;
if(!lane||lane.polling)return;lane.polling=true;let restart=false,failedToken='',failedCursor='',failedLaneID='';
try{
while(!closed&&sessionToken&&lanes.get(lane.id)===lane){
const controller=new AbortController(),laneID=String(lane.id);lane.controller=controller;
const response=await request('/api/v1/down',options('POST',sessionToken,null,{'X-Down-Cursor':lane.cursor,'X-Lane-ID':laneID},controller.signal));
const controller=new AbortController(),laneID=String(lane.id),token=sessionToken,cursor=lane.cursor;lane.controller=controller;
failedToken=token;failedCursor=cursor;failedLaneID=laneID;
const response=await request('/api/v1/down',options('POST',token,null,{'X-Down-Cursor':cursor,'X-Lane-ID':laneID},controller.signal),null,1);
if(response.status===204){
if(response.headers.get('X-Lane-Closed')==='1'){finishLane(lane,false);return}
status('connected');continue;
@@ -497,35 +445,55 @@ async function pollLane(lane){
if(!next||!data.byteLength)throw failure('protocol','invalid lane downlink response');
for(const value of splitFrames(data))if(value.id!==lane.id)throw new Error('cross-lane frame');
if(closed)return;
port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);lane.cursor=next;status('connected');
observeServerFrames(data);port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);lane.cursor=next;status('connected');
}
}catch(error){if(!closed)fail(failureReason(error,'network'))}
finally{lane.polling=false;lane.controller=null}
}catch(error){
if(!closed&&lanes.get(lane.id)===lane){
const recovered=await recoverTransport(error,async(signal,remaining)=>{
const response=await request('/api/v1/down',options('POST',failedToken,null,{'X-Down-Cursor':failedCursor,'X-Lane-ID':failedLaneID},signal),remaining,2);
if(response.status===204)return;
if(response.status!==200||!response.body.byteLength||!response.headers.get('X-Down-Cursor'))throw failure('http','lane downlink replay rejected');
});
restart=recovered&&!closed&&sessionToken===failedToken&&lanes.get(lane.id)===lane;
}
}
finally{lane.polling=false;lane.controller=null;if(restart)pollLane(lane)}
}
function deleteSession(){
const token=cleanupToken||sessionToken,headers=canonicalFailures.includes(terminalFailure)?{'X-Carrier-Failure':terminalFailure}:null;
if(token)fetch(relayOrigin+'/api/v1/session',options('DELETE',token,null,headers,undefined,true)).catch(()=>{});
}
function close(notifyServer){
if(closed)return;closed=true;if(helloTimer)clearTimeout(helloTimer);helloTimer=null;if(carrierTimer)clearTimeout(carrierTimer);clearProbeTimer();if(attemptController)attemptController.abort();if(pollController)pollController.abort();
if(closed)return;closed=true;if(recoveryController)recoveryController.cancel();rejectRecoveryCommit(failure('network','bridge closed'));if(helloTimer)clearTimeout(helloTimer);helloTimer=null;if(carrierTimer)clearTimeout(carrierTimer);clearProbeTimer();if(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');
if(notifyServer)deleteSession();releasePending(pending,null);releasePending(recoveryPending,null);lanes.clear();clearStreams();if(port)port.close();
buffers.assertEmpty();
}
function activatePort(nextPort){
initialized=true;port=nextPort;
port.onmessage=message=>{
observeResumeTrigger();
if(message.data instanceof ArrayBuffer){
if(!createStarted){createStarted=true;if(helloTimer)clearTimeout(helloTimer);helloTimer=null;helloFrame=message.data;if(negotiationEnabled){negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch)}createSession(attemptEpoch)}
else if(!carrierCommitted){if(!reserve(message.data,null)){fail('capacity');return}pending.push(message.data);maybeStartCandidate()}
else queueCarrier(message.data);
else{
let data;try{data=acceptNativeFrames(message.data)}catch(error){fail(error&&error.telemtReason==='capacity'?'capacity':'protocol');return}if(!data)return;
if(recoveryController.active()&&!recoveryReplaced){if(!reserve(data,null)){fail('capacity');return}recoveryPending.push(data)}
else if(!carrierCommitted){if(!reserve(data,null)){fail('capacity');return}pending.push(data);maybeStartCandidate()}
else queueCarrier(data);
}
}else if(message.data&&message.data.t==='close'){status('failed','terminal','closed',0);close(true)}
};
port.start();status('connecting','starting','',bridgeRequestMs);helloTimer=setTimeout(()=>fail('timeout'),bridgeRequestMs);
}
recoveryController=recoverySupport.create({
budgetMs:()=>bridgeRecoveryMs,requestMs:()=>bridgeRequestMs,url:()=>relayOrigin+recoveryPath,token:()=>cleanupToken||sessionToken,
read:(response,limit,exact,signal)=>responseBody.read(response,limit,exact,signal),cancel:responseBody.cancel,status:remaining=>status('reconnecting','retrying','',remaining),
restored:finishOldRecovery,replace:replaceCarrier,replaceable:error=>failureReason(error,'network')!=='protocol',
reason:(error,fallback)=>failureReason(error,fallback),terminal:reason=>fail(recoveryController.remaining()<=0?'timeout':reason)
});
addEventListener('message',event=>{
if(event.source!==parent)return;if(initialized){if(event.ports&&event.ports.length===1)event.ports[0].close();return}
if(event.data===null||typeof event.data!=='object')return;
@@ -535,8 +503,7 @@ addEventListener('message',event=>{
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'){
function activateAndroid(androidBridge){
const androidPort={onmessage:null,start(){},close(){androidBridge.onmessage=null},postMessage(value){
if(value instanceof ArrayBuffer){
let frames;try{frames=splitFrames(value)}catch(error){fail('protocol');return}
@@ -546,5 +513,16 @@ if(!initialized&&androidNonce&&androidBridge&&typeof androidBridge.postMessage==
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}));
}
function discoverAndroid(){
if(!androidNonce)return;const wall=Date.now()+bridgeRequestMs,monotonic=performance.now()+bridgeRequestMs;
const probe=()=>{
if(initialized||closed)return;const androidBridge=globalThis.TelegramWebProxy;
if(androidBridge&&typeof androidBridge.postMessage==='function'){activateAndroid(androidBridge);return}
const remaining=Math.min(wall-Date.now(),monotonic-performance.now());if(remaining>0)setTimeout(probe,Math.min(100,remaining));
};probe();
}
discoverAndroid();
addEventListener('online',observeResumeTrigger);
if(globalThis.document&&typeof globalThis.document.addEventListener==='function')globalThis.document.addEventListener('visibilitychange',()=>{if(globalThis.document.visibilityState==='visible')observeResumeTrigger()});
addEventListener('pagehide',()=>fail('navigation'),{once:true});
})();
+17 -2
View File
@@ -7,12 +7,16 @@ fn render_page(bootstrap: &str, candidate_count: usize) -> BridgePage {
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
1024,
true,
candidate_count,
[3, 5, 8, 12],
25,
10,
90,
15,
15,
120,
0,
&SecureRandom::new(),
)
@@ -47,7 +51,7 @@ fn rendered_page_preserves_the_ios_bootstrap_literal() {
let page = render_page(bootstrap, 2);
assert!(
page.body
.contains(&format!("const bootstrap=\"{bootstrap}\""))
.contains(&format!("let bootstrap=\"{bootstrap}\""))
);
}
@@ -59,12 +63,16 @@ fn rendered_page_embeds_the_configured_bridge_timing_policy() {
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
1024,
true,
4,
[3, 5, 8, 12],
17,
7,
41,
13,
11,
119,
4,
&SecureRandom::new(),
);
@@ -72,6 +80,9 @@ fn rendered_page_embeds_the_configured_bridge_timing_policy() {
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("bridgeRecoveryMs=13*1000"));
assert!(page.body.contains("websocketOpenMs=11*1000"));
assert!(page.body.contains("reconnectGraceMs=119*1000"));
assert!(page.body.contains("const probeCoalesceMs=4"));
assert!(page.body.contains(
"helloTimer=setTimeout(()=>fail('timeout'),bridgeRequestMs)"
@@ -99,12 +110,16 @@ fn disabled_negotiation_does_not_arm_a_carrier_deadline() {
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
1024,
false,
1,
[3, 5, 8, 12],
25,
10,
90,
15,
15,
120,
0,
&SecureRandom::new(),
);
@@ -122,7 +137,7 @@ fn retry_and_attempt_state_are_frozen_before_fetch() {
let page = render_page("EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE", 4);
assert!(
page.body
.contains("async function request(path,frozenOptions)")
.contains("async function request(path,frozenOptions,remainingBudget,maxAttempts)")
);
assert!(!page.body.contains("makeOptions"));
assert!(
+59 -6
View File
@@ -31,6 +31,8 @@ mod decoy;
mod down;
// Canonical request parsing rejects ambiguous credentials before routing.
mod request;
// Positive-only recovery representation stays separate from ordinary bridge rendering.
mod recovery;
// Carrier response construction and lane-header helpers are shared by handlers.
mod response;
// Session creation and replacement negotiation remain separate from request routing.
@@ -219,6 +221,11 @@ async fn handle_root(
generation: Arc<RuntimeGeneration>,
vhost: Arc<WebRuntimeVhost>,
) -> HttpResponse {
let representation = recovery::classify(&request);
if matches!(representation, recovery::RootRepresentation::Invalid) {
strip_query(&mut request);
return serve_decoy(request, vhost, true, &runtime).await;
}
let (candidate, canonical) = bridge_candidate(request.uri().query());
let profile = match_profile(&vhost, &candidate);
let Some(profile) = profile.filter(|_| canonical && request.method() == Method::GET) else {
@@ -236,12 +243,25 @@ async fn handle_root(
.headers()
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok());
let bootstrap = match runtime.issue_bootstrap_for_request(
&generation,
Arc::clone(&profile),
client_ip,
user_agent,
) {
let bootstrap = match match representation {
recovery::RootRepresentation::Bridge => runtime.issue_bootstrap_for_request(
&generation,
Arc::clone(&profile),
client_ip,
user_agent,
),
recovery::RootRepresentation::Recovery(_) => runtime
.issue_recovery_bootstrap_for_request(
&generation,
Arc::clone(&profile),
client_ip,
user_agent,
),
recovery::RootRepresentation::Invalid => {
strip_query(&mut request);
return serve_decoy(request, vhost, true, &runtime).await;
}
} {
Ok(bootstrap) => bootstrap,
Err(error) => {
runtime.trace().record_profile_lifecycle(
@@ -261,18 +281,51 @@ async fn handle_root(
trace.register_redaction(bootstrap.token.as_bytes());
}
let config = generation.config();
if let recovery::RootRepresentation::Recovery(previous_hash) = representation {
if let Some(session) = previous_hash.and_then(|hash| {
runtime.bridge_recovery_session(hash, &vhost.host, &profile)
}) {
let outcome = session.close(crate::web::session::SessionCloseReason::BridgeRecovery);
if outcome != crate::web::session::SessionCloseOutcome::Closed
&& tokio::time::timeout(
Duration::from_secs(config.web.timeouts.bridge_request_secs),
session.wait_close_complete(),
)
.await
.is_err()
{
strip_query(&mut request);
return serve_decoy(request, vhost, true, &runtime).await;
}
}
let Some(response) = recovery::response(
&bootstrap,
&vhost,
&profile,
&config.web.limits,
&config.web.timeouts,
) else {
strip_query(&mut request);
return serve_decoy(request, vhost, true, &runtime).await;
};
return response;
}
let page = bridge::render(
&vhost.host,
&bootstrap.token,
config.web.limits.carrier_batch_bytes,
config.web.limits.pending_bytes_per_session,
config.web.limits.pending_items_per_session,
profile.max_streams_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.bridge_recovery_secs,
config.web.timeouts.websocket_open_secs,
config.web.timeouts.reconnect_grace_secs,
config.web.timeouts.carrier_probe_coalesce_ms,
&generation.rng,
);
+1
View File
@@ -297,6 +297,7 @@ fn lease_deadline(
fn sanitize_transport_request<B>(request: &mut Request<B>) {
for name in [
header::AUTHORIZATION,
header::ACCEPT,
header::CONTENT_LENGTH,
header::CONTENT_TYPE,
header::UPGRADE,
+141
View File
@@ -0,0 +1,141 @@
use bytes::Bytes;
use hyper::header::{self, HeaderValue};
use hyper::{Request, StatusCode};
use serde::Serialize;
use super::request::{bearer_token_hash, compatible_cookie_header};
use super::response::{full_response, insert_header};
use super::{HttpResponse, RequestBody};
use crate::config::{WebRuntimeProfile, WebRuntimeVhost};
use crate::web::manager::{BootstrapResult, TokenHash};
pub(super) const MEDIA_TYPE: &str = "application/vnd.telemt.web-recovery+json";
const MAX_RESPONSE_BYTES: usize = 1024;
/// Positive representation selected by exact recovery headers.
#[derive(Clone, Copy)]
pub(super) enum RootRepresentation {
/// Render the ordinary transient bridge document.
Bridge,
/// Return fresh recovery policy and optionally retire one current bearer.
Recovery(Option<TokenHash>),
/// Hide malformed recovery material behind the configured decoy.
Invalid,
}
/// Classifies recovery headers without changing capability authentication.
pub(super) fn classify(request: &Request<RequestBody>) -> RootRepresentation {
let accepts = request.headers().get_all(header::ACCEPT);
let mut values = accepts.iter();
let first = values.next();
let exact = first.is_some_and(|value| value.as_bytes() == MEDIA_TYPE.as_bytes())
&& values.next().is_none();
let recovery_present = accepts
.iter()
.any(|value| value.as_bytes() == MEDIA_TYPE.as_bytes());
let authorization_present = request.headers().contains_key(header::AUTHORIZATION);
if !exact {
return if authorization_present || recovery_present {
RootRepresentation::Invalid
} else {
RootRepresentation::Bridge
};
}
if !compatible_cookie_header(request) {
return RootRepresentation::Invalid;
}
if !authorization_present {
return RootRepresentation::Recovery(None);
}
bearer_token_hash(request)
.map(|hash| RootRepresentation::Recovery(Some(hash)))
.unwrap_or(RootRepresentation::Invalid)
}
/// Builds the bounded no-store recovery representation.
pub(super) fn response(
bootstrap: &BootstrapResult,
vhost: &WebRuntimeVhost,
profile: &WebRuntimeProfile,
limits: &crate::config::WebLimitsConfig,
timeouts: &crate::config::WebTimeoutsConfig,
) -> Option<HttpResponse> {
let document = RecoveryDocument {
version: 1,
bootstrap: &bootstrap.token,
limits: RecoveryLimits {
carrier_batch_bytes: limits.carrier_batch_bytes,
pending_bytes_per_session: limits.pending_bytes_per_session,
pending_items_per_session: limits.pending_items_per_session,
max_streams_per_session: profile.max_streams_per_session,
},
timeouts: RecoveryTimeouts {
long_poll_secs: timeouts.long_poll_secs,
bridge_request_secs: timeouts.bridge_request_secs,
bridge_retry_secs: timeouts.bridge_retry_secs,
bridge_recovery_secs: timeouts.bridge_recovery_secs,
websocket_open_secs: timeouts.websocket_open_secs,
reconnect_grace_secs: timeouts.reconnect_grace_secs,
},
negotiation: RecoveryNegotiation {
enabled: profile.carrier_negotiation_enabled,
candidate_count: profile.carriers.len(),
deadlines_secs: profile.carrier_negotiation_deadlines_secs,
carrier_probe_coalesce_ms: timeouts.carrier_probe_coalesce_ms,
},
};
let body = serde_json::to_vec(&document).ok()?;
if body.len() > MAX_RESPONSE_BYTES || profile.host != vhost.host {
return None;
}
let mut response = full_response(StatusCode::OK, Bytes::from(body));
insert_header(&mut response, header::CONTENT_TYPE, MEDIA_TYPE);
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response.headers_mut().insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
response.headers_mut().insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
Some(response)
}
#[derive(Serialize)]
struct RecoveryDocument<'a> {
#[serde(rename = "v")]
version: u8,
bootstrap: &'a str,
limits: RecoveryLimits,
timeouts: RecoveryTimeouts,
negotiation: RecoveryNegotiation,
}
#[derive(Serialize)]
struct RecoveryLimits {
carrier_batch_bytes: usize,
pending_bytes_per_session: usize,
pending_items_per_session: usize,
max_streams_per_session: usize,
}
#[derive(Serialize)]
struct RecoveryTimeouts {
long_poll_secs: u64,
bridge_request_secs: u64,
bridge_retry_secs: u64,
bridge_recovery_secs: u64,
websocket_open_secs: u64,
reconnect_grace_secs: u64,
}
#[derive(Serialize)]
struct RecoveryNegotiation {
enabled: bool,
candidate_count: usize,
deadlines_secs: [u64; 4],
carrier_probe_coalesce_ms: u64,
}
+5 -3
View File
@@ -10,7 +10,9 @@ use tokio_util::sync::CancellationToken;
use super::ConnectionIo;
use crate::web::http::activity::UpgradeDeadlineLease;
use crate::web::manager::{WebProcessRuntime, WebSocketBudgetLease, WebSocketConnection};
use crate::web::session::{WebSession, WebSocketLaneReservation, WebSocketProbeReservation};
use crate::web::session::{
SessionCloseReason, WebSession, WebSocketLaneReservation, WebSocketProbeReservation,
};
use crate::web::trace::{TraceDirection, TraceWebSocketContext};
const READ_BUFFER_BYTES: usize = 64 * 1024;
@@ -103,7 +105,7 @@ pub(super) async fn run_upgraded(
if let Some(reservation) = lane_reservation {
session.close_websocket_lane(reservation);
} else if !acknowledge_commit || session.is_carrier_committed() {
session.close();
session.close(SessionCloseReason::WebSocketEnded);
}
}
@@ -194,7 +196,7 @@ async fn run_multiplex(
started,
);
if !session.websocket_commit_ack_written(connection.id()) {
session.close();
session.close(SessionCloseReason::Protocol);
return Err(());
}
} else if acknowledge_commit
+3 -3
View File
@@ -8,7 +8,7 @@ use tokio_util::sync::CancellationToken;
use super::CarrierSocket;
use super::io::{flush, process_lane, read_message, record_message, reserve_data, send};
use crate::web::manager::{WebProcessRuntime, WebSocketBudgetLease, WebSocketConnection};
use crate::web::session::{WebSession, WebSocketLaneReservation};
use crate::web::session::{SessionCloseReason, WebSession, WebSocketLaneReservation};
use crate::web::trace::{TraceDirection, TraceWebSocketContext};
#[allow(clippy::too_many_arguments)]
@@ -92,7 +92,7 @@ pub(super) async fn run_lane(
.await
.is_err()
{
session.close();
session.close(SessionCloseReason::Protocol);
return Err(());
}
record_message(
@@ -104,7 +104,7 @@ pub(super) async fn run_lane(
started,
);
if !session.websocket_commit_ack_written(connection.id()) {
session.close();
session.close(SessionCloseReason::Protocol);
return Err(());
}
} else if acknowledge_commit
+10 -3
View File
@@ -99,7 +99,7 @@ impl WebProcessRuntime {
identity: TraceIdentity,
) -> bool {
let mut state = self.state.lock();
let scores = state.bootstraps.get_mut(&bootstrap_hash).and_then(|entry| {
let outcome = state.bootstraps.get_mut(&bootstrap_hash).and_then(|entry| {
if entry.carrier_attempt == attempt
&& entry
.session
@@ -108,13 +108,20 @@ impl WebProcessRuntime {
&& entry.carrier_phase == CarrierChainPhase::Provisional
{
entry.carrier_phase = CarrierChainPhase::CommittedPendingHealth;
Some(entry.carrier_scores)
Some((entry.carrier_scores, entry.recovery))
} else {
None
}
});
drop(state);
let Some(scores) = scores else { return false };
let Some((scores, recovery)) = outcome else {
return false;
};
if recovery {
self.telemetry.record_bridge_recovery(
crate::web::telemetry::WebBridgeRecoveryEvent::Committed,
);
}
self.trace.record_carrier_lifecycle(
client_ip,
identity.clone(),
+3 -2
View File
@@ -7,6 +7,7 @@ use serde::Serialize;
use super::status::immutable_matches;
use super::{SessionFilter, WebProcessRuntime};
use crate::web::session::SessionCloseReason;
const OPERATION_REF_VERSION: &str = "wo1";
const OPERATION_RETENTION: usize = 32;
@@ -297,7 +298,7 @@ impl WebProcessRuntime {
status.matched = status.matched.saturating_add(direct.len());
});
for candidate in direct {
candidate.session.close();
candidate.session.close(SessionCloseReason::ApiClose);
self.update_operation(sequence, |status| {
status.close_signalled = status.close_signalled.saturating_add(1)
});
@@ -325,7 +326,7 @@ impl WebProcessRuntime {
current
};
if let Some(session) = session {
session.close();
session.close(SessionCloseReason::ApiClose);
self.update_operation(sequence, |status| {
status.matched = status.matched.saturating_add(1);
status.close_signalled = status.close_signalled.saturating_add(1);
+47 -8
View File
@@ -7,13 +7,13 @@ use zeroize::Zeroizing;
use super::state::{
Bootstrap, CarrierChainPhase, allow_rate, evict_oldest_unused_bootstrap, matching_profile,
new_unique_token, remove_expired_locked,
new_unique_token, profile_key, remove_expired_locked,
};
use super::{BootstrapResult, ManagerError, TOKEN_BYTES, TokenHash, WebProcessRuntime};
use crate::config::WebRuntimeProfile;
use crate::maestro::generation::RuntimeGeneration;
use crate::web::session::WebSession;
use crate::web::telemetry::WebRejectionReason;
use crate::web::session::{SessionCloseReason, WebSession};
use crate::web::telemetry::{WebBridgeRecoveryEvent, WebRejectionReason};
impl WebProcessRuntime {
/// Issues a one-use bootstrap credential for an active compatible profile.
@@ -24,7 +24,7 @@ impl WebProcessRuntime {
client_ip: IpAddr,
) -> std::result::Result<BootstrapResult, ManagerError> {
let generation = self.active_generation();
self.issue_bootstrap_inner(&generation, profile, client_ip, None)
self.issue_bootstrap_inner(&generation, profile, client_ip, None, false)
}
/// Issues one bootstrap against the generation that selected the bridge profile.
@@ -35,7 +35,7 @@ impl WebProcessRuntime {
profile: Arc<WebRuntimeProfile>,
client_ip: IpAddr,
) -> std::result::Result<BootstrapResult, ManagerError> {
self.issue_bootstrap_inner(generation, profile, client_ip, None)
self.issue_bootstrap_inner(generation, profile, client_ip, None, false)
}
/// Issues one bridge bootstrap with bounded non-secret request metadata.
@@ -46,7 +46,18 @@ impl WebProcessRuntime {
client_ip: IpAddr,
user_agent: Option<&str>,
) -> std::result::Result<BootstrapResult, ManagerError> {
self.issue_bootstrap_inner(generation, profile, client_ip, user_agent)
self.issue_bootstrap_inner(generation, profile, client_ip, user_agent, false)
}
/// Issues one recovery bootstrap with the same positive-only admission boundary.
pub(crate) fn issue_recovery_bootstrap_for_request(
&self,
generation: &Arc<RuntimeGeneration>,
profile: Arc<WebRuntimeProfile>,
client_ip: IpAddr,
user_agent: Option<&str>,
) -> std::result::Result<BootstrapResult, ManagerError> {
self.issue_bootstrap_inner(generation, profile, client_ip, user_agent, true)
}
fn issue_bootstrap_inner(
@@ -55,6 +66,7 @@ impl WebProcessRuntime {
profile: Arc<WebRuntimeProfile>,
client_ip: IpAddr,
user_agent: Option<&str>,
recovery: bool,
) -> std::result::Result<BootstrapResult, ManagerError> {
let config = generation.config();
let profile = config
@@ -76,7 +88,11 @@ impl WebProcessRuntime {
let _operator_admission = self.try_operator_admission()?;
let now = Instant::now();
let mut state = self.state.lock();
remove_expired_locked(&mut state, now);
let expired_recoveries = remove_expired_locked(&mut state, now);
self.telemetry.record_bridge_recovery_count(
WebBridgeRecoveryEvent::ExpiredUnused,
expired_recoveries,
);
state.apply_issuance_policy(generation.id, config.web.enabled);
if state.closed || !state.issuance_enabled {
self.record_limit_hit();
@@ -153,6 +169,7 @@ impl WebProcessRuntime {
session_client_ip: None,
session_ip_learning_eligible: false,
used: false,
recovery,
},
);
*state.bootstraps_per_ip.entry(client_ip).or_insert(0) += 1;
@@ -162,6 +179,10 @@ impl WebProcessRuntime {
.map(|entry| Arc::clone(&entry.profile))
.ok_or(ManagerError::Closed)?;
drop(state);
if recovery {
self.telemetry
.record_bridge_recovery(WebBridgeRecoveryEvent::BootstrapIssued);
}
self.trace.record_profile_lifecycle(
client_ip,
Some(trace_session_id),
@@ -215,6 +236,24 @@ impl WebProcessRuntime {
.ok_or(ManagerError::Authentication)
}
/// Resolves a current bearer only when it belongs to the recovering profile.
pub(crate) fn bridge_recovery_session(
&self,
hash: TokenHash,
host: &str,
profile: &WebRuntimeProfile,
) -> Option<Arc<WebSession>> {
let expected_profile = profile_key(profile);
self.state
.lock()
.sessions
.get(&hash)
.filter(|session| {
session.matches_host(host) && session.profile_key() == expected_profile
})
.cloned()
}
/// Closes a live token and accepts bounded tombstone retries.
pub(crate) fn close_token(
&self,
@@ -256,7 +295,7 @@ impl WebProcessRuntime {
.is_some_and(|closed| closed.host == host);
drop(state);
if let Some(session) = session {
if session.close()
if session.close(SessionCloseReason::ClientDelete).accepted()
&& let (Some(failure), Some(phase)) = (failure, failure_phase)
{
self.telemetry
+29 -4
View File
@@ -11,6 +11,7 @@ use super::state::{
};
use super::{ProfileKey, TokenHash, WebProcessRuntime};
use crate::maestro::generation::RuntimeGeneration;
use crate::web::session::SessionCloseReason;
/// Result of draining all process-owned WEB work under one absolute deadline.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -61,17 +62,29 @@ impl WebProcessRuntime {
profile_key: ProfileKey,
profile_host: &str,
closed_token_lifetime: Duration,
reason: SessionCloseReason,
) {
let mut state = self.state.lock();
let Some(session) = state.sessions.remove(&hash) else {
return;
};
let recovery_closed_before_commit = state.bootstraps.values().any(|bootstrap| {
bootstrap.recovery
&& bootstrap
.session
.as_ref()
.is_some_and(|current| current.token_hash() == hash)
&& !session.is_carrier_committed()
});
decrement_map(&mut state.sessions_per_ip, &client_ip);
decrement_map(&mut state.sessions_per_profile, &profile_key);
remember_closed_token_locked(
&mut state,
hash,
profile_host,
session.trace_session_id(),
session.carrier(),
reason,
closed_token_lifetime,
self.limits.max_sessions_global.saturating_mul(16),
);
@@ -86,6 +99,8 @@ impl WebProcessRuntime {
&mut state,
trace_session_id,
session.carrier_attempt(),
session.carrier(),
reason,
closed_token_lifetime,
self.limits.max_sessions_global,
);
@@ -104,7 +119,13 @@ impl WebProcessRuntime {
for bootstrap_hash in bootstrap_hashes {
remove_bootstrap_locked(&mut state, bootstrap_hash);
}
self.telemetry.record_session_closed();
self.telemetry
.record_session_closed(session.carrier(), reason);
if recovery_closed_before_commit {
self.telemetry.record_bridge_recovery(
crate::web::telemetry::WebBridgeRecoveryEvent::ClosedBeforeCommit,
);
}
drop(state);
self.notify_operator_work_changed();
}
@@ -135,7 +156,7 @@ impl WebProcessRuntime {
};
self.stream_admission.lock().closed = true;
for session in &sessions {
session.close();
session.close(SessionCloseReason::RuntimeShutdown);
}
self.tasks.close();
WebShutdownDrain {
@@ -215,14 +236,18 @@ impl WebProcessRuntime {
for (hash, _) in expired {
remove_bootstrap_locked(&mut state, hash);
}
remove_expired_locked(&mut state, now);
let expired_recoveries = remove_expired_locked(&mut state, now);
self.telemetry.record_bridge_recovery_count(
crate::web::telemetry::WebBridgeRecoveryEvent::ExpiredUnused,
expired_recoveries,
);
(
state.sessions.values().cloned().collect::<Vec<_>>(),
expired_chains,
)
};
for session in expired_chains {
session.close();
session.close(SessionCloseReason::NegotiationTimeout);
}
for session in sessions {
session.close_if_due(now);
+2 -1
View File
@@ -9,6 +9,7 @@ use tokio::time::Instant as TokioInstant;
use tokio_util::sync::CancellationToken;
use super::WebProcessRuntime;
use crate::web::session::SessionCloseReason;
// Serialized state-machine values stay separate from synchronization mechanics.
mod status;
@@ -447,7 +448,7 @@ impl WebProcessRuntime {
}
forced = true;
for session in sessions {
session.close();
session.close(SessionCloseReason::OperatorForce);
}
}
}
+14 -4
View File
@@ -18,7 +18,7 @@ use super::{
};
use crate::config::{WebCarrier, WebRuntimeProfile};
use crate::web::frame;
use crate::web::session::WebSession;
use crate::web::session::{SessionCloseReason, WebSession};
use crate::web::telemetry::WebCarrierSelectionDisposition;
use crate::web::trace::TraceLifecycleEvent;
@@ -56,7 +56,11 @@ impl WebProcessRuntime {
let config = generation.config();
let now = Instant::now();
let mut state = self.state.lock();
remove_expired_locked(&mut state, now);
let expired_recoveries = remove_expired_locked(&mut state, now);
self.telemetry.record_bridge_recovery_count(
crate::web::telemetry::WebBridgeRecoveryEvent::ExpiredUnused,
expired_recoveries,
);
state.apply_issuance_policy(generation.id, config.web.enabled);
if state.closed || !state.issuance_enabled {
return Err(ManagerError::Closed);
@@ -79,7 +83,7 @@ impl WebProcessRuntime {
let session = entry.session.clone();
drop(state);
if let Some(session) = session {
session.close();
session.close(SessionCloseReason::NegotiationTimeout);
}
return Err(ManagerError::Closed);
}
@@ -335,7 +339,7 @@ impl WebProcessRuntime {
state.sessions.insert(session_hash, Arc::clone(&session));
*state.sessions_per_ip.entry(client_ip).or_insert(0) += 1;
*state.sessions_per_profile.entry(profile_key).or_insert(0) += 1;
let (issuance_ip, candidate_count, user_agent, user_agent_id) = {
let (issuance_ip, candidate_count, user_agent, user_agent_id, recovery) = {
let entry = state
.bootstraps
.get_mut(&bootstrap_hash)
@@ -362,10 +366,16 @@ impl WebProcessRuntime {
u8::try_from(entry.carrier_candidates.len()).unwrap_or(4),
entry.user_agent.clone(),
entry.user_agent_id,
entry.recovery,
)
};
decrement_map(&mut state.bootstraps_per_ip, &issuance_ip);
self.telemetry.record_session_created();
if recovery {
self.telemetry.record_bridge_recovery(
crate::web::telemetry::WebBridgeRecoveryEvent::SessionCreated,
);
}
let identity = session.trace_identity();
let result = CreateResult {
token: session_token,
@@ -20,7 +20,11 @@ impl WebProcessRuntime {
let config = generation.config();
let now = Instant::now();
let mut state = self.state.lock();
remove_expired_locked(&mut state, now);
let expired_recoveries = remove_expired_locked(&mut state, now);
self.telemetry.record_bridge_recovery_count(
crate::web::telemetry::WebBridgeRecoveryEvent::ExpiredUnused,
expired_recoveries,
);
state.apply_issuance_policy(generation.id, config.web.enabled);
let valid = state.bootstraps.get(&bootstrap_hash).is_some_and(|entry| {
entry.carrier_transitioning
@@ -85,7 +89,7 @@ impl WebProcessRuntime {
let Some(supersede) = replacement.old_session.prepare_carrier_supersede() else {
drop(state);
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
session.close();
session.close(crate::web::session::SessionCloseReason::Protocol);
return Err(ManagerError::Closed);
};
let old_hash = replacement.old_session.token_hash();
@@ -94,6 +98,9 @@ impl WebProcessRuntime {
&mut state,
old_hash,
&replacement.profile.host,
replacement.old_session.trace_session_id(),
replacement.old_session.carrier(),
crate::web::session::SessionCloseReason::CarrierSuperseded,
Duration::from_secs(replacement.old_session.timeouts().bootstrap_lifetime_secs),
self.limits.max_sessions_global.saturating_mul(16),
);
@@ -115,7 +122,15 @@ impl WebProcessRuntime {
*slot = Some(replacement.old_session.carrier());
}
self.telemetry.record_session_created();
self.telemetry.record_session_closed();
if entry.recovery {
self.telemetry.record_bridge_recovery(
crate::web::telemetry::WebBridgeRecoveryEvent::SessionCreated,
);
}
self.telemetry.record_session_closed(
replacement.old_session.carrier(),
crate::web::session::SessionCloseReason::CarrierSuperseded,
);
let result = CreateResult {
token: session_token,
carrier: replacement.carrier,
+35 -3
View File
@@ -86,6 +86,8 @@ pub(super) struct Bootstrap {
pub(super) session_ip_learning_eligible: bool,
/// Distinguishes unused issuance quota from completed creation replay state.
pub(super) used: bool,
/// Whether this credential was issued by the post-commit recovery representation.
pub(super) recovery: bool,
}
/// Bounded replay marker for one explicitly or naturally closed session token.
@@ -94,6 +96,12 @@ pub(super) struct ClosedToken {
pub(super) expires_at: Instant,
/// Canonical host that owned the session.
pub(super) host: String,
/// Non-secret logical trace owner retained for exact late-request diagnostics.
pub(super) trace_session_id: u64,
/// Carrier that owned the retired bearer.
pub(super) carrier: WebCarrier,
/// First-writer terminal cause for the retired bearer.
pub(super) reason: crate::web::session::SessionCloseReason,
}
/// Current logical-session owner stored without exposing bearer credentials.
@@ -116,6 +124,12 @@ pub(super) struct ClosedSession {
pub(super) expires_at: Instant,
/// Last carrier incarnation closed for this logical session.
pub(super) attempt: u8,
/// Last carrier owning this logical session.
pub(super) carrier: WebCarrier,
/// First-writer terminal close cause.
pub(super) reason: crate::web::session::SessionCloseReason,
/// Monotonic instant used only to report bounded close age.
pub(super) closed_at: Instant,
}
/// Token-bucket state for one process-wide creation class.
@@ -303,13 +317,19 @@ pub(super) fn evict_oldest_unused_bootstrap(state: &mut ManagerState) -> bool {
}
/// Removes expired bootstrap and closed-token entries while the manager lock is held.
pub(super) fn remove_expired_locked(state: &mut ManagerState, now: Instant) {
pub(super) fn remove_expired_locked(state: &mut ManagerState, now: Instant) -> usize {
let expired = state
.bootstraps
.iter()
.filter_map(|(hash, bootstrap)| (now > bootstrap.expires_at).then_some(*hash))
.filter_map(|(hash, bootstrap)| {
(now > bootstrap.expires_at).then_some((*hash, bootstrap.recovery && !bootstrap.used))
})
.collect::<Vec<_>>();
for hash in expired {
let recovery_unused = expired
.iter()
.filter(|(_, recovery_unused)| *recovery_unused)
.count();
for (hash, _) in expired {
remove_bootstrap_locked(state, hash);
}
state
@@ -325,6 +345,7 @@ pub(super) fn remove_expired_locked(state: &mut ManagerState, now: Instant) {
state.closed_sessions.remove(&trace_session_id);
}
}
recovery_unused
}
/// Removes one bootstrap and releases its per-address issuance quota when unused.
@@ -342,6 +363,9 @@ pub(super) fn remember_closed_token_locked(
state: &mut ManagerState,
hash: TokenHash,
host: &str,
trace_session_id: u64,
carrier: WebCarrier,
reason: crate::web::session::SessionCloseReason,
lifetime: Duration,
capacity: usize,
) {
@@ -350,6 +374,9 @@ pub(super) fn remember_closed_token_locked(
ClosedToken {
expires_at: Instant::now() + lifetime,
host: host.to_string(),
trace_session_id,
carrier,
reason,
},
);
while state.closed_tokens.len() > capacity {
@@ -370,6 +397,8 @@ pub(super) fn remember_closed_session_locked(
state: &mut ManagerState,
trace_session_id: u64,
attempt: u8,
carrier: WebCarrier,
reason: crate::web::session::SessionCloseReason,
lifetime: Duration,
capacity: usize,
) {
@@ -380,6 +409,9 @@ pub(super) fn remember_closed_session_locked(
ClosedSession {
expires_at: Instant::now() + lifetime,
attempt,
carrier,
reason,
closed_at: Instant::now(),
},
)
.is_none()
+24 -8
View File
@@ -198,7 +198,16 @@ pub(crate) enum SessionDetail {
/// One exact live-session snapshot.
Active(Box<SessionRow>),
/// One bounded retained closed-session tombstone.
Gone { attempt: u8 },
Gone {
/// Last carrier incarnation number.
attempt: u8,
/// Carrier that owned the final incarnation.
carrier: WebCarrier,
/// First-writer terminal close cause.
reason: &'static str,
/// Monotonic age since final closure.
closed_age_ms: u64,
},
/// A required short lock was contended.
Busy,
/// Neither a live session nor a retained tombstone exists.
@@ -500,13 +509,16 @@ impl WebProcessRuntime {
.map(|status| SessionDetail::Active(Box::new(self.row(candidate, status))))
.unwrap_or(SessionDetail::Busy);
}
let closed = state
.closed_sessions
.get(&trace_session_id)
.map(|closed| closed.attempt);
closed.map_or(SessionDetail::NotFound, |attempt| SessionDetail::Gone {
attempt,
})
let now = Instant::now();
state.closed_sessions.get(&trace_session_id).map_or(
SessionDetail::NotFound,
|closed| SessionDetail::Gone {
attempt: closed.attempt,
carrier: closed.carrier,
reason: closed.reason.as_str(),
closed_age_ms: millis(now.saturating_duration_since(closed.closed_at)),
},
)
}
fn row(&self, candidate: Candidate, status: WebSessionStatus) -> SessionRow {
@@ -524,3 +536,7 @@ mod reference;
pub(crate) use reference::SessionRefError;
pub(super) use reference::immutable_matches;
use reference::permits;
fn millis(duration: std::time::Duration) -> u64 {
duration.as_millis().min(u128::from(u64::MAX)) as u64
}
+17 -15
View File
@@ -20,6 +20,9 @@ use crate::web::manager::{
// Backend tasks own generation admission and authenticated MTProxy relay lifetimes.
mod backend;
// Activity clocks separate authenticated peer leases from diagnostic progress.
mod activity;
use activity::SessionActivity;
// Downlink queues own cursor replay, flow control, and memory reservations.
mod downlink;
// Response ownership keeps detached batches charged until the last body clone drops.
@@ -42,6 +45,8 @@ mod negotiation;
use negotiation::CarrierHealthPublicationState;
// Session closure and carrier-attempt transitions share one cancellation boundary.
mod lifecycle;
pub(crate) use lifecycle::{SessionCloseOutcome, SessionCloseReason};
use lifecycle::SessionNegotiationPhase;
// Uplink batches own exactly-once sequencing and client-frame validation.
mod uplink;
@@ -169,7 +174,7 @@ struct SessionState {
pending_items: usize,
pending_control_bytes: usize,
pending_control_items: usize,
last_activity: Instant,
activity: SessionActivity,
negotiation_phase: SessionNegotiationPhase,
carrier_health_due_at: Option<Instant>,
carrier_health_activity_at: Option<Instant>,
@@ -181,18 +186,10 @@ struct SessionState {
websocket_commit_ack_owner: Option<u64>,
websocket_commit_ack_written: bool,
websocket_probe_claimed: bool,
close_requested: bool,
close_requested: Option<SessionCloseReason>,
closed: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SessionNegotiationPhase {
Uncommitted,
Replacing,
Committed,
Superseded,
}
/// One bounded WEB carrier session containing logical MTProxy streams.
pub(crate) struct WebSession {
manager: std::sync::Weak<WebProcessRuntime>,
@@ -213,6 +210,8 @@ pub(crate) struct WebSession {
timeouts: WebTimeoutsConfig,
state: Mutex<SessionState>,
carrier_health_publication: AtomicU8,
close_complete: AtomicBool,
close_notify: Notify,
down_notify: Arc<Notify>,
lane_open_notify: Arc<Notify>,
cancel: CancellationToken,
@@ -253,6 +252,7 @@ impl WebSession {
limits: WebLimitsConfig,
timeouts: WebTimeoutsConfig,
) -> Arc<Self> {
let created_at = Instant::now();
let mut carrier_lanes = HashMap::new();
let mut next_lane_instance = 1;
if selected_carrier == WebCarrier::HttpsLanes {
@@ -273,7 +273,7 @@ impl WebSession {
carrier_class,
learning_context,
automatic_carrier,
created_at: Instant::now(),
created_at,
limits,
timeouts,
state: Mutex::new(SessionState {
@@ -298,7 +298,7 @@ impl WebSession {
pending_items: 0,
pending_control_bytes: 0,
pending_control_items: 0,
last_activity: Instant::now(),
activity: SessionActivity::new(created_at),
negotiation_phase: SessionNegotiationPhase::Uncommitted,
carrier_health_due_at: None,
carrier_health_activity_at: None,
@@ -310,12 +310,14 @@ impl WebSession {
websocket_commit_ack_owner: None,
websocket_commit_ack_written: false,
websocket_probe_claimed: false,
close_requested: false,
close_requested: None,
closed: false,
}),
carrier_health_publication: AtomicU8::new(
CarrierHealthPublicationState::Awaiting as u8,
),
close_complete: AtomicBool::new(false),
close_notify: Notify::new(),
down_notify: Arc::new(Notify::new()),
lane_open_notify: Arc::new(Notify::new()),
cancel: CancellationToken::new(),
@@ -431,7 +433,7 @@ impl WebSession {
self.release_locked(&mut state, count + overhead, usize::from(finished), false);
if !self.queue_window_locked(&mut state, stream.id, count as u32) {
drop(state);
self.close();
self.close(SessionCloseReason::Backpressure);
return Poll::Ready(Err(io::Error::other(
"WEB session control budget exhausted",
)));
@@ -497,7 +499,7 @@ impl WebSession {
)));
};
stream_state.send_credit -= count as u64;
state.last_activity = Instant::now();
state.activity.touch_progress(Instant::now());
drop(state);
if self.carrier().is_multiplexed() {
self.down_notify.notify_waiters();
+40
View File
@@ -0,0 +1,40 @@
use std::time::{Duration, Instant};
/// Session-local activity clocks with distinct lease and diagnostic authority.
pub(super) struct SessionActivity {
last_peer: Instant,
last_progress: Instant,
}
impl SessionActivity {
/// Starts both activity clocks at the same session creation instant.
pub(super) fn new(now: Instant) -> Self {
Self {
last_peer: now,
last_progress: now,
}
}
/// Records one validated peer operation and returns the preceding peer gap.
pub(super) fn touch_peer(&mut self, now: Instant) -> Duration {
let gap = now.saturating_duration_since(self.last_peer);
self.last_peer = now;
self.last_progress = now;
gap
}
/// Records server-side carrier progress without extending the peer lease.
pub(super) fn touch_progress(&mut self, now: Instant) {
self.last_progress = now;
}
/// Returns elapsed time since the latest validated peer operation.
pub(super) fn peer_idle(&self, now: Instant) -> Duration {
now.saturating_duration_since(self.last_peer)
}
/// Returns elapsed time since the latest carrier-side progress.
pub(super) fn progress_idle(&self, now: Instant) -> Duration {
now.saturating_duration_since(self.last_progress)
}
}
+3 -3
View File
@@ -7,7 +7,7 @@ use crate::proxy::shared_state::ConntrackClosePolicy;
use crate::web::frame::FrameType;
use crate::web::stream::WebLogicalStream;
use super::{StreamIdentity, WebSession, inbound_queue_cost};
use super::{SessionCloseReason, StreamIdentity, WebSession, inbound_queue_cost};
#[cfg(test)]
#[path = "backend_tests.rs"]
@@ -119,7 +119,7 @@ impl WebSession {
})
};
if queued.is_some_and(|queued| !queued) {
self.close();
self.close(SessionCloseReason::Backpressure);
}
}
@@ -156,7 +156,7 @@ impl WebSession {
}
if let Some(queued) = queued {
if !queued {
self.close();
self.close(SessionCloseReason::Backpressure);
}
if self.carrier().is_multiplexed() {
self.down_notify.notify_waiters();
+2 -2
View File
@@ -47,7 +47,7 @@ impl TestRuntime {
}
async fn shutdown(self) {
self.session.close();
self.session.close(super::SessionCloseReason::ApiClose);
self.session.wait().await;
self.manager.shutdown().await;
self.generation.stop_sessions().await;
@@ -402,7 +402,7 @@ async fn cancellation_while_waiting_for_data_releases_stream_ownership() {
settle_tasks().await;
assert_eq!(runtime.session.tasks_live.load(Ordering::Acquire), 1);
runtime.session.close();
runtime.session.close(super::SessionCloseReason::ApiClose);
runtime.session.wait().await;
assert_eq!(runtime.session.tasks_live.load(Ordering::Acquire), 0);
+8 -7
View File
@@ -5,7 +5,8 @@ use bytes::{BufMut, Bytes, BytesMut};
use super::resident::{OwnedBatchBody, PendingCounts, PendingResponseLease};
use super::{
DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState, WebSession,
DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionCloseReason,
SessionState, WebSession,
};
use crate::web::frame::{self, FrameType};
use crate::web::manager::ManagerError;
@@ -21,7 +22,7 @@ impl WebSession {
if state.closed {
return Err(ManagerError::Closed);
}
state.last_activity = Instant::now();
state.activity.touch_peer(Instant::now());
if let Some(unacked) = &state.unacked {
if cursor == unacked.base_cursor {
return Ok(PollResult {
@@ -32,7 +33,7 @@ impl WebSession {
}
if cursor != unacked.next_cursor {
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
let carrier_health_eligible = unacked.carrier_health_eligible;
@@ -43,12 +44,12 @@ impl WebSession {
}
} else if cursor != state.down_cursor {
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
let Some(epoch) = state.down_epoch.checked_add(1) else {
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
};
state.down_epoch = epoch;
@@ -83,7 +84,7 @@ impl WebSession {
}
Err(error) => {
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
return Err(error);
}
};
@@ -110,7 +111,7 @@ impl WebSession {
Err(_) => {
let mut state = self.state.lock();
if state.down_epoch == epoch {
state.last_activity = Instant::now();
state.activity.touch_progress(Instant::now());
}
Ok(PollResult {
body: Bytes::new(),
+3 -3
View File
@@ -69,7 +69,7 @@ async fn downlink_replays_unacknowledged_batch_byte_for_byte() {
assert_eq!(first.body, replay.body);
drop(first);
drop(replay);
session.close();
session.close(super::SessionCloseReason::ApiClose);
manager.shutdown().await;
}
@@ -89,7 +89,7 @@ async fn acknowledged_response_stays_resident_until_the_last_body_clone_drops()
assert!(session.resident.snapshot().bytes() > 0);
drop(retained);
assert_eq!(session.resident.snapshot().bytes(), 0);
session.close();
session.close(super::SessionCloseReason::ApiClose);
manager.shutdown().await;
}
@@ -139,6 +139,6 @@ async fn newer_poll_supersedes_older_poll_without_closing_session() {
assert_eq!(superseded.next_cursor, 0);
assert!(!session.state.lock().closed);
second.abort();
session.close();
session.close(super::SessionCloseReason::ApiClose);
manager.shutdown().await;
}
+10 -10
View File
@@ -5,7 +5,7 @@ use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use super::uplink::{AppliedProgress, inbound_reservation, validate_batch};
use super::{PendingClass, WebSession, insert_carrier_lane};
use super::{PendingClass, SessionCloseReason, WebSession, insert_carrier_lane};
use crate::config::WebCarrier;
use crate::web::frame::{self, Frame, FrameType};
use crate::web::manager::{ManagerError, TokenHash};
@@ -24,7 +24,7 @@ impl WebSession {
let frames = match frame::parse_all(body, &self.limits) {
Ok(frames) => frames,
Err(_) => {
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
};
@@ -33,7 +33,7 @@ impl WebSession {
.copied()
.any(|value| value.stream_id != lane_id || frame::validate_client_shape(value).is_err())
{
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
let digest: TokenHash = Sha256::digest(body).into();
@@ -46,7 +46,7 @@ impl WebSession {
return Err(ManagerError::Closed);
}
self.ensure_carrier_active_locked(&state)?;
state.last_activity = Instant::now();
state.activity.touch_peer(Instant::now());
let new_lane = !state.carrier_lanes.contains_key(&lane_id);
if new_lane {
if lane_id != 0
@@ -69,7 +69,7 @@ impl WebSession {
.is_none_or(|value| value.frame_type != FrameType::Open)
{
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
let lane_limit = self
@@ -92,13 +92,13 @@ impl WebSession {
Ok(sequence)
} else {
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
Err(ManagerError::Protocol)
};
}
if sequence == 0 || sequence != last_sequence.saturating_add(1) {
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
if up_active {
@@ -106,7 +106,7 @@ impl WebSession {
}
if !validate_batch(&state, &frames) {
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
let (reserve_bytes, reserve_items) = inbound_reservation(&state, &frames);
@@ -124,7 +124,7 @@ impl WebSession {
if new_lane && insert_carrier_lane(&mut state, lane_id).is_none() {
self.release_locked(&mut state, reserve_bytes, reserve_items, false);
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
let Some(lane) = state.carrier_lanes.get_mut(&lane_id) else {
@@ -161,7 +161,7 @@ impl WebSession {
return result;
}
if result.is_err() {
self.close();
self.close(SessionCloseReason::Protocol);
drop(opened);
return result;
}
+9 -9
View File
@@ -6,8 +6,8 @@ use tokio::sync::OwnedSemaphorePermit;
use super::lane_downlink::take_lane_down_batch;
use super::{
CarrierLaneIdentity, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState,
WebSession, remember_closed,
CarrierLaneIdentity, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionCloseReason,
SessionState, WebSession, remember_closed,
};
use crate::web::frame::{self, FrameType};
use crate::web::manager::ManagerError;
@@ -65,7 +65,7 @@ impl WebSession {
if state.closed {
return Err(ManagerError::Closed);
}
state.last_activity = Instant::now();
state.activity.touch_peer(Instant::now());
let acknowledged = {
let Some(lane) = state.carrier_lanes.get_mut(&lane_id) else {
return Ok(PollResult {
@@ -91,14 +91,14 @@ impl WebSession {
}
if cursor != unacked.next_cursor {
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
lane.unacked.take()
} else {
if cursor != lane.down_cursor {
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
None
@@ -133,7 +133,7 @@ impl WebSession {
.ok_or(ManagerError::Protocol)?;
let Some(epoch) = lane.down_epoch.checked_add(1) else {
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
};
lane.down_epoch = epoch;
@@ -195,7 +195,7 @@ impl WebSession {
}
Err(error) => {
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
return Err(error);
}
};
@@ -258,7 +258,7 @@ impl WebSession {
});
}
if lane.down_epoch == epoch {
state.last_activity = Instant::now();
state.activity.touch_progress(Instant::now());
}
}
Ok(PollResult {
@@ -281,7 +281,7 @@ impl WebSession {
}
if cursor != 0 || lane_id == 0 {
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
if state.closed_streams.contains(&lane_id)
+5 -5
View File
@@ -108,7 +108,7 @@ async fn early_down_waits_without_creating_a_provisional_lane() {
assert!(!result.body.is_empty());
assert_eq!(session.state.lock().lane_open_waits, 0);
drop(result);
session.close();
session.close(super::super::SessionCloseReason::ApiClose);
manager.shutdown().await;
}
@@ -126,7 +126,7 @@ async fn early_down_timeout_is_empty_and_releases_its_session_slot() {
assert_eq!(result.next_cursor, 0);
assert!(!result.lane_closed);
assert_eq!(session.state.lock().lane_open_waits, 0);
session.close();
session.close(super::super::SessionCloseReason::ApiClose);
manager.shutdown().await;
}
@@ -156,7 +156,7 @@ async fn early_down_admission_is_bounded_and_cancellation_safe() {
let _ = wait.await;
}
assert_eq!(session.state.lock().lane_open_waits, 0);
session.close();
session.close(super::super::SessionCloseReason::ApiClose);
manager.shutdown().await;
}
@@ -168,7 +168,7 @@ async fn session_close_wakes_early_down_with_closed_state() {
while session.state.lock().lane_open_waits == 0 {
tokio::task::yield_now().await;
}
session.close();
session.close(super::super::SessionCloseReason::ApiClose);
assert!(matches!(
tokio::time::timeout(Duration::from_secs(1), poll)
.await
@@ -229,7 +229,7 @@ async fn drained_closed_lane_replays_then_signals_completion() {
assert!(finished.lane_closed);
drop(first);
drop(replay);
session.close();
session.close(super::super::SessionCloseReason::ApiClose);
manager.shutdown().await;
}
+182 -55
View File
@@ -1,7 +1,95 @@
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use super::{SessionNegotiationPhase, WebSession};
use super::WebSession;
/// Stable terminal cause assigned by the first session-close winner.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(usize)]
pub(crate) enum SessionCloseReason {
/// An authenticated client explicitly deleted its current session.
ClientDelete,
/// A surviving bridge replaced an unreachable carrier incarnation.
BridgeRecovery,
/// No validated peer operation arrived within the frozen reconnect grace.
PeerIdle,
/// Automatic carrier negotiation exhausted its absolute deadline.
NegotiationTimeout,
/// A successful negotiation replacement retired this incarnation.
CarrierSuperseded,
/// Authenticated carrier framing or sequencing violated the protocol.
Protocol,
/// Mandatory bounded control state could not be retained.
Backpressure,
/// A committed WebSocket carrier ended.
WebSocketEnded,
/// An authenticated control-plane request selected this session.
ApiClose,
/// A graceful operator drain reached its force-close deadline.
OperatorForce,
/// Terminal process shutdown closed all remaining sessions.
RuntimeShutdown,
}
impl SessionCloseReason {
/// Complete fixed reason set in stable API and metric order.
pub(crate) const ALL: [Self; 11] = [
Self::ClientDelete,
Self::BridgeRecovery,
Self::PeerIdle,
Self::NegotiationTimeout,
Self::CarrierSuperseded,
Self::Protocol,
Self::Backpressure,
Self::WebSocketEnded,
Self::ApiClose,
Self::OperatorForce,
Self::RuntimeShutdown,
];
/// Returns the stable API, trace, and Prometheus token.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::ClientDelete => "client_delete",
Self::BridgeRecovery => "bridge_recovery",
Self::PeerIdle => "peer_idle",
Self::NegotiationTimeout => "negotiation_timeout",
Self::CarrierSuperseded => "carrier_superseded",
Self::Protocol => "protocol",
Self::Backpressure => "backpressure",
Self::WebSocketEnded => "websocket_ended",
Self::ApiClose => "api_close",
Self::OperatorForce => "operator_force",
Self::RuntimeShutdown => "runtime_shutdown",
}
}
}
/// Result of one first-writer-wins close request.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SessionCloseOutcome {
/// This caller closed the session synchronously.
Closed,
/// This caller owns a close deferred behind carrier replacement.
Deferred,
/// An earlier caller already owns or completed session closure.
AlreadyClosing,
}
impl SessionCloseOutcome {
/// Returns whether this caller won the terminal cause.
pub(crate) const fn accepted(self) -> bool {
!matches!(self, Self::AlreadyClosing)
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum SessionNegotiationPhase {
Uncommitted,
Replacing,
Committed,
Superseded,
}
struct ReleasedQueues {
data_bytes: usize,
@@ -9,6 +97,7 @@ struct ReleasedQueues {
control_bytes: usize,
control_items: usize,
closed_before_health: bool,
reason: SessionCloseReason,
}
/// Deferred queue release after manager publication linearizes a supersede.
@@ -21,24 +110,31 @@ pub(crate) struct CarrierSupersedeCompletion<'a> {
impl CarrierSupersedeCompletion<'_> {
/// Releases process budgets and signals cancellation after manager locks are dropped.
pub(crate) fn finish(self) {
self.session.finish_close(self.released, true);
self.session.finish_close(self.released);
}
}
impl WebSession {
/// Closes carrier state while relay tasks retain their admission until exit.
pub(crate) fn close(&self) -> bool {
let Some(released) = self.begin_close(false, None) else {
return false;
};
self.finish_close(released, false);
true
pub(crate) fn close(&self, reason: SessionCloseReason) -> SessionCloseOutcome {
let mut state = self.state.lock();
if state.closed || state.close_requested.is_some() {
return SessionCloseOutcome::AlreadyClosing;
}
if state.negotiation_phase == SessionNegotiationPhase::Replacing {
state.close_requested = Some(reason);
return SessionCloseOutcome::Deferred;
}
let released = self.release_on_close_locked(&mut state, reason);
drop(state);
self.finish_close(released);
SessionCloseOutcome::Closed
}
/// Atomically prevents first-frame commit while one successor is prepared.
pub(crate) fn begin_carrier_supersede(&self) -> bool {
let mut state = self.state.lock();
if state.closed || state.close_requested {
if state.closed || state.close_requested.is_some() {
return false;
}
match state.negotiation_phase {
@@ -54,21 +150,32 @@ impl WebSession {
/// Restores an uncommitted attempt after successor admission failed.
pub(crate) fn cancel_carrier_supersede(&self) {
let close_requested = {
let released = {
let mut state = self.state.lock();
if !state.closed && state.negotiation_phase == SessionNegotiationPhase::Replacing {
state.negotiation_phase = SessionNegotiationPhase::Uncommitted;
}
state.close_requested
state
.close_requested
.filter(|_| !state.closed)
.map(|reason| self.release_on_close_locked(&mut state, reason))
};
if close_requested {
self.close();
if let Some(released) = released {
self.finish_close(released);
}
}
/// Linearizes manager publication against close requests on the old token.
pub(crate) fn prepare_carrier_supersede(&self) -> Option<CarrierSupersedeCompletion<'_>> {
let released = self.begin_close(true, None)?;
let mut state = self.state.lock();
if state.closed
|| state.negotiation_phase != SessionNegotiationPhase::Replacing
|| state.close_requested.is_some()
{
return None;
}
let released =
self.release_on_close_locked(&mut state, SessionCloseReason::CarrierSuperseded);
Some(CarrierSupersedeCompletion {
session: self,
released,
@@ -88,6 +195,19 @@ impl WebSession {
}
}
/// Waits until registry removal and close telemetry have completed.
pub(crate) async fn wait_close_complete(&self) {
loop {
let notified = self.close_notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if self.close_complete.load(Ordering::Acquire) {
return;
}
notified.await;
}
}
/// Returns the current number of registered logical-stream tasks.
pub(crate) fn tasks_live(&self) -> usize {
self.tasks_live.load(Ordering::Acquire)
@@ -102,38 +222,38 @@ impl WebSession {
if let Some(claim) = healthy {
self.finish_carrier_health(claim);
}
let Some(released) = self.begin_close(false, Some(now)) else {
let Some(released) = self.begin_idle_close(now) else {
return false;
};
self.finish_close(released, false);
self.finish_close(released);
true
}
fn begin_close(&self, superseded: bool, idle_now: Option<Instant>) -> Option<ReleasedQueues> {
fn begin_idle_close(&self, now: Instant) -> Option<ReleasedQueues> {
let mut state = self.state.lock();
if state.closed
|| (superseded
&& (state.negotiation_phase != SessionNegotiationPhase::Replacing
|| state.close_requested))
if state.closed || state.close_requested.is_some() {
return None;
}
if state.negotiation_phase == SessionNegotiationPhase::Replacing
|| state.activity.peer_idle(now)
< Duration::from_secs(self.timeouts.reconnect_grace_secs)
{
return None;
}
if let Some(now) = idle_now
&& (state.negotiation_phase == SessionNegotiationPhase::Replacing
|| now.saturating_duration_since(state.last_activity)
< Duration::from_secs(self.timeouts.reconnect_grace_secs))
{
return None;
}
if !superseded && state.negotiation_phase == SessionNegotiationPhase::Replacing {
state.close_requested = true;
return None;
}
Some(self.release_on_close_locked(&mut state, SessionCloseReason::PeerIdle))
}
fn release_on_close_locked(
&self,
state: &mut super::SessionState,
reason: SessionCloseReason,
) -> ReleasedQueues {
let closed_before_health = self.automatic_carrier
&& state.negotiation_phase == SessionNegotiationPhase::Committed
&& self.reject_carrier_health_on_close();
state.close_requested = Some(reason);
state.closed = true;
if superseded {
if reason == SessionCloseReason::CarrierSuperseded {
state.negotiation_phase = SessionNegotiationPhase::Superseded;
}
for stream in state.streams.values_mut() {
@@ -149,8 +269,8 @@ impl WebSession {
state.pending_windows.clear();
if let Some(batch) = state.unacked.take() {
batch.lease.detach();
self.release_local_locked(&mut state, batch.data_bytes, batch.data_items, false);
self.release_local_locked(&mut state, batch.control_bytes, batch.control_items, true);
self.release_local_locked(state, batch.data_bytes, batch.data_items, false);
self.release_local_locked(state, batch.control_bytes, batch.control_items, true);
}
let mut lane_data_bytes = 0usize;
let mut lane_data_items = 0usize;
@@ -166,8 +286,8 @@ impl WebSession {
lane_control_items = lane_control_items.saturating_add(batch.control_items);
}
}
self.release_local_locked(&mut state, lane_data_bytes, lane_data_items, false);
self.release_local_locked(&mut state, lane_control_bytes, lane_control_items, true);
self.release_local_locked(state, lane_data_bytes, lane_data_items, false);
self.release_local_locked(state, lane_control_bytes, lane_control_items, true);
state.carrier_lanes.clear();
let control_bytes = state.pending_control_bytes;
let control_items = state.pending_control_items;
@@ -177,16 +297,17 @@ impl WebSession {
state.pending_items = 0;
state.pending_control_bytes = 0;
state.pending_control_items = 0;
Some(ReleasedQueues {
ReleasedQueues {
data_bytes,
data_items,
control_bytes,
control_items,
closed_before_health,
})
reason,
}
}
fn finish_close(&self, released: ReleasedQueues, superseded: bool) {
fn finish_close(&self, released: ReleasedQueues) {
self.cancel.cancel();
if self.carrier().is_multiplexed() {
self.down_notify.notify_waiters();
@@ -194,7 +315,8 @@ impl WebSession {
if self.carrier().uses_lanes() {
self.lane_open_notify.notify_waiters();
}
if let Some(manager) = self.manager.upgrade() {
let manager = self.manager.upgrade();
if let Some(manager) = &manager {
if released.closed_before_health {
manager.telemetry().record_carrier_learning(
self.selected_carrier,
@@ -213,22 +335,27 @@ impl WebSession {
released.control_items,
true,
);
if !self.finished.swap(true, Ordering::AcqRel) {
self.trace_lifecycle(
crate::web::trace::TraceLifecycleEvent::SessionClosed,
None,
Some(if superseded { "superseded" } else { "closed" }),
}
if !self.finished.swap(true, Ordering::AcqRel) {
self.trace_lifecycle(
crate::web::trace::TraceLifecycleEvent::SessionClosed,
None,
Some(released.reason.as_str()),
);
if released.reason != SessionCloseReason::CarrierSuperseded
&& let Some(manager) = manager
{
manager.session_finished(
self.token_hash,
self.client_ip,
self.profile_key,
&self.profile.host,
Duration::from_secs(self.timeouts.bootstrap_lifetime_secs),
released.reason,
);
if !superseded {
manager.session_finished(
self.token_hash,
self.client_ip,
self.profile_key,
&self.profile.host,
Duration::from_secs(self.timeouts.bootstrap_lifetime_secs),
);
}
}
self.close_complete.store(true, Ordering::Release);
self.close_notify.notify_waiters();
}
}
}
+5 -2
View File
@@ -460,7 +460,7 @@ mod tests {
let close = std::thread::spawn(move || {
close_barrier.wait();
std::thread::yield_now();
close_session.close();
close_session.close(super::SessionCloseReason::ApiClose);
});
barrier.wait();
health.join().unwrap();
@@ -472,7 +472,10 @@ mod tests {
| CarrierHealthPublicationState::Rejected
));
assert!(!session.publish_carrier_health());
assert!(!session.close());
assert_eq!(
session.close(super::SessionCloseReason::ApiClose),
super::SessionCloseOutcome::AlreadyClosing
);
}
}
+15 -2
View File
@@ -54,6 +54,12 @@ pub(crate) struct WebSessionStatus {
pub(crate) age_ms: u64,
/// Monotonic age since the latest carrier activity.
pub(crate) idle_ms: u64,
/// Monotonic age since the latest validated peer operation.
pub(crate) peer_idle_ms: u64,
/// Session-frozen authenticated peer inactivity allowance.
pub(crate) reconnect_grace_ms: u64,
/// Remaining time before peer inactivity makes the session eligible for cleanup.
pub(crate) peer_deadline_remaining_ms: u64,
/// Remaining automatic negotiation deadline.
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) negotiation_remaining_ms: Option<u64>,
@@ -66,7 +72,7 @@ impl WebSession {
let resident = self.resident.snapshot();
let state_name = if state.closed {
"closed"
} else if state.close_requested {
} else if state.close_requested.is_some() {
"closing"
} else if self.carrier_health_publication_state()
== CarrierHealthPublicationState::Published
@@ -112,7 +118,14 @@ impl WebSession {
.pending_control_items
.saturating_add(resident.control_items),
age_ms: millis(now.saturating_duration_since(self.created_at)),
idle_ms: millis(now.saturating_duration_since(state.last_activity)),
idle_ms: millis(state.activity.progress_idle(now)),
peer_idle_ms: millis(state.activity.peer_idle(now)),
reconnect_grace_ms: self.timeouts.reconnect_grace_secs.saturating_mul(1_000),
peer_deadline_remaining_ms: self
.timeouts
.reconnect_grace_secs
.saturating_mul(1_000)
.saturating_sub(millis(state.activity.peer_idle(now))),
negotiation_remaining_ms: self
.carrier_deadline_at
.map(|deadline| millis(deadline.saturating_duration_since(now))),
+9 -9
View File
@@ -9,8 +9,8 @@ use subtle::ConstantTimeEq;
use super::backend::StreamCompletion;
use super::{
InboundChunk, PendingClass, QUEUE_ITEM_COST, SessionState, StreamIdentity, StreamState,
WebSession, inbound_queue_cost,
InboundChunk, PendingClass, QUEUE_ITEM_COST, SessionCloseReason, SessionState, StreamIdentity,
StreamState, WebSession, inbound_queue_cost,
};
use crate::web::frame::{self, Frame, FrameType};
use crate::web::manager::{ManagerError, TokenHash};
@@ -70,7 +70,7 @@ impl WebSession {
let frames = match frame::parse_all(body, &self.limits) {
Ok(frames) => frames,
Err(_) => {
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
};
@@ -79,7 +79,7 @@ impl WebSession {
.copied()
.any(|value| frame::validate_client_shape(value).is_err())
{
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
let digest: TokenHash = Sha256::digest(body).into();
@@ -92,24 +92,24 @@ impl WebSession {
return Err(ManagerError::Closed);
}
self.ensure_carrier_active_locked(&state)?;
state.last_activity = Instant::now();
state.activity.touch_peer(Instant::now());
if sequence == state.last_up_sequence && sequence != 0 {
return if bool::from(state.last_up_digest.ct_eq(&digest)) {
Ok((sequence, false))
} else {
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
Err(ManagerError::Protocol)
};
}
if sequence == 0 || sequence != state.last_up_sequence.saturating_add(1) {
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
if !validate_batch(&state, &frames) {
drop(state);
self.close();
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
let (reserve_bytes, reserve_items) = inbound_reservation(&state, &frames);
@@ -147,7 +147,7 @@ impl WebSession {
return result;
}
if result.is_err() {
self.close();
self.close(SessionCloseReason::Protocol);
drop(opened);
return result;
}
+1 -1
View File
@@ -391,7 +391,7 @@ impl WebSession {
lane.last_up_digest = digest;
}
}
state.last_activity = Instant::now();
state.activity.touch_peer(Instant::now());
if applied {
(committed, healthy) = self.record_uplink_progress_locked(&mut state, progress);
}
+10 -4
View File
@@ -18,7 +18,7 @@ struct TestRuntime {
impl TestRuntime {
async fn shutdown(self) {
self.session.close();
self.session.close(super::super::SessionCloseReason::ApiClose);
self.session.wait().await;
self.manager.shutdown().await;
self.generation.stop_sessions().await;
@@ -181,7 +181,9 @@ async fn closed_session_releases_bound_lane_quota_on_reservation_drop() {
let mut reservation = runtime.session.reserve_websocket_lane(7).unwrap();
reservation.bind(1).unwrap();
runtime.session.close();
runtime
.session
.close(super::super::SessionCloseReason::ApiClose);
drop(reservation);
assert!(runtime.session.state.lock().active_peer_ports.is_empty());
@@ -220,7 +222,9 @@ async fn closed_session_releases_transferred_rejected_lane_quota() {
WebSocketLaneReservationPhase::Transferred
);
runtime.session.close();
runtime
.session
.close(super::super::SessionCloseReason::ApiClose);
drop(reservation);
assert!(runtime.session.state.lock().active_peer_ports.is_empty());
@@ -259,7 +263,9 @@ async fn closed_session_keeps_stream_owned_quota_until_task_completion() {
WebSocketLaneReservationPhase::StreamOwned
);
runtime.session.close();
runtime
.session
.close(super::super::SessionCloseReason::ApiClose);
drop(reservation);
assert!(
+12 -5
View File
@@ -10,6 +10,12 @@ pub(crate) use carrier::{
WebCarrierLearningOutcome, WebCarrierSelectionCounter, WebCarrierSelectionDisposition,
};
use carrier::{CARRIER_FAILURE_SLOTS, CARRIER_LEARNING_SLOTS, CARRIER_SELECTION_SLOTS};
mod lifecycle;
pub(crate) use lifecycle::{
WebBridgeRecoveryCounter, WebBridgeRecoveryEvent, WebSessionCloseCounter,
WebSessionLifecycleObservation, WebSessionLifecycleObservationCounter,
};
use lifecycle::{SESSION_CLOSE_SLOTS, SESSION_OBSERVATION_SLOTS};
const LAST_DECOY_OUTCOME_BITS: u32 = 4;
const LAST_DECOY_OUTCOME_MASK: u64 = (1 << LAST_DECOY_OUTCOME_BITS) - 1;
@@ -310,6 +316,9 @@ pub(crate) struct WebTelemetry {
carrier_selections: [AtomicU64; CARRIER_SELECTION_SLOTS],
carrier_failures: [AtomicU64; CARRIER_FAILURE_SLOTS],
carrier_learning_outcomes: [AtomicU64; CARRIER_LEARNING_SLOTS],
session_closures: [AtomicU64; SESSION_CLOSE_SLOTS],
session_observations: [AtomicU64; SESSION_OBSERVATION_SLOTS],
bridge_recovery_events: [AtomicU64; WebBridgeRecoveryEvent::ALL.len()],
last_decoy: AtomicU64,
sessions_created: AtomicU64,
sessions_closed: AtomicU64,
@@ -334,6 +343,9 @@ impl WebTelemetry {
carrier_selections: std::array::from_fn(|_| AtomicU64::new(0)),
carrier_failures: std::array::from_fn(|_| AtomicU64::new(0)),
carrier_learning_outcomes: std::array::from_fn(|_| AtomicU64::new(0)),
session_closures: std::array::from_fn(|_| AtomicU64::new(0)),
session_observations: std::array::from_fn(|_| AtomicU64::new(0)),
bridge_recovery_events: std::array::from_fn(|_| AtomicU64::new(0)),
last_decoy: AtomicU64::new(0),
sessions_created: AtomicU64::new(0),
sessions_closed: AtomicU64::new(0),
@@ -467,11 +479,6 @@ impl WebTelemetry {
self.sessions_created.fetch_add(1, Ordering::Relaxed);
}
/// Records one closed session incarnation.
pub(crate) fn record_session_closed(&self) {
self.sessions_closed.fetch_add(1, Ordering::Relaxed);
}
/// Records one admitted logical stream.
pub(crate) fn record_stream_opened(&self) {
self.streams_opened.fetch_add(1, Ordering::Relaxed);
+236
View File
@@ -0,0 +1,236 @@
use std::sync::atomic::{AtomicU64, Ordering};
use serde::Serialize;
use crate::config::WebCarrier;
use crate::web::session::SessionCloseReason;
use super::WebTelemetry;
pub(super) const SESSION_CLOSE_SLOTS: usize =
WebCarrier::ALL.len() * SessionCloseReason::ALL.len();
pub(super) const SESSION_OBSERVATION_SLOTS: usize =
WebCarrier::ALL.len() * WebSessionLifecycleObservation::ALL.len();
/// Stable observation emitted after an authenticated session lifecycle gap.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(usize)]
pub(crate) enum WebSessionLifecycleObservation {
/// A valid HTTP carrier request resumed after a suspicious peer gap.
HttpActivityAfterGap,
/// A valid WebSocket message resumed after a suspicious peer gap.
WebSocketActivityAfterGap,
/// A valid retained bearer was used after its session closed.
RequestAfterClose,
}
impl WebSessionLifecycleObservation {
/// Complete fixed observation set in stable metric order.
pub(crate) const ALL: [Self; 3] = [
Self::HttpActivityAfterGap,
Self::WebSocketActivityAfterGap,
Self::RequestAfterClose,
];
/// Returns the stable API and Prometheus token.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::HttpActivityAfterGap => "http_activity_after_gap",
Self::WebSocketActivityAfterGap => "websocket_activity_after_gap",
Self::RequestAfterClose => "request_after_close",
}
}
}
/// Stable server-side milestone for one bridge recovery incarnation.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(usize)]
pub(crate) enum WebBridgeRecoveryEvent {
/// A recovery request received a fresh bootstrap.
BootstrapIssued,
/// A fresh recovery session was registered.
SessionCreated,
/// The recovery session committed a real carrier.
Committed,
/// An issued recovery bootstrap expired without session creation.
ExpiredUnused,
/// A recovery session closed before carrier commit.
ClosedBeforeCommit,
}
impl WebBridgeRecoveryEvent {
/// Complete fixed recovery event set in stable metric order.
pub(crate) const ALL: [Self; 5] = [
Self::BootstrapIssued,
Self::SessionCreated,
Self::Committed,
Self::ExpiredUnused,
Self::ClosedBeforeCommit,
];
/// Returns the stable API and Prometheus token.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::BootstrapIssued => "bootstrap_issued",
Self::SessionCreated => "session_created",
Self::Committed => "committed",
Self::ExpiredUnused => "expired_unused",
Self::ClosedBeforeCommit => "closed_before_commit",
}
}
}
/// API-safe typed session-close counter.
#[derive(Clone, Serialize)]
pub(crate) struct WebSessionCloseCounter {
/// Fixed carrier owning the closed incarnation.
pub(crate) carrier: &'static str,
/// Fixed terminal close cause.
pub(crate) reason: &'static str,
/// Monotonic process-lifetime count.
pub(crate) total: u64,
}
/// API-safe typed lifecycle-observation counter.
#[derive(Clone, Serialize)]
pub(crate) struct WebSessionLifecycleObservationCounter {
/// Fixed carrier associated with the observation.
pub(crate) carrier: &'static str,
/// Fixed lifecycle observation.
pub(crate) observation: &'static str,
/// Monotonic process-lifetime count.
pub(crate) total: u64,
}
/// API-safe typed bridge-recovery counter.
#[derive(Clone, Serialize)]
pub(crate) struct WebBridgeRecoveryCounter {
/// Fixed recovery milestone.
pub(crate) event: &'static str,
/// Monotonic process-lifetime count.
pub(crate) total: u64,
}
pub(super) const fn session_close_slot(
carrier: WebCarrier,
reason: SessionCloseReason,
) -> usize {
carrier as usize * SessionCloseReason::ALL.len() + reason as usize
}
pub(super) const fn session_observation_slot(
carrier: WebCarrier,
observation: WebSessionLifecycleObservation,
) -> usize {
carrier as usize * WebSessionLifecycleObservation::ALL.len() + observation as usize
}
pub(super) fn load(counter: &AtomicU64) -> u64 {
counter.load(Ordering::Relaxed)
}
impl WebTelemetry {
/// Records one closed session incarnation and its exact terminal cause.
pub(crate) fn record_session_closed(
&self,
carrier: WebCarrier,
reason: SessionCloseReason,
) {
self.sessions_closed.fetch_add(1, Ordering::Relaxed);
self.session_closures[session_close_slot(carrier, reason)]
.fetch_add(1, Ordering::Relaxed);
}
/// Returns one fixed session-close counter.
pub(crate) fn session_close_total(
&self,
carrier: WebCarrier,
reason: SessionCloseReason,
) -> u64 {
load(&self.session_closures[session_close_slot(carrier, reason)])
}
/// Captures the complete fixed session-close matrix for API serialization.
pub(crate) fn session_close_counters(&self) -> Vec<WebSessionCloseCounter> {
WebCarrier::ALL
.into_iter()
.flat_map(|carrier| {
SessionCloseReason::ALL.into_iter().map(move |reason| {
WebSessionCloseCounter {
carrier: carrier.as_str(),
reason: reason.as_str(),
total: self.session_close_total(carrier, reason),
}
})
})
.collect()
}
/// Records one fixed authenticated lifecycle observation.
pub(crate) fn record_session_observation(
&self,
carrier: WebCarrier,
observation: WebSessionLifecycleObservation,
) {
self.session_observations[session_observation_slot(carrier, observation)]
.fetch_add(1, Ordering::Relaxed);
}
/// Returns one fixed authenticated lifecycle observation counter.
pub(crate) fn session_observation_total(
&self,
carrier: WebCarrier,
observation: WebSessionLifecycleObservation,
) -> u64 {
load(&self.session_observations[session_observation_slot(carrier, observation)])
}
/// Captures the complete fixed lifecycle-observation matrix.
pub(crate) fn session_observation_counters(
&self,
) -> Vec<WebSessionLifecycleObservationCounter> {
WebCarrier::ALL
.into_iter()
.flat_map(|carrier| {
WebSessionLifecycleObservation::ALL
.into_iter()
.map(move |observation| WebSessionLifecycleObservationCounter {
carrier: carrier.as_str(),
observation: observation.as_str(),
total: self.session_observation_total(carrier, observation),
})
})
.collect()
}
/// Records one fixed bridge-recovery milestone.
pub(crate) fn record_bridge_recovery(&self, event: WebBridgeRecoveryEvent) {
self.bridge_recovery_events[event as usize].fetch_add(1, Ordering::Relaxed);
}
/// Adds a bounded batch of identical recovery milestones.
pub(crate) fn record_bridge_recovery_count(
&self,
event: WebBridgeRecoveryEvent,
count: usize,
) {
self.bridge_recovery_events[event as usize]
.fetch_add(u64::try_from(count).unwrap_or(u64::MAX), Ordering::Relaxed);
}
/// Returns one fixed bridge-recovery counter.
pub(crate) fn bridge_recovery_total(&self, event: WebBridgeRecoveryEvent) -> u64 {
load(&self.bridge_recovery_events[event as usize])
}
/// Captures the complete fixed bridge-recovery event set.
pub(crate) fn bridge_recovery_counters(&self) -> Vec<WebBridgeRecoveryCounter> {
WebBridgeRecoveryEvent::ALL
.into_iter()
.map(|event| WebBridgeRecoveryCounter {
event: event.as_str(),
total: self.bridge_recovery_total(event),
})
.collect()
}
}