WEB Carriers negotiation and lane lifecycle bounds hardened

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-08-26 23:08:53 +03:00
parent 34eeb2d856
commit e72b1f04d1
15 changed files with 645 additions and 489 deletions
+14 -11
View File
@@ -118,11 +118,11 @@ impl WebSession {
.then(|| state.streams.remove(&stream.id))
.flatten()
.map(|stream_state| {
let (bytes, items) = inbound_queue_cost(&stream_state.inbound);
self.release_locked(&mut state, bytes, items, false);
self.remember_closed_locked(&mut state, stream.id);
self.queue_control_locked(&mut state, FrameType::Close, stream.id, &[])
})
let (bytes, items) = inbound_queue_cost(&stream_state.inbound);
self.release_locked(&mut state, bytes, items, false);
self.remember_closed_locked(&mut state, stream.id);
self.queue_control_locked(&mut state, FrameType::Close, stream.id, &[])
})
};
if queued.is_some_and(|queued| !queued) {
self.close();
@@ -137,12 +137,15 @@ impl WebSession {
.streams
.get(&stream.id)
.is_some_and(|state| state.instance == stream.instance);
let queued = current.then(|| state.streams.remove(&stream.id)).flatten().map(|stream_state| {
let (bytes, items) = inbound_queue_cost(&stream_state.inbound);
self.release_locked(&mut state, bytes, items, false);
self.remember_closed_locked(&mut state, stream.id);
self.queue_control_locked(&mut state, FrameType::Close, stream.id, &[])
});
let queued = current
.then(|| state.streams.remove(&stream.id))
.flatten()
.map(|stream_state| {
let (bytes, items) = inbound_queue_cost(&stream_state.inbound);
self.release_locked(&mut state, bytes, items, false);
self.remember_closed_locked(&mut state, stream.id);
self.queue_control_locked(&mut state, FrameType::Close, stream.id, &[])
});
if state.closing_streams.get(&stream.id) == Some(&stream.instance) {
state.closing_streams.remove(&stream.id);
self.remember_closed_locked(&mut state, stream.id);
+14
View File
@@ -373,3 +373,17 @@ async fn cancellation_while_waiting_for_data_releases_stream_ownership() {
);
runtime.shutdown().await;
}
#[tokio::test]
async fn exhausted_stream_identity_does_not_acquire_synthetic_port_ownership() {
let runtime = test_runtime(WebCarrier::Https, 1);
runtime.session.state.lock().next_stream_instance = u64::MAX;
assert_eq!(
runtime.process_frame(1, 1, FrameType::Open, &[]),
Err(ManagerError::Closed)
);
assert!(runtime.session.state.lock().active_peer_ports.is_empty());
runtime.shutdown().await;
}
+12 -1
View File
@@ -55,7 +55,14 @@ impl WebSession {
.is_some_and(|value| value.frame_type != FrameType::Open)
&& only_late_frames(&frames)
{
return Ok(sequence);
return if self.automatic_carrier
&& state.negotiation_phase
!= super::SessionNegotiationPhase::Committed
{
Err(ManagerError::Backpressure)
} else {
Ok(sequence)
};
}
if lane_id == 0
|| frames
@@ -160,6 +167,10 @@ impl WebSession {
drop(opened);
return result;
}
if self.automatic_carrier && !self.is_carrier_committed() {
self.lane_open_notify.notify_waiters();
return Err(ManagerError::Backpressure);
}
if committed {
self.finish_carrier_commit();
}
+1
View File
@@ -3,6 +3,7 @@ use std::time::{Duration, Instant};
use bytes::{BufMut, Bytes, BytesMut};
use tokio::sync::OwnedSemaphorePermit;
use super::lane_downlink::take_lane_down_batch;
use super::{
PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState, WebSession,
+31 -2
View File
@@ -20,6 +20,14 @@ fn session_with_limits(limits: WebLimitsConfig) -> Arc<WebSession> {
fn new_session(
limits: WebLimitsConfig,
manager: std::sync::Weak<WebProcessRuntime>,
) -> Arc<WebSession> {
new_session_with_automatic(limits, manager, false)
}
fn new_session_with_automatic(
limits: WebLimitsConfig,
manager: std::sync::Weak<WebProcessRuntime>,
automatic: bool,
) -> Arc<WebSession> {
let profile = Arc::new(WebRuntimeProfile {
host: "proxy.example.com".to_string(),
@@ -48,9 +56,13 @@ fn new_session(
1,
[3; 32],
None,
crate::web::manager::CarrierClientClass::Legacy,
if automatic {
crate::web::manager::CarrierClientClass::Bridge
} else {
crate::web::manager::CarrierClientClass::Legacy
},
None,
false,
automatic,
limits,
WebTimeoutsConfig::default(),
)
@@ -259,3 +271,20 @@ fn tombstone_eviction_releases_lane_budget_and_accepts_late_frames() {
assert_eq!(session.process_up_lane(7, 7, &late), Ok(7));
assert!(!session.state.lock().closed);
}
#[test]
fn automatic_lane_does_not_ack_a_missing_lane_without_real_progress() {
let session = new_session_with_automatic(
WebLimitsConfig::default(),
std::sync::Weak::new(),
true,
);
let late = frame::encode(FrameType::Data, 7, b"late");
assert_eq!(
session.process_up_lane(7, 1, &late),
Err(ManagerError::Backpressure)
);
assert!(!session.is_carrier_committed());
assert!(!session.state.lock().closed);
}
+36 -2
View File
@@ -31,7 +31,7 @@ impl WebSession {
/// Publishes the already-linearized session commit to process state.
pub(super) fn finish_carrier_commit(&self) {
if let Some(manager) = self.manager.upgrade() {
let published = self.manager.upgrade().is_some_and(|manager| {
manager.carrier_committed(
self.bootstrap_hash,
self.token_hash,
@@ -40,7 +40,22 @@ impl WebSession {
self.carrier_class,
self.client_ip,
self.trace_identity(),
);
)
});
if !published {
return;
}
let healthy = {
let mut state = self.state.lock();
if state.closed || state.negotiation_phase != SessionNegotiationPhase::Committed {
false
} else {
state.carrier_commit_published = true;
self.carrier_health_ready_locked(&mut state, Instant::now())
}
};
if healthy {
self.finish_carrier_health();
}
}
@@ -97,7 +112,9 @@ impl WebSession {
now: Instant,
) -> bool {
if !self.automatic_carrier
|| state.closed
|| state.negotiation_phase != SessionNegotiationPhase::Committed
|| !state.carrier_commit_published
|| state.carrier_health_reported
|| state.carrier_health_due_at.is_none_or(|due| now < due)
{
@@ -233,6 +250,7 @@ mod tests {
let now = Instant::now();
let mut state = session.state.lock();
state.negotiation_phase = SessionNegotiationPhase::Committed;
state.carrier_commit_published = true;
state.carrier_health_due_at = Some(now - Duration::from_secs(1));
state.carrier_health_uplink = true;
state.carrier_health_downlink = true;
@@ -251,6 +269,7 @@ mod tests {
let now = Instant::now();
let mut state = session.state.lock();
state.negotiation_phase = SessionNegotiationPhase::Committed;
state.carrier_commit_published = true;
state.carrier_health_due_at = Some(now - Duration::from_secs(1));
state.websocket_carrier_active = true;
state.websocket_commit_ack_owner = Some(7);
@@ -261,6 +280,21 @@ mod tests {
assert!(session.carrier_health_ready_locked(&mut state, now));
}
#[test]
fn health_waits_for_manager_commit_publication() {
let session = session(WebCarrier::Https, Instant::now() + Duration::from_secs(60));
let now = Instant::now();
let mut state = session.state.lock();
state.negotiation_phase = SessionNegotiationPhase::Committed;
state.carrier_health_due_at = Some(now - Duration::from_secs(1));
state.carrier_health_uplink = true;
state.carrier_health_downlink = true;
state.carrier_health_activity_at = Some(now);
assert!(!session.carrier_health_ready_locked(&mut state, now));
assert!(!state.carrier_health_reported);
}
#[test]
fn commit_and_supersede_have_one_session_lock_winner() {
let committed = session(WebCarrier::Https, Instant::now() + Duration::from_secs(60));
+28 -7
View File
@@ -34,8 +34,11 @@ impl WebSession {
sequence: u64,
body: &[u8],
) -> Result<u64, ManagerError> {
self.process_up_inner(sequence, body)
.map(|(acknowledged, _)| acknowledged)
let (acknowledged, progressed) = self.process_up_inner(sequence, body)?;
if self.automatic_carrier && !progressed && !self.is_carrier_committed() {
return Err(ManagerError::Backpressure);
}
Ok(acknowledged)
}
/// Applies one WebSocket uplink batch and reports actual carrier progress.
@@ -182,6 +185,9 @@ impl WebSession {
|| state.closing_streams.contains_key(&value.stream_id);
match value.frame_type {
FrameType::Open => {
let Some(stream) = next_stream_identity(state, value.stream_id) else {
return false;
};
let peer_port = match reserved_open.take() {
Some((reserved_stream_id, peer_port))
if reserved_stream_id == value.stream_id =>
@@ -208,9 +214,6 @@ impl WebSession {
peer_port
}
};
let Some(stream) = next_stream_identity(state, value.stream_id) else {
return false;
};
state.streams.insert(
value.stream_id,
StreamState {
@@ -420,6 +423,10 @@ mod tests {
use crate::web::manager::WebProcessRuntime;
fn session() -> Arc<WebSession> {
session_with_automatic(false)
}
fn session_with_automatic(automatic: bool) -> Arc<WebSession> {
let profile = Arc::new(WebRuntimeProfile {
host: "proxy.example.com".to_string(),
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
@@ -447,9 +454,13 @@ mod tests {
1,
[3; 32],
None,
crate::web::manager::CarrierClientClass::Legacy,
if automatic {
crate::web::manager::CarrierClientClass::Bridge
} else {
crate::web::manager::CarrierClientClass::Legacy
},
None,
false,
automatic,
WebLimitsConfig::default(),
WebTimeoutsConfig::default(),
)
@@ -515,4 +526,14 @@ mod tests {
assert_eq!(session.process_up(2, &body), Err(ManagerError::Protocol));
assert!(session.state.lock().closed);
}
#[test]
fn automatic_uplink_does_not_ack_a_batch_without_real_progress() {
let session = session_with_automatic(true);
let body = frame::encode(FrameType::Pong, 0, &[]);
assert_eq!(session.process_up(1, &body), Err(ManagerError::Backpressure));
assert!(!session.is_carrier_committed());
assert!(!session.state.lock().closed);
}
}