WEB Manager Session + Generation Fence tests

This commit is contained in:
Alexey
2026-09-21 21:08:34 +03:00
parent d706b3f3ba
commit 51e706770c
19 changed files with 727 additions and 578 deletions
+3 -104
View File
@@ -2,7 +2,6 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use bytes::{BufMut, Bytes, BytesMut};
use tokio::sync::OwnedSemaphorePermit;
use super::lane_downlink::take_lane_down_batch;
use super::{
@@ -13,6 +12,9 @@ use crate::web::frame::{self, FrameType};
use crate::web::manager::ManagerError;
use crate::web::telemetry::WebSessionLifecycleObservation;
// Bounded lane-open admission and cancellation-safe wait lifecycle.
mod lane_open_wait;
impl WebSession {
/// Polls one lane with independent cursor replay and newest-poll-wins semantics.
pub(crate) async fn poll_down_lane(
@@ -311,97 +313,6 @@ impl WebSession {
}
}
async fn wait_for_lane_open(&self, lane_id: u32, cursor: u64) -> Result<bool, ManagerError> {
if self.close_if_cancelled() {
return Err(ManagerError::Closed);
}
let wait = {
let mut state = self.state.lock();
if state.closed || self.cancel.is_cancelled() {
drop(state);
self.close_if_cancelled();
return Err(ManagerError::Closed);
}
if state.carrier_lanes.contains_key(&lane_id) {
return Ok(true);
}
if cursor != 0 || lane_id == 0 {
drop(state);
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
if state.closed_streams.contains(&lane_id)
|| state.closing_streams.contains_key(&lane_id)
{
return Ok(true);
}
if state.lane_open_waits >= self.limits.max_lane_open_waits_per_session {
return Err(ManagerError::Limit);
}
let Some(manager) = self.manager.upgrade() else {
return Err(ManagerError::Closed);
};
let Some(auxiliary) = manager.try_lane_poll(true) else {
return Err(ManagerError::Limit);
};
state.lane_open_waits += 1;
let observation = WebSessionLifecycleObservation::HttpActivityAfterGap;
self.touch_peer_locked(&mut state, Instant::now(), observation);
LaneOpenWaitGuard {
session: self,
_auxiliary: auxiliary,
}
};
let deadline = Duration::from_secs(self.timeouts.lane_open_wait_secs);
let opened = tokio::time::timeout(deadline, async {
loop {
let notified = self.lane_open_notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
{
let state = self.state.lock();
if state.closed || self.cancel.is_cancelled() {
drop(state);
self.close_if_cancelled();
return Err(ManagerError::Closed);
}
if state.carrier_lanes.contains_key(&lane_id)
|| state.closed_streams.contains(&lane_id)
|| state.closing_streams.contains_key(&lane_id)
{
return Ok(true);
}
}
notified.await;
}
});
let opened = tokio::select! {
biased;
_ = self.cancel.cancelled() => {
drop(wait);
self.close_if_cancelled();
return Err(ManagerError::Closed);
}
opened = opened => opened,
};
drop(wait);
match opened {
Ok(result) => result,
Err(_) => {
let state = self.state.lock();
if state.closed || self.cancel.is_cancelled() {
drop(state);
self.close_if_cancelled();
Err(ManagerError::Closed)
} else {
Ok(state.carrier_lanes.contains_key(&lane_id)
|| state.closed_streams.contains(&lane_id)
|| state.closing_streams.contains_key(&lane_id))
}
}
}
}
pub(super) fn queue_lane_frame_locked(
&self,
state: &mut SessionState,
@@ -571,18 +482,6 @@ impl WebSession {
}
}
struct LaneOpenWaitGuard<'a> {
session: &'a WebSession,
_auxiliary: OwnedSemaphorePermit,
}
impl Drop for LaneOpenWaitGuard<'_> {
fn drop(&mut self) {
let mut state = self.session.state.lock();
state.lane_open_waits = state.lane_open_waits.saturating_sub(1);
}
}
// Lane-specific protocol, replay, and lifecycle tests.
#[cfg(test)]
mod tests;
+118
View File
@@ -0,0 +1,118 @@
use std::time::{Duration, Instant};
use tokio::sync::OwnedSemaphorePermit;
use super::WebSession;
use crate::web::manager::ManagerError;
use crate::web::session::SessionCloseReason;
use crate::web::telemetry::WebSessionLifecycleObservation;
impl WebSession {
/// Waits for a non-control lane to become observable within the bounded admission budget.
pub(super) async fn wait_for_lane_open(
&self,
lane_id: u32,
cursor: u64,
) -> Result<bool, ManagerError> {
if self.close_if_cancelled() {
return Err(ManagerError::Closed);
}
let wait = {
let mut state = self.state.lock();
if state.closed || self.cancel.is_cancelled() {
drop(state);
self.close_if_cancelled();
return Err(ManagerError::Closed);
}
if state.carrier_lanes.contains_key(&lane_id) {
return Ok(true);
}
if cursor != 0 || lane_id == 0 {
drop(state);
self.close(SessionCloseReason::Protocol);
return Err(ManagerError::Protocol);
}
if state.closed_streams.contains(&lane_id)
|| state.closing_streams.contains_key(&lane_id)
{
return Ok(true);
}
if state.lane_open_waits >= self.limits.max_lane_open_waits_per_session {
return Err(ManagerError::Limit);
}
let Some(manager) = self.manager.upgrade() else {
return Err(ManagerError::Closed);
};
let Some(auxiliary) = manager.try_lane_poll(true) else {
return Err(ManagerError::Limit);
};
state.lane_open_waits += 1;
let observation = WebSessionLifecycleObservation::HttpActivityAfterGap;
self.touch_peer_locked(&mut state, Instant::now(), observation);
LaneOpenWaitGuard {
session: self,
_auxiliary: auxiliary,
}
};
let deadline = Duration::from_secs(self.timeouts.lane_open_wait_secs);
let opened = tokio::time::timeout(deadline, async {
loop {
let notified = self.lane_open_notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
{
let state = self.state.lock();
if state.closed || self.cancel.is_cancelled() {
drop(state);
self.close_if_cancelled();
return Err(ManagerError::Closed);
}
if state.carrier_lanes.contains_key(&lane_id)
|| state.closed_streams.contains(&lane_id)
|| state.closing_streams.contains_key(&lane_id)
{
return Ok(true);
}
}
notified.await;
}
});
let opened = tokio::select! {
biased;
_ = self.cancel.cancelled() => {
drop(wait);
self.close_if_cancelled();
return Err(ManagerError::Closed);
}
opened = opened => opened,
};
drop(wait);
match opened {
Ok(result) => result,
Err(_) => {
let state = self.state.lock();
if state.closed || self.cancel.is_cancelled() {
drop(state);
self.close_if_cancelled();
Err(ManagerError::Closed)
} else {
Ok(state.carrier_lanes.contains_key(&lane_id)
|| state.closed_streams.contains(&lane_id)
|| state.closing_streams.contains_key(&lane_id))
}
}
}
}
}
struct LaneOpenWaitGuard<'a> {
session: &'a WebSession,
_auxiliary: OwnedSemaphorePermit,
}
impl Drop for LaneOpenWaitGuard<'_> {
fn drop(&mut self) {
let mut state = self.session.state.lock();
state.lane_open_waits = state.lane_open_waits.saturating_sub(1);
}
}
+2 -254
View File
@@ -299,258 +299,6 @@ impl WebSession {
}
}
// Carrier health publication and negotiation race tests.
#[cfg(test)]
mod tests {
use std::net::SocketAddr;
use std::sync::{Arc, Barrier};
use super::*;
use crate::config::{
WebCarrier, WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig,
};
use crate::web::manager::{CarrierClientClass, WebProcessRuntime};
use crate::web::session::{SessionCloseOutcome, SessionCloseReason};
fn session(carrier: WebCarrier, deadline: Instant) -> Arc<WebSession> {
let profile = Arc::new(WebRuntimeProfile {
host: "proxy.example.com".to_string(),
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
user: "alice".to_string(),
secret_mode: WebSecretMode::Plain,
carrier,
carrier_negotiation_enabled: true,
carrier_learning: false,
carriers: Arc::from([carrier]),
carrier_negotiation_deadlines_secs: [3, 5, 8, 12],
capability: [0; 32],
credential_id: [0; 16],
key_fingerprint: "0000000000000000".to_string(),
max_sessions: 1,
max_streams: 1,
max_streams_per_session: 1,
});
WebSession::new(
std::sync::Weak::<WebProcessRuntime>::new(),
[1; 32],
"192.0.2.10".parse().unwrap(),
1,
profile,
[2; 32],
carrier,
1,
[3; 32],
Some(deadline),
CarrierClientClass::Bridge,
None,
true,
false,
WebLimitsConfig::default(),
WebTimeoutsConfig::default(),
None,
)
}
fn arm_http_health(session: &WebSession, now: Instant) {
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;
state.carrier_health_activity_at = Some(now);
}
#[test]
fn final_deadline_refuses_uncommitted_progress() {
let session = session(WebCarrier::Https, Instant::now() - Duration::from_secs(1));
let state = session.state.lock();
assert_eq!(
session.ensure_carrier_active_locked(&state),
Err(crate::web::manager::ManagerError::Closed)
);
assert!(matches!(
state.negotiation_phase,
SessionNegotiationPhase::Uncommitted
));
}
#[test]
fn http_health_requires_authenticated_activity_after_the_window() {
let session = session(WebCarrier::Https, Instant::now() + Duration::from_secs(60));
let now = Instant::now();
let mut state = session.state.lock();
state.negotiation_phase = SessionNegotiationPhase::Committed;
state.carrier_commit_published = true;
state.carrier_health_due_at = Some(now - Duration::from_secs(1));
state.carrier_health_uplink = true;
state.carrier_health_downlink = true;
state.carrier_health_activity_at = Some(now - Duration::from_secs(2));
assert!(
session
.carrier_health_ready_locked(&mut state, now)
.is_none()
);
state.carrier_health_activity_at = Some(now);
assert!(
session
.carrier_health_ready_locked(&mut state, now)
.is_some()
);
}
#[test]
fn websocket_health_requires_the_exact_live_probe_owner() {
let session = session(
WebCarrier::Websocket,
Instant::now() + Duration::from_secs(60),
);
let now = Instant::now();
let mut state = session.state.lock();
state.negotiation_phase = SessionNegotiationPhase::Committed;
state.carrier_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);
state.websocket_commit_ack_written = true;
state.carrier_health_uplink = true;
assert!(
session
.carrier_health_ready_locked(&mut state, now)
.is_none()
);
state.websocket_probe_claimed = true;
assert!(
session
.carrier_health_ready_locked(&mut state, now)
.is_some()
);
}
#[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)
.is_none()
);
assert_eq!(
session.carrier_health_publication_state(),
CarrierHealthPublicationState::Awaiting
);
}
#[test]
fn health_publication_claim_is_single_shot() {
let session = session(WebCarrier::Https, Instant::now() + Duration::from_secs(60));
let now = Instant::now();
arm_http_health(&session, now);
let mut state = session.state.lock();
assert!(
session
.carrier_health_ready_locked(&mut state, now)
.is_some()
);
assert!(
session
.carrier_health_ready_locked(&mut state, now)
.is_none()
);
drop(state);
assert_eq!(
session.carrier_health_publication_state(),
CarrierHealthPublicationState::Publishing
);
assert!(session.publish_carrier_health());
assert!(!session.publish_carrier_health());
assert_eq!(
session.carrier_health_publication_state(),
CarrierHealthPublicationState::Published
);
}
#[test]
fn concurrent_health_and_close_always_reach_one_terminal_state() {
for _ in 0..512 {
let session = session(WebCarrier::Https, Instant::now() + Duration::from_secs(60));
let now = Instant::now();
arm_http_health(&session, now);
let barrier = Arc::new(Barrier::new(3));
let health_session = Arc::clone(&session);
let health_barrier = Arc::clone(&barrier);
let health = std::thread::spawn(move || {
health_barrier.wait();
std::thread::yield_now();
let claim = {
let mut state = health_session.state.lock();
health_session.carrier_health_ready_locked(&mut state, now)
};
if claim.is_some() {
health_session.publish_carrier_health();
}
});
let close_session = Arc::clone(&session);
let close_barrier = Arc::clone(&barrier);
let close = std::thread::spawn(move || {
close_barrier.wait();
std::thread::yield_now();
close_session.close(SessionCloseReason::ApiClose);
});
barrier.wait();
health.join().unwrap();
close.join().unwrap();
assert!(matches!(
session.carrier_health_publication_state(),
CarrierHealthPublicationState::Published | CarrierHealthPublicationState::Rejected
));
assert!(!session.publish_carrier_health());
assert_eq!(
session.close(SessionCloseReason::ApiClose),
SessionCloseOutcome::AlreadyClosing
);
}
}
#[test]
fn commit_and_supersede_have_one_session_lock_winner() {
let committed = session(WebCarrier::Https, Instant::now() + Duration::from_secs(60));
{
let mut state = committed.state.lock();
assert!(
committed
.record_uplink_progress_locked(
&mut state,
AppliedProgress {
accepted_open: true,
accepted_data: true,
},
)
.0
);
}
assert!(!committed.begin_carrier_supersede());
let replacing = session(WebCarrier::Https, Instant::now() + Duration::from_secs(60));
assert!(replacing.begin_carrier_supersede());
assert_eq!(
replacing.ensure_carrier_active_locked(&replacing.state.lock()),
Err(crate::web::manager::ManagerError::Closed)
);
replacing.cancel_carrier_supersede();
assert!(
replacing
.ensure_carrier_active_locked(&replacing.state.lock())
.is_ok()
);
}
}
mod tests;
+252
View File
@@ -0,0 +1,252 @@
use std::net::SocketAddr;
use std::sync::{Arc, Barrier};
use super::*;
use crate::config::{
WebCarrier, WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig,
};
use crate::web::manager::{CarrierClientClass, WebProcessRuntime};
use crate::web::session::{SessionCloseOutcome, SessionCloseReason};
fn session(carrier: WebCarrier, deadline: Instant) -> Arc<WebSession> {
let profile = Arc::new(WebRuntimeProfile {
host: "proxy.example.com".to_string(),
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
user: "alice".to_string(),
secret_mode: WebSecretMode::Plain,
carrier,
carrier_negotiation_enabled: true,
carrier_learning: false,
carriers: Arc::from([carrier]),
carrier_negotiation_deadlines_secs: [3, 5, 8, 12],
capability: [0; 32],
credential_id: [0; 16],
key_fingerprint: "0000000000000000".to_string(),
max_sessions: 1,
max_streams: 1,
max_streams_per_session: 1,
});
WebSession::new(
std::sync::Weak::<WebProcessRuntime>::new(),
[1; 32],
"192.0.2.10".parse().unwrap(),
1,
profile,
[2; 32],
carrier,
1,
[3; 32],
Some(deadline),
CarrierClientClass::Bridge,
None,
true,
false,
WebLimitsConfig::default(),
WebTimeoutsConfig::default(),
None,
)
}
fn arm_http_health(session: &WebSession, now: Instant) {
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;
state.carrier_health_activity_at = Some(now);
}
#[test]
fn final_deadline_refuses_uncommitted_progress() {
let session = session(WebCarrier::Https, Instant::now() - Duration::from_secs(1));
let state = session.state.lock();
assert_eq!(
session.ensure_carrier_active_locked(&state),
Err(crate::web::manager::ManagerError::Closed)
);
assert!(matches!(
state.negotiation_phase,
SessionNegotiationPhase::Uncommitted
));
}
#[test]
fn http_health_requires_authenticated_activity_after_the_window() {
let session = session(WebCarrier::Https, Instant::now() + Duration::from_secs(60));
let now = Instant::now();
let mut state = session.state.lock();
state.negotiation_phase = SessionNegotiationPhase::Committed;
state.carrier_commit_published = true;
state.carrier_health_due_at = Some(now - Duration::from_secs(1));
state.carrier_health_uplink = true;
state.carrier_health_downlink = true;
state.carrier_health_activity_at = Some(now - Duration::from_secs(2));
assert!(
session
.carrier_health_ready_locked(&mut state, now)
.is_none()
);
state.carrier_health_activity_at = Some(now);
assert!(
session
.carrier_health_ready_locked(&mut state, now)
.is_some()
);
}
#[test]
fn websocket_health_requires_the_exact_live_probe_owner() {
let session = session(
WebCarrier::Websocket,
Instant::now() + Duration::from_secs(60),
);
let now = Instant::now();
let mut state = session.state.lock();
state.negotiation_phase = SessionNegotiationPhase::Committed;
state.carrier_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);
state.websocket_commit_ack_written = true;
state.carrier_health_uplink = true;
assert!(
session
.carrier_health_ready_locked(&mut state, now)
.is_none()
);
state.websocket_probe_claimed = true;
assert!(
session
.carrier_health_ready_locked(&mut state, now)
.is_some()
);
}
#[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)
.is_none()
);
assert_eq!(
session.carrier_health_publication_state(),
CarrierHealthPublicationState::Awaiting
);
}
#[test]
fn health_publication_claim_is_single_shot() {
let session = session(WebCarrier::Https, Instant::now() + Duration::from_secs(60));
let now = Instant::now();
arm_http_health(&session, now);
let mut state = session.state.lock();
assert!(
session
.carrier_health_ready_locked(&mut state, now)
.is_some()
);
assert!(
session
.carrier_health_ready_locked(&mut state, now)
.is_none()
);
drop(state);
assert_eq!(
session.carrier_health_publication_state(),
CarrierHealthPublicationState::Publishing
);
assert!(session.publish_carrier_health());
assert!(!session.publish_carrier_health());
assert_eq!(
session.carrier_health_publication_state(),
CarrierHealthPublicationState::Published
);
}
#[test]
fn concurrent_health_and_close_always_reach_one_terminal_state() {
for _ in 0..512 {
let session = session(WebCarrier::Https, Instant::now() + Duration::from_secs(60));
let now = Instant::now();
arm_http_health(&session, now);
let barrier = Arc::new(Barrier::new(3));
let health_session = Arc::clone(&session);
let health_barrier = Arc::clone(&barrier);
let health = std::thread::spawn(move || {
health_barrier.wait();
std::thread::yield_now();
let claim = {
let mut state = health_session.state.lock();
health_session.carrier_health_ready_locked(&mut state, now)
};
if claim.is_some() {
health_session.publish_carrier_health();
}
});
let close_session = Arc::clone(&session);
let close_barrier = Arc::clone(&barrier);
let close = std::thread::spawn(move || {
close_barrier.wait();
std::thread::yield_now();
close_session.close(SessionCloseReason::ApiClose);
});
barrier.wait();
health.join().unwrap();
close.join().unwrap();
assert!(matches!(
session.carrier_health_publication_state(),
CarrierHealthPublicationState::Published | CarrierHealthPublicationState::Rejected
));
assert!(!session.publish_carrier_health());
assert_eq!(
session.close(SessionCloseReason::ApiClose),
SessionCloseOutcome::AlreadyClosing
);
}
}
#[test]
fn commit_and_supersede_have_one_session_lock_winner() {
let committed = session(WebCarrier::Https, Instant::now() + Duration::from_secs(60));
{
let mut state = committed.state.lock();
assert!(
committed
.record_uplink_progress_locked(
&mut state,
AppliedProgress {
accepted_open: true,
accepted_data: true,
},
)
.0
);
}
assert!(!committed.begin_carrier_supersede());
let replacing = session(WebCarrier::Https, Instant::now() + Duration::from_secs(60));
assert!(replacing.begin_carrier_supersede());
assert_eq!(
replacing.ensure_carrier_active_locked(&replacing.state.lock()),
Err(crate::web::manager::ManagerError::Closed)
);
replacing.cancel_carrier_supersede();
assert!(
replacing
.ensure_carrier_active_locked(&replacing.state.lock())
.is_ok()
);
}