mirror of
https://github.com/telemt/telemt.git
synced 2026-09-10 04:24:08 +03:00
WEB Ephemeral Lifecycle
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
+101
-45
@@ -1,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use http_body_util::Full;
|
||||
use hyper::body::{Bytes, Incoming};
|
||||
@@ -13,12 +13,14 @@ use super::model::ApiFailure;
|
||||
use super::{ALLOW_GET, ALLOW_POST, ApiShared};
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::web::control::{WebRuntimeLifecycle, WebRuntimePublication};
|
||||
use crate::web::manager::{ControlError, SessionDetail, WebProcessRuntime};
|
||||
use crate::web::manager::{
|
||||
ControlError, OperatorLifecycleError, SessionDetail, WebProcessRuntime,
|
||||
};
|
||||
|
||||
// Exact JSON DTOs and strict query parsing stay independent from route dispatch.
|
||||
mod request;
|
||||
use request::{
|
||||
CloseRequest, RuntimeInstanceRequest, parse_session_query, parse_session_ref,
|
||||
CloseRequest, DrainRequest, RuntimeInstanceRequest, parse_session_query, parse_session_ref,
|
||||
valid_runtime_instance,
|
||||
};
|
||||
|
||||
@@ -27,6 +29,9 @@ const SESSIONS_PATH: &str = "/v1/runtime/web/sessions";
|
||||
const CLOSE_PATH: &str = "/v1/runtime/web/sessions/close";
|
||||
const DEBUG_CLEAR_PATH: &str = "/v1/runtime/web/debug/clear";
|
||||
const LEARNING_RESET_PATH: &str = "/v1/runtime/web/carrier-learning/reset";
|
||||
const LIFECYCLE_PAUSE_PATH: &str = "/v1/runtime/web/lifecycle/pause";
|
||||
const LIFECYCLE_DRAIN_PATH: &str = "/v1/runtime/web/lifecycle/drain";
|
||||
const LIFECYCLE_RESUME_PATH: &str = "/v1/runtime/web/lifecycle/resume";
|
||||
const SESSION_DETAIL_PREFIX: &str = "/v1/runtime/web/sessions/";
|
||||
const OPERATION_PREFIX: &str = "/v1/runtime/web/operations/";
|
||||
const MAX_CONTROL_BODY_BYTES: usize = 64 * 1024;
|
||||
@@ -35,7 +40,12 @@ const MAX_CONTROL_BODY_BYTES: usize = 64 * 1024;
|
||||
pub(super) fn allowed_methods(path: &str) -> Option<&'static str> {
|
||||
match path {
|
||||
STATUS_PATH | SESSIONS_PATH => Some(ALLOW_GET),
|
||||
CLOSE_PATH | DEBUG_CLEAR_PATH | LEARNING_RESET_PATH => Some(ALLOW_POST),
|
||||
CLOSE_PATH
|
||||
| DEBUG_CLEAR_PATH
|
||||
| LEARNING_RESET_PATH
|
||||
| LIFECYCLE_PAUSE_PATH
|
||||
| LIFECYCLE_DRAIN_PATH
|
||||
| LIFECYCLE_RESUME_PATH => Some(ALLOW_POST),
|
||||
_ if detail_ref(path).is_some() || operation_ref(path).is_some() => Some(ALLOW_GET),
|
||||
_ => None,
|
||||
}
|
||||
@@ -105,6 +115,67 @@ pub(super) async fn handle(
|
||||
.map_err(control_failure)?;
|
||||
Ok(success_response(StatusCode::OK, status, revision))
|
||||
}
|
||||
("POST", LIFECYCLE_PAUSE_PATH) => {
|
||||
require_mutable(config)?;
|
||||
reject_query(query)?;
|
||||
require_json_content_type(&request)?;
|
||||
let request = read_json::<RuntimeInstanceRequest>(
|
||||
request.into_body(),
|
||||
body_limit.min(MAX_CONTROL_BODY_BYTES),
|
||||
)
|
||||
.await?;
|
||||
let runtime = control_runtime(shared)?;
|
||||
require_runtime_instance(&runtime, &request.runtime_instance)?;
|
||||
let status = runtime.pause_operator().await.map_err(lifecycle_failure)?;
|
||||
shared.runtime_events.record(
|
||||
"api.web.lifecycle.pause.ok",
|
||||
format!("epoch={}", status.epoch),
|
||||
);
|
||||
Ok(success_response(StatusCode::OK, status, revision))
|
||||
}
|
||||
("POST", LIFECYCLE_DRAIN_PATH) => {
|
||||
require_mutable(config)?;
|
||||
reject_query(query)?;
|
||||
require_json_content_type(&request)?;
|
||||
let request = read_json::<DrainRequest>(
|
||||
request.into_body(),
|
||||
body_limit.min(MAX_CONTROL_BODY_BYTES),
|
||||
)
|
||||
.await?;
|
||||
let timeout = drain_timeout(request.timeout_secs)?;
|
||||
let runtime = control_runtime(shared)?;
|
||||
require_runtime_instance(&runtime, &request.runtime_instance)?;
|
||||
let status = runtime
|
||||
.drain_operator(timeout)
|
||||
.await
|
||||
.map_err(lifecycle_failure)?;
|
||||
shared.runtime_events.record(
|
||||
"api.web.lifecycle.drain.accepted",
|
||||
format!(
|
||||
"epoch={} timeout_secs={}",
|
||||
status.epoch, request.timeout_secs
|
||||
),
|
||||
);
|
||||
Ok(success_response(StatusCode::ACCEPTED, status, revision))
|
||||
}
|
||||
("POST", LIFECYCLE_RESUME_PATH) => {
|
||||
require_mutable(config)?;
|
||||
reject_query(query)?;
|
||||
require_json_content_type(&request)?;
|
||||
let request = read_json::<RuntimeInstanceRequest>(
|
||||
request.into_body(),
|
||||
body_limit.min(MAX_CONTROL_BODY_BYTES),
|
||||
)
|
||||
.await?;
|
||||
let runtime = control_runtime(shared)?;
|
||||
require_runtime_instance(&runtime, &request.runtime_instance)?;
|
||||
let status = runtime.resume_operator().await.map_err(lifecycle_failure)?;
|
||||
shared.runtime_events.record(
|
||||
"api.web.lifecycle.resume.ok",
|
||||
format!("epoch={}", status.epoch),
|
||||
);
|
||||
Ok(success_response(StatusCode::OK, status, revision))
|
||||
}
|
||||
("POST", CLOSE_PATH) => {
|
||||
require_mutable(config)?;
|
||||
reject_query(query)?;
|
||||
@@ -195,6 +266,8 @@ struct WebStatusData {
|
||||
listeners: Vec<String>,
|
||||
effective_config_enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
operator_lifecycle: Option<crate::web::manager::OperatorLifecycleStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
runtime: Option<crate::web::manager::WebRuntimeStatus>,
|
||||
}
|
||||
|
||||
@@ -221,6 +294,7 @@ impl WebStatusData {
|
||||
WebRuntimeLifecycle::DeadlineExceeded => "deadline_exceeded",
|
||||
})
|
||||
};
|
||||
let operator_lifecycle = runtime.map(WebProcessRuntime::operator_lifecycle_status);
|
||||
Self {
|
||||
lifecycle: publication.lifecycle.as_str(),
|
||||
lifecycle_epoch: publication.epoch,
|
||||
@@ -233,6 +307,7 @@ impl WebStatusData {
|
||||
.map(ToString::to_string)
|
||||
.collect(),
|
||||
effective_config_enabled,
|
||||
operator_lifecycle,
|
||||
runtime: runtime.map(WebProcessRuntime::try_status),
|
||||
}
|
||||
}
|
||||
@@ -369,6 +444,17 @@ fn control_failure(error: ControlError) -> ApiFailure {
|
||||
}
|
||||
}
|
||||
|
||||
fn lifecycle_failure(error: OperatorLifecycleError) -> ApiFailure {
|
||||
match error {
|
||||
OperatorLifecycleError::Closed => runtime_unavailable(WebRuntimeLifecycle::Draining),
|
||||
OperatorLifecycleError::OperationInProgress => ApiFailure::new(
|
||||
StatusCode::CONFLICT,
|
||||
"web_lifecycle_in_progress",
|
||||
"Another WEB drain operation is active",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_busy() -> ApiFailure {
|
||||
ApiFailure::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -377,6 +463,15 @@ fn snapshot_busy() -> ApiFailure {
|
||||
)
|
||||
}
|
||||
|
||||
fn drain_timeout(timeout_secs: u64) -> Result<Duration, ApiFailure> {
|
||||
if !(1..=3600).contains(&timeout_secs) {
|
||||
return Err(ApiFailure::bad_request(
|
||||
"timeout_secs must be within 1..=3600",
|
||||
));
|
||||
}
|
||||
Ok(Duration::from_secs(timeout_secs))
|
||||
}
|
||||
|
||||
fn reject_query(query: Option<&str>) -> Result<(), ApiFailure> {
|
||||
if query.is_some_and(|query| !query.is_empty()) {
|
||||
return Err(ApiFailure::bad_request(
|
||||
@@ -401,44 +496,5 @@ fn millis(duration: std::time::Duration) -> u64 {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hyper::header::HeaderValue;
|
||||
|
||||
#[test]
|
||||
fn route_table_keeps_status_read_only_and_controls_post_only() {
|
||||
assert_eq!(allowed_methods(STATUS_PATH), Some(ALLOW_GET));
|
||||
assert_eq!(allowed_methods(SESSIONS_PATH), Some(ALLOW_GET));
|
||||
assert_eq!(allowed_methods(CLOSE_PATH), Some(ALLOW_POST));
|
||||
assert_eq!(allowed_methods(DEBUG_CLEAR_PATH), Some(ALLOW_POST));
|
||||
assert_eq!(allowed_methods(LEARNING_RESET_PATH), Some(ALLOW_POST));
|
||||
assert_eq!(
|
||||
allowed_methods("/v1/runtime/web/sessions/ws1.instance.0000000000000001"),
|
||||
Some(ALLOW_GET)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_content_type_is_exact_and_single() {
|
||||
let exact = Request::builder()
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(require_json_content_type(&exact).is_ok());
|
||||
|
||||
let parameterized = Request::builder()
|
||||
.header(CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(require_json_content_type(¶meterized).is_err());
|
||||
|
||||
let mut duplicated = Request::builder()
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(())
|
||||
.unwrap();
|
||||
duplicated
|
||||
.headers_mut()
|
||||
.append(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
assert!(require_json_content_type(&duplicated).is_err());
|
||||
}
|
||||
}
|
||||
#[path = "web_runtime/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -22,6 +22,16 @@ pub(super) struct RuntimeInstanceRequest {
|
||||
pub(super) runtime_instance: String,
|
||||
}
|
||||
|
||||
/// Process-fenced graceful drain request with one bounded relative deadline.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct DrainRequest {
|
||||
/// Random process identifier copied from WEB runtime status.
|
||||
pub(super) runtime_instance: String,
|
||||
/// Relative drain deadline frozen into one monotonic server deadline.
|
||||
pub(super) timeout_secs: u64,
|
||||
}
|
||||
|
||||
/// One process-fenced asynchronous close request.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -331,6 +341,14 @@ mod tests {
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<DrainRequest>(serde_json::json!({
|
||||
"runtime_instance": runtime_instance,
|
||||
"timeout_secs": 30,
|
||||
"extra": true,
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
use super::*;
|
||||
|
||||
use hyper::header::HeaderValue;
|
||||
|
||||
#[test]
|
||||
fn route_table_keeps_status_read_only_and_controls_post_only() {
|
||||
assert_eq!(allowed_methods(STATUS_PATH), Some(ALLOW_GET));
|
||||
assert_eq!(allowed_methods(SESSIONS_PATH), Some(ALLOW_GET));
|
||||
assert_eq!(allowed_methods(CLOSE_PATH), Some(ALLOW_POST));
|
||||
assert_eq!(allowed_methods(DEBUG_CLEAR_PATH), Some(ALLOW_POST));
|
||||
assert_eq!(allowed_methods(LEARNING_RESET_PATH), Some(ALLOW_POST));
|
||||
assert_eq!(allowed_methods(LIFECYCLE_PAUSE_PATH), Some(ALLOW_POST));
|
||||
assert_eq!(allowed_methods(LIFECYCLE_DRAIN_PATH), Some(ALLOW_POST));
|
||||
assert_eq!(allowed_methods(LIFECYCLE_RESUME_PATH), Some(ALLOW_POST));
|
||||
assert_eq!(
|
||||
allowed_methods("/v1/runtime/web/sessions/ws1.instance.0000000000000001"),
|
||||
Some(ALLOW_GET)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_content_type_is_exact_and_single() {
|
||||
let exact = Request::builder()
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(require_json_content_type(&exact).is_ok());
|
||||
|
||||
let parameterized = Request::builder()
|
||||
.header(CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(require_json_content_type(¶meterized).is_err());
|
||||
|
||||
let mut duplicated = Request::builder()
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(())
|
||||
.unwrap();
|
||||
duplicated
|
||||
.headers_mut()
|
||||
.append(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
assert!(require_json_content_type(&duplicated).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_timeout_is_bounded_to_the_public_contract() {
|
||||
assert_eq!(drain_timeout(1).unwrap(), Duration::from_secs(1));
|
||||
assert_eq!(drain_timeout(3600).unwrap(), Duration::from_secs(3600));
|
||||
assert!(drain_timeout(0).is_err());
|
||||
assert!(drain_timeout(3601).is_err());
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
use super::*;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::web::manager::{ManagerError, OperatorLifecycleError};
|
||||
|
||||
fn create_request(bootstrap: &str, hello: &[u8]) -> Vec<u8> {
|
||||
let mut request = format!(
|
||||
"POST /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
hello.len()
|
||||
)
|
||||
.into_bytes();
|
||||
request.extend_from_slice(hello);
|
||||
request
|
||||
}
|
||||
|
||||
fn negotiated_request(
|
||||
bootstrap: &str,
|
||||
hello: &[u8],
|
||||
attempt: u8,
|
||||
failure: Option<&str>,
|
||||
) -> Vec<u8> {
|
||||
let failure = failure
|
||||
.map(|failure| format!("X-Carrier-Failure: {failure}\r\n"))
|
||||
.unwrap_or_default();
|
||||
let mut request = format!(
|
||||
"POST /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nX-Carrier-Capabilities: https,https-lanes,websocket,websocket-lanes\r\nX-Carrier-Attempt: {attempt}\r\n{failure}Content-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
hello.len()
|
||||
)
|
||||
.into_bytes();
|
||||
request.extend_from_slice(hello);
|
||||
request
|
||||
}
|
||||
|
||||
fn token_hash(token: &str) -> crate::web::manager::TokenHash {
|
||||
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(token)
|
||||
.unwrap();
|
||||
Sha256::digest(raw).into()
|
||||
}
|
||||
|
||||
async fn live_runtime() -> (
|
||||
Arc<WebProcessRuntime>,
|
||||
Arc<crate::maestro::generation::RuntimeGeneration>,
|
||||
TcpListener,
|
||||
) {
|
||||
let generation = test_runtime_generation(
|
||||
1,
|
||||
runtime_config([71; 32], WebCarrier::Https),
|
||||
);
|
||||
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
(runtime, generation, listener)
|
||||
}
|
||||
|
||||
fn issue_bootstrap(runtime: &Arc<WebProcessRuntime>) -> String {
|
||||
let profile = runtime
|
||||
.active_generation()
|
||||
.config()
|
||||
.web
|
||||
.runtime
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.profiles[0]
|
||||
.clone();
|
||||
runtime
|
||||
.issue_bootstrap(profile, "192.0.2.10".parse().unwrap())
|
||||
.unwrap()
|
||||
.token
|
||||
}
|
||||
|
||||
async fn create_session(
|
||||
listener: &TcpListener,
|
||||
runtime: &Arc<WebProcessRuntime>,
|
||||
bootstrap: &str,
|
||||
) -> (Vec<u8>, String) {
|
||||
let hello = frame::encode(FrameType::Hello, 0, &[1]);
|
||||
let response = request(listener, runtime, create_request(bootstrap, &hello)).await;
|
||||
let (headers, _) = split_response(&response);
|
||||
assert!(headers.starts_with(b"HTTP/1.1 200"));
|
||||
let token = response_header(headers, "x-session-token").to_string();
|
||||
(hello.to_vec(), token)
|
||||
}
|
||||
|
||||
async fn stop_runtime(
|
||||
runtime: Arc<WebProcessRuntime>,
|
||||
generation: Arc<crate::maestro::generation::RuntimeGeneration>,
|
||||
) {
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
generation.stop_background_tasks().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pause_preserves_decoy_retry_and_exact_session_replay() {
|
||||
let (runtime, generation, listener) = live_runtime().await;
|
||||
let bootstrap = issue_bootstrap(&runtime);
|
||||
let hello = frame::encode(FrameType::Hello, 0, &[1]);
|
||||
|
||||
runtime.pause_operator().await.unwrap();
|
||||
let paused_create = request(
|
||||
&listener,
|
||||
&runtime,
|
||||
create_request(&bootstrap, &hello),
|
||||
)
|
||||
.await;
|
||||
let (paused_headers, _) = split_response(&paused_create);
|
||||
assert!(paused_headers.starts_with(b"HTTP/1.1 503"));
|
||||
assert_eq!(response_header(paused_headers, "retry-after"), "1");
|
||||
|
||||
let profile = runtime
|
||||
.active_generation()
|
||||
.config()
|
||||
.web
|
||||
.runtime
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.profiles[0]
|
||||
.clone();
|
||||
assert!(matches!(
|
||||
runtime.issue_bootstrap(profile, "192.0.2.11".parse().unwrap()),
|
||||
Err(ManagerError::AdmissionPaused)
|
||||
));
|
||||
let bridge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([71; 32]);
|
||||
let decoy = format!(
|
||||
"GET /?bridge={bridge} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.11\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
let decoy = request(&listener, &runtime, decoy).await;
|
||||
let (decoy_headers, decoy_body) = split_response(&decoy);
|
||||
assert!(decoy_headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert!(!decoy_body.windows(11).any(|window| window == b"bootstrap=\""));
|
||||
|
||||
runtime.resume_operator().await.unwrap();
|
||||
let created = request(
|
||||
&listener,
|
||||
&runtime,
|
||||
create_request(&bootstrap, &hello),
|
||||
)
|
||||
.await;
|
||||
let (created_headers, _) = split_response(&created);
|
||||
assert!(created_headers.starts_with(b"HTTP/1.1 200"));
|
||||
let token = response_header(created_headers, "x-session-token").to_string();
|
||||
|
||||
runtime.pause_operator().await.unwrap();
|
||||
let replay = request(
|
||||
&listener,
|
||||
&runtime,
|
||||
create_request(&bootstrap, &hello),
|
||||
)
|
||||
.await;
|
||||
let (replay_headers, _) = split_response(&replay);
|
||||
assert!(replay_headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert_eq!(response_header(replay_headers, "x-session-token"), token);
|
||||
|
||||
stop_runtime(runtime, generation).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paused_open_is_stream_local_and_does_not_charge_limit_hits() {
|
||||
let (runtime, generation, listener) = live_runtime().await;
|
||||
let bootstrap = issue_bootstrap(&runtime);
|
||||
let (_, token) = create_session(&listener, &runtime, &bootstrap).await;
|
||||
let before = serde_json::to_value(runtime.try_status()).unwrap();
|
||||
let limit_hits = before["limit_hits"].as_u64().unwrap();
|
||||
let streams_rejected = before["streams_rejected"].as_u64().unwrap();
|
||||
|
||||
runtime.pause_operator().await.unwrap();
|
||||
let open = frame::encode(FrameType::Open, 7, &[]);
|
||||
let mut uplink = format!(
|
||||
"POST /api/v1/up HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {token}\r\nContent-Type: application/octet-stream\r\nX-Up-Seq: 1\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
open.len()
|
||||
)
|
||||
.into_bytes();
|
||||
uplink.extend_from_slice(&open);
|
||||
let rejected = request(&listener, &runtime, uplink).await;
|
||||
assert!(rejected.starts_with(b"HTTP/1.1 204"));
|
||||
|
||||
let after = serde_json::to_value(runtime.try_status()).unwrap();
|
||||
assert_eq!(after["limit_hits"].as_u64(), Some(limit_hits));
|
||||
assert_eq!(
|
||||
after["streams_rejected"].as_u64(),
|
||||
Some(streams_rejected + 1)
|
||||
);
|
||||
assert_eq!(after["streams"]["live"].as_u64(), Some(0));
|
||||
assert!(
|
||||
runtime
|
||||
.get_session(token_hash(&token), "proxy.example.com")
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
stop_runtime(runtime, generation).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paused_replacement_preserves_old_session_and_attempt_replay() {
|
||||
let generation = test_runtime_generation(
|
||||
1,
|
||||
negotiation_runtime_config(
|
||||
[72; 32],
|
||||
WebCarrier::Https,
|
||||
false,
|
||||
Arc::from([WebCarrier::Https, WebCarrier::HttpsLanes]),
|
||||
),
|
||||
);
|
||||
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let bootstrap = issue_bootstrap(&runtime);
|
||||
let hello = frame::encode(FrameType::Hello, 0, &[1]);
|
||||
let first_request = negotiated_request(&bootstrap, &hello, 1, None);
|
||||
let first = request(&listener, &runtime, first_request.clone()).await;
|
||||
let (first_headers, _) = split_response(&first);
|
||||
assert!(first_headers.starts_with(b"HTTP/1.1 200"));
|
||||
let first_token = response_header(first_headers, "x-session-token").to_string();
|
||||
|
||||
runtime.pause_operator().await.unwrap();
|
||||
let replacement_request = negotiated_request(&bootstrap, &hello, 2, Some("timeout"));
|
||||
let rejected = request(&listener, &runtime, replacement_request.clone()).await;
|
||||
let (rejected_headers, _) = split_response(&rejected);
|
||||
assert!(rejected_headers.starts_with(b"HTTP/1.1 503"));
|
||||
assert_eq!(response_header(rejected_headers, "retry-after"), "1");
|
||||
assert!(
|
||||
runtime
|
||||
.get_session(token_hash(&first_token), "proxy.example.com")
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let replay = request(&listener, &runtime, first_request).await;
|
||||
let (replay_headers, _) = split_response(&replay);
|
||||
assert!(replay_headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert_eq!(response_header(replay_headers, "x-session-token"), first_token);
|
||||
|
||||
runtime.resume_operator().await.unwrap();
|
||||
let replacement = request(&listener, &runtime, replacement_request).await;
|
||||
let (replacement_headers, _) = split_response(&replacement);
|
||||
assert!(replacement_headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert_ne!(
|
||||
response_header(replacement_headers, "x-session-token"),
|
||||
first_token
|
||||
);
|
||||
|
||||
stop_runtime(runtime, generation).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_delete_during_drain_completes_naturally() {
|
||||
let (runtime, generation, listener) = live_runtime().await;
|
||||
let bootstrap = issue_bootstrap(&runtime);
|
||||
let (_, token) = create_session(&listener, &runtime, &bootstrap).await;
|
||||
runtime.drain_operator(Duration::from_secs(30)).await.unwrap();
|
||||
|
||||
let delete = format!(
|
||||
"DELETE /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {token}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
let deleted = request(&listener, &runtime, delete).await;
|
||||
assert!(deleted.starts_with(b"HTTP/1.1 204"));
|
||||
|
||||
let completed = tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
let status = runtime.operator_lifecycle_status();
|
||||
if serde_json::to_value(&status).unwrap()["state"] == "drained" {
|
||||
break status;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let completed = serde_json::to_value(&completed).unwrap();
|
||||
assert_eq!(completed["drain"]["outcome"], "graceful");
|
||||
assert_eq!(completed["drain"]["force_close_signalled"], false);
|
||||
stop_runtime(runtime, generation).await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn deadline_force_closes_all_sessions_and_requires_explicit_resume() {
|
||||
let (runtime, generation, listener) = live_runtime().await;
|
||||
let bootstrap = issue_bootstrap(&runtime);
|
||||
let (_, token) = create_session(&listener, &runtime, &bootstrap).await;
|
||||
|
||||
let accepted = runtime.drain_operator(Duration::from_secs(30)).await.unwrap();
|
||||
assert_eq!(serde_json::to_value(&accepted).unwrap()["state"], "draining");
|
||||
assert!(matches!(
|
||||
runtime.drain_operator(Duration::from_secs(30)).await,
|
||||
Err(OperatorLifecycleError::OperationInProgress)
|
||||
));
|
||||
tokio::time::advance(Duration::from_secs(30)).await;
|
||||
let completed = tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
let status = runtime.operator_lifecycle_status();
|
||||
if serde_json::to_value(&status).unwrap()["state"] == "drained" {
|
||||
break status;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let completed = serde_json::to_value(&completed).unwrap();
|
||||
assert_eq!(completed["drain"]["state"], "completed");
|
||||
assert_eq!(completed["drain"]["outcome"], "forced");
|
||||
assert_eq!(completed["drain"]["force_close_signalled"], true);
|
||||
assert_eq!(completed["admission_open"], false);
|
||||
assert!(
|
||||
runtime
|
||||
.get_session(token_hash(&token), "proxy.example.com")
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let resumed = runtime.resume_operator().await.unwrap();
|
||||
let resumed = serde_json::to_value(&resumed).unwrap();
|
||||
assert_eq!(resumed["state"], "running");
|
||||
assert_eq!(resumed["admission_open"], true);
|
||||
stop_runtime(runtime, generation).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_before_deadline_cancels_drain_without_closing_existing_session() {
|
||||
let (runtime, generation, listener) = live_runtime().await;
|
||||
let bootstrap = issue_bootstrap(&runtime);
|
||||
let (_, token) = create_session(&listener, &runtime, &bootstrap).await;
|
||||
|
||||
runtime.drain_operator(Duration::from_secs(30)).await.unwrap();
|
||||
let still_draining = runtime.pause_operator().await.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&still_draining).unwrap()["state"],
|
||||
"draining"
|
||||
);
|
||||
let resumed = runtime.resume_operator().await.unwrap();
|
||||
let resumed = serde_json::to_value(&resumed).unwrap();
|
||||
assert_eq!(resumed["state"], "running");
|
||||
assert_eq!(resumed["drain"]["state"], "cancelled");
|
||||
assert_eq!(resumed["drain"]["outcome"], "cancelled");
|
||||
assert_eq!(resumed["drain"]["force_close_signalled"], false);
|
||||
assert!(
|
||||
runtime
|
||||
.get_session(token_hash(&token), "proxy.example.com")
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
stop_runtime(runtime, generation).await;
|
||||
}
|
||||
@@ -189,7 +189,10 @@ pub(super) async fn handle_session(
|
||||
response
|
||||
}
|
||||
Err(
|
||||
error @ (ManagerError::Limit | ManagerError::Backpressure | ManagerError::Concurrent),
|
||||
error @ (ManagerError::Limit
|
||||
| ManagerError::Backpressure
|
||||
| ManagerError::Concurrent
|
||||
| ManagerError::AdmissionPaused),
|
||||
) => {
|
||||
runtime.trace().record_profile_lifecycle(
|
||||
client_ip,
|
||||
|
||||
@@ -30,6 +30,9 @@ mod session_policy_tests;
|
||||
// Runtime control integration stays separate from carrier protocol scenarios.
|
||||
#[path = "control_tests.rs"]
|
||||
mod control_tests;
|
||||
// Reversible operator lifecycle coverage stays separate from terminal shutdown tests.
|
||||
#[path = "operator_lifecycle_tests.rs"]
|
||||
mod operator_lifecycle_tests;
|
||||
|
||||
const TEST_CARRIER_DEADLINES_SECS: [u64; 4] = [3, 5, 8, 12];
|
||||
|
||||
|
||||
+15
-1
@@ -34,6 +34,11 @@ mod admission;
|
||||
// Shutdown and expiry work remain outside request-path coordination.
|
||||
mod lifecycle;
|
||||
pub(crate) use lifecycle::WebShutdownOutcome;
|
||||
// Reversible operator admission stays independent from terminal process shutdown.
|
||||
mod operator_lifecycle;
|
||||
pub(crate) use operator_lifecycle::{
|
||||
OperatorLifecycleError, OperatorLifecycleStatus,
|
||||
};
|
||||
// Queue and WebSocket allocations share one process-owned data-plane budget.
|
||||
mod budget;
|
||||
// WebSocket admission, replacement, and liveness are process-scoped.
|
||||
@@ -79,6 +84,8 @@ pub(crate) enum ManagerError {
|
||||
Committed,
|
||||
/// The process or session has stopped accepting work.
|
||||
Closed,
|
||||
/// Operator pause or drain temporarily rejects new WEB work.
|
||||
AdmissionPaused,
|
||||
}
|
||||
|
||||
impl ManagerError {
|
||||
@@ -92,6 +99,7 @@ impl ManagerError {
|
||||
Self::Concurrent => "concurrent",
|
||||
Self::Committed => "committed",
|
||||
Self::Closed => "closed",
|
||||
Self::AdmissionPaused => "admission_paused",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,6 +163,7 @@ pub(crate) struct WebProcessRuntime {
|
||||
websocket_next_id: AtomicU64,
|
||||
websocket_clock: std::time::Instant,
|
||||
websocket_notify: Arc<Notify>,
|
||||
operator_lifecycle: operator_lifecycle::OperatorLifecycle,
|
||||
data_budget: Arc<WebDataBudget>,
|
||||
control_operations: Mutex<control::ControlOperationRegistry>,
|
||||
next_control_operation_id: AtomicU64,
|
||||
@@ -200,8 +209,13 @@ impl WebProcessRuntime {
|
||||
.saturating_sub(limits.websocket_http_connection_reserve);
|
||||
let lane_poll_limit = limits.max_http_handlers / 2;
|
||||
let lane_aux_poll_limit = (lane_poll_limit / 2).max(1);
|
||||
let runtime_instance: Arc<str> =
|
||||
Arc::from(format!("{:032x}", rand::random::<u128>()));
|
||||
let runtime = Arc::new(Self {
|
||||
runtime_instance: Arc::from(format!("{:032x}", rand::random::<u128>())),
|
||||
operator_lifecycle: operator_lifecycle::OperatorLifecycle::new(Arc::clone(
|
||||
&runtime_instance,
|
||||
)),
|
||||
runtime_instance,
|
||||
active_runtime,
|
||||
trace,
|
||||
http_connections: Arc::new(Semaphore::new(limits.max_http_connections)),
|
||||
|
||||
@@ -13,11 +13,21 @@ impl WebProcessRuntime {
|
||||
max_streams: usize,
|
||||
client_ip: IpAddr,
|
||||
public_addr: SocketAddr,
|
||||
) -> Option<u16> {
|
||||
) -> Result<u16, super::ManagerError> {
|
||||
let _operator_admission = match self.try_operator_admission() {
|
||||
Ok(admission) => admission,
|
||||
Err(error) => {
|
||||
self.streams_rejected.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let now = Instant::now();
|
||||
let mut state = self.stream_admission.lock();
|
||||
if state.closed
|
||||
|| state.streams_live >= self.limits.max_streams_global
|
||||
if state.closed {
|
||||
self.streams_rejected.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(super::ManagerError::Closed);
|
||||
}
|
||||
if state.streams_live >= self.limits.max_streams_global
|
||||
|| state
|
||||
.streams_per_profile
|
||||
.get(&profile_key)
|
||||
@@ -33,17 +43,17 @@ impl WebProcessRuntime {
|
||||
{
|
||||
self.streams_rejected.fetch_add(1, Ordering::Relaxed);
|
||||
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||
return None;
|
||||
return Err(super::ManagerError::Limit);
|
||||
}
|
||||
let Some(peer_port) = allocate_stream_port(&mut state, client_ip, public_addr) else {
|
||||
self.streams_rejected.fetch_add(1, Ordering::Relaxed);
|
||||
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||
return None;
|
||||
return Err(super::ManagerError::Limit);
|
||||
};
|
||||
state.streams_live += 1;
|
||||
*state.streams_per_profile.entry(profile_key).or_insert(0) += 1;
|
||||
self.streams_opened.fetch_add(1, Ordering::Relaxed);
|
||||
Some(peer_port)
|
||||
Ok(peer_port)
|
||||
}
|
||||
|
||||
/// Releases one live logical-stream slot after its relay task exits.
|
||||
@@ -60,6 +70,8 @@ impl WebProcessRuntime {
|
||||
}
|
||||
state.streams_live = state.streams_live.saturating_sub(1);
|
||||
decrement_map(&mut state.streams_per_profile, &profile_key);
|
||||
drop(state);
|
||||
self.notify_operator_work_changed();
|
||||
}
|
||||
|
||||
/// Records a logical stream rejected outside manager quota acquisition.
|
||||
|
||||
@@ -66,6 +66,7 @@ impl WebProcessRuntime {
|
||||
if !config.web.enabled || !generation.proxy_shared.is_user_enabled(&profile.user) {
|
||||
return Err(ManagerError::Closed);
|
||||
}
|
||||
let _operator_admission = self.try_operator_admission()?;
|
||||
let now = Instant::now();
|
||||
let mut state = self.state.lock();
|
||||
remove_expired_locked(&mut state, now);
|
||||
|
||||
@@ -80,11 +80,14 @@ impl WebProcessRuntime {
|
||||
remove_bootstrap_locked(&mut state, bootstrap_hash);
|
||||
}
|
||||
self.sessions_closed.fetch_add(1, Ordering::Relaxed);
|
||||
drop(state);
|
||||
self.notify_operator_work_changed();
|
||||
}
|
||||
|
||||
/// Closes every WEB authority gate before any graceful wait begins.
|
||||
pub(crate) fn begin_shutdown(self: &std::sync::Arc<Self>) -> WebShutdownDrain {
|
||||
let started = TokioInstant::now();
|
||||
self.close_operator_lifecycle();
|
||||
self.shutdown.cancel();
|
||||
self.close_control_submission_gate();
|
||||
self.close_websockets();
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use parking_lot::Mutex;
|
||||
use tokio::sync::{Mutex as AsyncMutex, Notify};
|
||||
use tokio::time::Instant as TokioInstant;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::WebProcessRuntime;
|
||||
|
||||
// Serialized state-machine values stay separate from synchronization mechanics.
|
||||
mod status;
|
||||
pub(crate) use status::{
|
||||
OperatorDrainOutcome, OperatorDrainState, OperatorDrainStatus, OperatorLifecycleState,
|
||||
OperatorLifecycleStatus,
|
||||
};
|
||||
|
||||
const OPERATOR_ADMISSION_CLOSED: usize = 1 << (usize::BITS - 1);
|
||||
const OPERATOR_REGISTRATION_COUNT: usize = OPERATOR_ADMISSION_CLOSED - 1;
|
||||
const DRAIN_REF_VERSION: &str = "wd1";
|
||||
|
||||
/// Stable operator-control rejection category.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum OperatorLifecycleError {
|
||||
/// Process shutdown terminally closed the command gate.
|
||||
Closed,
|
||||
/// One drain already owns the single active operation slot.
|
||||
OperationInProgress,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
struct WorkCounts {
|
||||
sessions: usize,
|
||||
streams: usize,
|
||||
websockets: usize,
|
||||
}
|
||||
|
||||
impl WorkCounts {
|
||||
fn is_zero(self) -> bool {
|
||||
self.sessions == 0 && self.streams == 0 && self.websockets == 0
|
||||
}
|
||||
}
|
||||
|
||||
struct OperatorAdmission {
|
||||
state: AtomicUsize,
|
||||
registrations_drained: Notify,
|
||||
}
|
||||
|
||||
pub(super) struct OperatorRegistration<'a> {
|
||||
admission: &'a OperatorAdmission,
|
||||
}
|
||||
|
||||
impl OperatorAdmission {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
state: AtomicUsize::new(0),
|
||||
registrations_drained: Notify::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_register(&self) -> Option<OperatorRegistration<'_>> {
|
||||
let mut state = self.state.load(Ordering::Acquire);
|
||||
loop {
|
||||
if state & OPERATOR_ADMISSION_CLOSED != 0
|
||||
|| state & OPERATOR_REGISTRATION_COUNT == OPERATOR_REGISTRATION_COUNT
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match self.state.compare_exchange_weak(
|
||||
state,
|
||||
state + 1,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
) {
|
||||
Ok(_) => return Some(OperatorRegistration { admission: self }),
|
||||
Err(observed) => state = observed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close(&self) {
|
||||
self.state
|
||||
.fetch_or(OPERATOR_ADMISSION_CLOSED, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
fn reopen(&self) {
|
||||
self.state
|
||||
.fetch_and(!OPERATOR_ADMISSION_CLOSED, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
fn is_closed(&self) -> bool {
|
||||
self.state.load(Ordering::Acquire) & OPERATOR_ADMISSION_CLOSED != 0
|
||||
}
|
||||
|
||||
async fn wait_for_registrations(&self) {
|
||||
loop {
|
||||
let notified = self.registrations_drained.notified();
|
||||
if self.state.load(Ordering::Acquire) & OPERATOR_REGISTRATION_COUNT == 0 {
|
||||
return;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OperatorRegistration<'_> {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.admission.state.fetch_sub(1, Ordering::AcqRel);
|
||||
if previous & OPERATOR_REGISTRATION_COUNT == 1 {
|
||||
self.admission.registrations_drained.notify_waiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActiveDrain {
|
||||
sequence: u64,
|
||||
cancellation: CancellationToken,
|
||||
}
|
||||
|
||||
struct OperatorLifecycleInner {
|
||||
state: OperatorLifecycleState,
|
||||
epoch: u64,
|
||||
since: Instant,
|
||||
terminal: bool,
|
||||
active: Option<ActiveDrain>,
|
||||
drain: Option<OperatorDrainStatus>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct OperatorSnapshot {
|
||||
state: OperatorLifecycleState,
|
||||
epoch: u64,
|
||||
since: Instant,
|
||||
terminal: bool,
|
||||
drain: Option<OperatorDrainStatus>,
|
||||
}
|
||||
|
||||
pub(super) struct OperatorLifecycle {
|
||||
runtime_instance: Arc<str>,
|
||||
admission: OperatorAdmission,
|
||||
commands: AsyncMutex<()>,
|
||||
inner: Mutex<OperatorLifecycleInner>,
|
||||
published: ArcSwap<OperatorSnapshot>,
|
||||
work_changed: Notify,
|
||||
next_operation_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl OperatorLifecycle {
|
||||
pub(super) fn new(runtime_instance: Arc<str>) -> Self {
|
||||
let since = Instant::now();
|
||||
let snapshot = OperatorSnapshot {
|
||||
state: OperatorLifecycleState::Running,
|
||||
epoch: 0,
|
||||
since,
|
||||
terminal: false,
|
||||
drain: None,
|
||||
};
|
||||
Self {
|
||||
runtime_instance,
|
||||
admission: OperatorAdmission::new(),
|
||||
commands: AsyncMutex::new(()),
|
||||
inner: Mutex::new(OperatorLifecycleInner {
|
||||
state: snapshot.state,
|
||||
epoch: snapshot.epoch,
|
||||
since,
|
||||
terminal: false,
|
||||
active: None,
|
||||
drain: None,
|
||||
}),
|
||||
published: ArcSwap::from_pointee(snapshot),
|
||||
work_changed: Notify::new(),
|
||||
next_operation_id: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn try_register(&self) -> Option<OperatorRegistration<'_>> {
|
||||
self.admission.try_register()
|
||||
}
|
||||
|
||||
pub(super) fn notify_work_changed(&self) {
|
||||
if self.admission.is_closed() {
|
||||
self.work_changed.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn status(&self, config_enabled: bool) -> OperatorLifecycleStatus {
|
||||
let snapshot = self.published.load();
|
||||
let admission_open = !snapshot.terminal
|
||||
&& snapshot.state == OperatorLifecycleState::Running;
|
||||
OperatorLifecycleStatus {
|
||||
state: snapshot.state,
|
||||
epoch: snapshot.epoch,
|
||||
age_ms: millis(Instant::now().saturating_duration_since(snapshot.since)),
|
||||
admission_open,
|
||||
effective_new_work_admission: admission_open && config_enabled,
|
||||
drain: snapshot.drain.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_terminal(&self) -> bool {
|
||||
self.published.load().terminal
|
||||
}
|
||||
|
||||
fn publish_locked(&self, inner: &OperatorLifecycleInner) {
|
||||
self.published.store(Arc::new(OperatorSnapshot {
|
||||
state: inner.state,
|
||||
epoch: inner.epoch,
|
||||
since: inner.since,
|
||||
terminal: inner.terminal,
|
||||
drain: inner.drain.clone(),
|
||||
}));
|
||||
}
|
||||
|
||||
fn transition_locked(
|
||||
&self,
|
||||
inner: &mut OperatorLifecycleInner,
|
||||
state: OperatorLifecycleState,
|
||||
) {
|
||||
if inner.state != state {
|
||||
inner.state = state;
|
||||
inner.epoch = inner.epoch.saturating_add(1);
|
||||
inner.since = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
fn update_counts(&self, sequence: u64, counts: WorkCounts) -> bool {
|
||||
let mut inner = self.inner.lock();
|
||||
if inner
|
||||
.active
|
||||
.as_ref()
|
||||
.is_none_or(|active| active.sequence != sequence)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(drain) = inner.drain.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if drain.remaining_sessions != counts.sessions
|
||||
|| drain.remaining_streams != counts.streams
|
||||
|| drain.remaining_websockets != counts.websockets
|
||||
{
|
||||
drain.remaining_sessions = counts.sessions;
|
||||
drain.remaining_streams = counts.streams;
|
||||
drain.remaining_websockets = counts.websockets;
|
||||
self.publish_locked(&inner);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn commit_force(&self, sequence: u64, counts: WorkCounts) -> bool {
|
||||
let mut inner = self.inner.lock();
|
||||
if inner
|
||||
.active
|
||||
.as_ref()
|
||||
.is_none_or(|active| active.sequence != sequence)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.transition_locked(&mut inner, OperatorLifecycleState::ForceClosing);
|
||||
let Some(drain) = inner.drain.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
drain.state = OperatorDrainState::ForceClosing;
|
||||
drain.remaining_sessions = counts.sessions;
|
||||
drain.remaining_streams = counts.streams;
|
||||
drain.remaining_websockets = counts.websockets;
|
||||
drain.force_close_signalled = true;
|
||||
self.publish_locked(&inner);
|
||||
true
|
||||
}
|
||||
|
||||
fn complete(&self, sequence: u64, forced: bool) {
|
||||
let mut inner = self.inner.lock();
|
||||
if inner
|
||||
.active
|
||||
.as_ref()
|
||||
.is_none_or(|active| active.sequence != sequence)
|
||||
{
|
||||
return;
|
||||
}
|
||||
inner.active = None;
|
||||
self.transition_locked(&mut inner, OperatorLifecycleState::Drained);
|
||||
if let Some(drain) = inner.drain.as_mut() {
|
||||
drain.state = OperatorDrainState::Completed;
|
||||
drain.outcome = Some(if forced {
|
||||
OperatorDrainOutcome::Forced
|
||||
} else {
|
||||
OperatorDrainOutcome::Graceful
|
||||
});
|
||||
drain.completed_epoch_millis = Some(crate::web::trace::store_epoch_millis());
|
||||
drain.remaining_sessions = 0;
|
||||
drain.remaining_streams = 0;
|
||||
drain.remaining_websockets = 0;
|
||||
}
|
||||
self.publish_locked(&inner);
|
||||
}
|
||||
|
||||
fn close_terminal(&self) {
|
||||
let mut inner = self.inner.lock();
|
||||
self.admission.close();
|
||||
if inner.terminal {
|
||||
return;
|
||||
}
|
||||
inner.terminal = true;
|
||||
if let Some(active) = inner.active.take() {
|
||||
active.cancellation.cancel();
|
||||
if let Some(drain) = inner.drain.as_mut() {
|
||||
drain.state = OperatorDrainState::Cancelled;
|
||||
drain.outcome = Some(OperatorDrainOutcome::Cancelled);
|
||||
drain.completed_epoch_millis = Some(crate::web::trace::store_epoch_millis());
|
||||
}
|
||||
}
|
||||
self.publish_locked(&inner);
|
||||
self.work_changed.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
impl WebProcessRuntime {
|
||||
/// Returns the current lock-free operator lifecycle snapshot.
|
||||
pub(crate) fn operator_lifecycle_status(&self) -> OperatorLifecycleStatus {
|
||||
let config_enabled = self.active_generation().config().web.enabled;
|
||||
self.operator_lifecycle.status(config_enabled)
|
||||
}
|
||||
|
||||
/// Pauses new WEB work after every pre-cutover admission section completes.
|
||||
pub(crate) async fn pause_operator(
|
||||
self: &Arc<Self>,
|
||||
) -> Result<OperatorLifecycleStatus, OperatorLifecycleError> {
|
||||
let _command = self.operator_lifecycle.commands.lock().await;
|
||||
{
|
||||
let mut inner = self.operator_lifecycle.inner.lock();
|
||||
if inner.terminal || self.shutdown.is_cancelled() {
|
||||
return Err(OperatorLifecycleError::Closed);
|
||||
}
|
||||
self.operator_lifecycle.admission.close();
|
||||
if matches!(
|
||||
inner.state,
|
||||
OperatorLifecycleState::Running | OperatorLifecycleState::Drained
|
||||
) {
|
||||
self.operator_lifecycle
|
||||
.transition_locked(&mut inner, OperatorLifecycleState::Paused);
|
||||
}
|
||||
self.operator_lifecycle.publish_locked(&inner);
|
||||
}
|
||||
self.operator_lifecycle
|
||||
.admission
|
||||
.wait_for_registrations()
|
||||
.await;
|
||||
if self.shutdown.is_cancelled() || self.operator_lifecycle.is_terminal() {
|
||||
return Err(OperatorLifecycleError::Closed);
|
||||
}
|
||||
Ok(self.operator_lifecycle_status())
|
||||
}
|
||||
|
||||
/// Starts one asynchronous graceful WEB drain under an absolute deadline.
|
||||
pub(crate) async fn drain_operator(
|
||||
self: &Arc<Self>,
|
||||
timeout: Duration,
|
||||
) -> Result<OperatorLifecycleStatus, OperatorLifecycleError> {
|
||||
let _command = self.operator_lifecycle.commands.lock().await;
|
||||
let sequence = self
|
||||
.operator_lifecycle
|
||||
.next_operation_id
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
let cancellation = CancellationToken::new();
|
||||
let started = TokioInstant::now();
|
||||
let deadline = started + timeout;
|
||||
let started_epoch_millis = crate::web::trace::store_epoch_millis();
|
||||
{
|
||||
let mut inner = self.operator_lifecycle.inner.lock();
|
||||
if inner.terminal || self.shutdown.is_cancelled() {
|
||||
return Err(OperatorLifecycleError::Closed);
|
||||
}
|
||||
if inner.active.is_some()
|
||||
|| matches!(
|
||||
inner.state,
|
||||
OperatorLifecycleState::Draining | OperatorLifecycleState::ForceClosing
|
||||
)
|
||||
{
|
||||
return Err(OperatorLifecycleError::OperationInProgress);
|
||||
}
|
||||
self.operator_lifecycle.admission.close();
|
||||
inner.active = Some(ActiveDrain {
|
||||
sequence,
|
||||
cancellation: cancellation.clone(),
|
||||
});
|
||||
inner.drain = Some(OperatorDrainStatus {
|
||||
operation_id: format!(
|
||||
"{DRAIN_REF_VERSION}.{}.{sequence:016x}",
|
||||
self.operator_lifecycle.runtime_instance
|
||||
),
|
||||
state: OperatorDrainState::Draining,
|
||||
outcome: None,
|
||||
timeout_secs: timeout.as_secs(),
|
||||
started_epoch_millis,
|
||||
deadline_epoch_millis: started_epoch_millis
|
||||
.saturating_add(timeout.as_millis().min(u128::from(u64::MAX)) as u64),
|
||||
completed_epoch_millis: None,
|
||||
remaining_sessions: 0,
|
||||
remaining_streams: 0,
|
||||
remaining_websockets: 0,
|
||||
force_close_signalled: false,
|
||||
});
|
||||
self.operator_lifecycle
|
||||
.transition_locked(&mut inner, OperatorLifecycleState::Draining);
|
||||
self.operator_lifecycle.publish_locked(&inner);
|
||||
}
|
||||
self.operator_lifecycle
|
||||
.admission
|
||||
.wait_for_registrations()
|
||||
.await;
|
||||
if self.shutdown.is_cancelled() || self.operator_lifecycle.is_terminal() {
|
||||
return Err(OperatorLifecycleError::Closed);
|
||||
}
|
||||
let counts = self.operator_work_counts();
|
||||
if !self.operator_lifecycle.update_counts(sequence, counts) {
|
||||
return Err(OperatorLifecycleError::Closed);
|
||||
}
|
||||
let runtime = Arc::clone(self);
|
||||
self.spawn_auxiliary(async move {
|
||||
runtime
|
||||
.run_operator_drain(sequence, deadline, cancellation)
|
||||
.await;
|
||||
});
|
||||
Ok(self.operator_lifecycle_status())
|
||||
}
|
||||
|
||||
/// Resumes operator admission and invalidates any active drain waiter.
|
||||
pub(crate) async fn resume_operator(
|
||||
self: &Arc<Self>,
|
||||
) -> Result<OperatorLifecycleStatus, OperatorLifecycleError> {
|
||||
let _command = self.operator_lifecycle.commands.lock().await;
|
||||
let counts = self.operator_work_counts();
|
||||
let mut inner = self.operator_lifecycle.inner.lock();
|
||||
if inner.terminal || self.shutdown.is_cancelled() {
|
||||
return Err(OperatorLifecycleError::Closed);
|
||||
}
|
||||
if let Some(active) = inner.active.take() {
|
||||
active.cancellation.cancel();
|
||||
if let Some(drain) = inner.drain.as_mut() {
|
||||
drain.state = OperatorDrainState::Cancelled;
|
||||
drain.outcome = Some(OperatorDrainOutcome::Cancelled);
|
||||
drain.completed_epoch_millis = Some(crate::web::trace::store_epoch_millis());
|
||||
drain.remaining_sessions = counts.sessions;
|
||||
drain.remaining_streams = counts.streams;
|
||||
drain.remaining_websockets = counts.websockets;
|
||||
}
|
||||
}
|
||||
self.operator_lifecycle
|
||||
.transition_locked(&mut inner, OperatorLifecycleState::Running);
|
||||
self.operator_lifecycle.admission.reopen();
|
||||
self.operator_lifecycle.publish_locked(&inner);
|
||||
drop(inner);
|
||||
self.operator_lifecycle.work_changed.notify_waiters();
|
||||
Ok(self.operator_lifecycle_status())
|
||||
}
|
||||
|
||||
pub(super) fn try_operator_admission(
|
||||
&self,
|
||||
) -> Result<OperatorRegistration<'_>, super::ManagerError> {
|
||||
self.operator_lifecycle
|
||||
.try_register()
|
||||
.ok_or(super::ManagerError::AdmissionPaused)
|
||||
}
|
||||
|
||||
pub(super) fn notify_operator_work_changed(&self) {
|
||||
self.operator_lifecycle.notify_work_changed();
|
||||
}
|
||||
|
||||
pub(super) fn close_operator_lifecycle(&self) {
|
||||
self.operator_lifecycle.close_terminal();
|
||||
}
|
||||
|
||||
fn operator_work_counts(&self) -> WorkCounts {
|
||||
let sessions = self.state.lock().sessions.len();
|
||||
let streams = self.stream_admission.lock().streams_live;
|
||||
let websockets = self.websockets.lock().status().entries;
|
||||
WorkCounts {
|
||||
sessions,
|
||||
streams,
|
||||
websockets,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_operator_drain(
|
||||
self: Arc<Self>,
|
||||
sequence: u64,
|
||||
deadline: TokioInstant,
|
||||
cancellation: CancellationToken,
|
||||
) {
|
||||
let mut forced = false;
|
||||
loop {
|
||||
let notified = self.operator_lifecycle.work_changed.notified();
|
||||
let counts = self.operator_work_counts();
|
||||
if !self.operator_lifecycle.update_counts(sequence, counts) {
|
||||
return;
|
||||
}
|
||||
if counts.is_zero() {
|
||||
self.operator_lifecycle.complete(sequence, forced);
|
||||
return;
|
||||
}
|
||||
if forced {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => return,
|
||||
_ = notified => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => return,
|
||||
_ = notified => {},
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
let counts = self.operator_work_counts();
|
||||
if counts.is_zero() {
|
||||
self.operator_lifecycle.complete(sequence, false);
|
||||
return;
|
||||
}
|
||||
// Freeze the close set while admission is still closed. If resume
|
||||
// wins the following epoch check, this snapshot is discarded.
|
||||
let sessions = self
|
||||
.state
|
||||
.lock()
|
||||
.sessions
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !self.operator_lifecycle.commit_force(sequence, counts) {
|
||||
return;
|
||||
}
|
||||
forced = true;
|
||||
for session in sessions {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn millis(duration: Duration) -> u64 {
|
||||
duration.as_millis().min(u128::from(u64::MAX)) as u64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "operator_lifecycle/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,90 @@
|
||||
use serde::Serialize;
|
||||
|
||||
/// Reversible operator lifecycle state independent from terminal WEB shutdown.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum OperatorLifecycleState {
|
||||
/// Operator admission is open subject to config and generation policy.
|
||||
Running,
|
||||
/// New WEB work is paused without closing existing work.
|
||||
Paused,
|
||||
/// Existing WEB work is completing before the absolute deadline.
|
||||
Draining,
|
||||
/// The deadline fired and close signals were sent to remaining sessions.
|
||||
ForceClosing,
|
||||
/// Every tracked WEB session, stream, and WebSocket completed.
|
||||
Drained,
|
||||
}
|
||||
|
||||
/// Current or retained drain-operation phase.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum OperatorDrainState {
|
||||
/// Existing work is inside its graceful completion interval.
|
||||
Draining,
|
||||
/// Forced session closure was signalled and zero is not confirmed yet.
|
||||
ForceClosing,
|
||||
/// The operation reached confirmed zero.
|
||||
Completed,
|
||||
/// Explicit resume or terminal process shutdown cancelled the waiter.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Terminal result retained for the latest drain operation.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum OperatorDrainOutcome {
|
||||
/// All tracked work completed before forced closure was committed.
|
||||
Graceful,
|
||||
/// Tracked work reached zero after the deadline forced session closure.
|
||||
Forced,
|
||||
/// The drain waiter was cancelled before confirmed zero.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// API-visible status for the active or latest process-local drain.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub(crate) struct OperatorDrainStatus {
|
||||
/// Opaque process-fenced drain identifier.
|
||||
pub(crate) operation_id: String,
|
||||
/// Current operation phase.
|
||||
pub(crate) state: OperatorDrainState,
|
||||
/// Terminal outcome when the operation no longer waits for zero.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) outcome: Option<OperatorDrainOutcome>,
|
||||
/// Frozen relative deadline accepted from the API.
|
||||
pub(crate) timeout_secs: u64,
|
||||
/// Wall-clock projection retained only for operator correlation.
|
||||
pub(crate) started_epoch_millis: u64,
|
||||
/// Wall-clock projection of the monotonic deadline.
|
||||
pub(crate) deadline_epoch_millis: u64,
|
||||
/// Wall-clock completion or cancellation time.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) completed_epoch_millis: Option<u64>,
|
||||
/// Most recent exact live-session sample.
|
||||
pub(crate) remaining_sessions: usize,
|
||||
/// Most recent exact logical-stream ownership sample.
|
||||
pub(crate) remaining_streams: usize,
|
||||
/// Most recent exact session-owned WebSocket sample.
|
||||
pub(crate) remaining_websockets: usize,
|
||||
/// Whether the deadline won and committed the forced-close snapshot.
|
||||
pub(crate) force_close_signalled: bool,
|
||||
}
|
||||
|
||||
/// API-visible snapshot of reversible WEB operator lifecycle state.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub(crate) struct OperatorLifecycleStatus {
|
||||
/// Stable process-local state machine value.
|
||||
pub(crate) state: OperatorLifecycleState,
|
||||
/// Monotonic state-transition epoch independent from config revision.
|
||||
pub(crate) epoch: u64,
|
||||
/// Monotonic age of the current state.
|
||||
pub(crate) age_ms: u64,
|
||||
/// Whether the operator-owned admission fence is open.
|
||||
pub(crate) admission_open: bool,
|
||||
/// Operator and effective config admission conjunction.
|
||||
pub(crate) effective_new_work_admission: bool,
|
||||
/// Active or latest drain retained until replacement or process restart.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) drain: Option<OperatorDrainStatus>,
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use tokio::sync::Barrier;
|
||||
|
||||
use super::*;
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::maestro::generation::test_runtime_generation;
|
||||
|
||||
fn test_runtime() -> (
|
||||
Arc<WebProcessRuntime>,
|
||||
Arc<crate::maestro::generation::RuntimeGeneration>,
|
||||
) {
|
||||
let generation = test_runtime_generation(1, ProxyConfig::default());
|
||||
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||
(runtime, generation)
|
||||
}
|
||||
|
||||
async fn stop_runtime(
|
||||
runtime: Arc<WebProcessRuntime>,
|
||||
generation: Arc<crate::maestro::generation::RuntimeGeneration>,
|
||||
) {
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
generation.stop_background_tasks().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pause_is_idempotent_and_resume_advances_only_real_transitions() {
|
||||
let (runtime, generation) = test_runtime();
|
||||
let initial = runtime.operator_lifecycle_status();
|
||||
assert_eq!(initial.state, OperatorLifecycleState::Running);
|
||||
assert_eq!(initial.epoch, 0);
|
||||
|
||||
let paused = runtime.pause_operator().await.unwrap();
|
||||
assert_eq!(paused.state, OperatorLifecycleState::Paused);
|
||||
assert!(!paused.admission_open);
|
||||
let repeated_pause = runtime.pause_operator().await.unwrap();
|
||||
assert_eq!(repeated_pause.epoch, paused.epoch);
|
||||
|
||||
let resumed = runtime.resume_operator().await.unwrap();
|
||||
assert_eq!(resumed.state, OperatorLifecycleState::Running);
|
||||
assert!(resumed.admission_open);
|
||||
let repeated_resume = runtime.resume_operator().await.unwrap();
|
||||
assert_eq!(repeated_resume.epoch, resumed.epoch);
|
||||
|
||||
stop_runtime(runtime, generation).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pause_waits_for_pre_cutover_admission_and_rejects_late_registration() {
|
||||
let (runtime, generation) = test_runtime();
|
||||
let registration = runtime.try_operator_admission().unwrap();
|
||||
let pause_runtime = Arc::clone(&runtime);
|
||||
let pause = tokio::spawn(async move { pause_runtime.pause_operator().await.unwrap() });
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
assert!(!pause.is_finished());
|
||||
assert_eq!(
|
||||
runtime.try_operator_admission().err(),
|
||||
Some(super::super::ManagerError::AdmissionPaused)
|
||||
);
|
||||
drop(registration);
|
||||
let paused = pause.await.unwrap();
|
||||
assert_eq!(paused.state, OperatorLifecycleState::Paused);
|
||||
|
||||
stop_runtime(runtime, generation).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn pause_fence_leaves_no_late_admission_commits_under_scheduler_pressure() {
|
||||
const ATTEMPTS: usize = 10_000;
|
||||
|
||||
let (runtime, generation) = test_runtime();
|
||||
let start = Arc::new(Barrier::new(ATTEMPTS + 1));
|
||||
let committed = Arc::new(AtomicUsize::new(0));
|
||||
let mut attempts = tokio::task::JoinSet::new();
|
||||
for _ in 0..ATTEMPTS {
|
||||
let runtime = Arc::clone(&runtime);
|
||||
let start = Arc::clone(&start);
|
||||
let committed = Arc::clone(&committed);
|
||||
attempts.spawn(async move {
|
||||
start.wait().await;
|
||||
if let Ok(_registration) = runtime.try_operator_admission() {
|
||||
tokio::task::yield_now().await;
|
||||
committed.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
});
|
||||
}
|
||||
start.wait().await;
|
||||
runtime.pause_operator().await.unwrap();
|
||||
let committed_at_pause = committed.load(Ordering::Acquire);
|
||||
while let Some(result) = attempts.join_next().await {
|
||||
result.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(committed.load(Ordering::Acquire), committed_at_pause);
|
||||
assert!(matches!(
|
||||
runtime.try_operator_admission(),
|
||||
Err(super::super::ManagerError::AdmissionPaused)
|
||||
));
|
||||
stop_runtime(runtime, generation).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_drain_completes_gracefully_and_stays_closed_until_resume() {
|
||||
let (runtime, generation) = test_runtime();
|
||||
let accepted = runtime.drain_operator(Duration::from_secs(30)).await.unwrap();
|
||||
assert_eq!(accepted.state, OperatorLifecycleState::Draining);
|
||||
|
||||
let completed = tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
let status = runtime.operator_lifecycle_status();
|
||||
if status.state == OperatorLifecycleState::Drained {
|
||||
break status;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let drain = completed.drain.unwrap();
|
||||
assert_eq!(drain.state, OperatorDrainState::Completed);
|
||||
assert_eq!(drain.outcome, Some(OperatorDrainOutcome::Graceful));
|
||||
assert!(!completed.admission_open);
|
||||
|
||||
let resumed = runtime.resume_operator().await.unwrap();
|
||||
assert_eq!(resumed.state, OperatorLifecycleState::Running);
|
||||
stop_runtime(runtime, generation).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_after_force_commit_cancels_wait_but_preserves_force_evidence() {
|
||||
let (runtime, generation) = test_runtime();
|
||||
let sequence = 7;
|
||||
let cancellation = CancellationToken::new();
|
||||
{
|
||||
let mut inner = runtime.operator_lifecycle.inner.lock();
|
||||
runtime.operator_lifecycle.admission.close();
|
||||
inner.active = Some(ActiveDrain {
|
||||
sequence,
|
||||
cancellation,
|
||||
});
|
||||
inner.drain = Some(OperatorDrainStatus {
|
||||
operation_id: format!("wd1.{}.{sequence:016x}", runtime.runtime_instance()),
|
||||
state: OperatorDrainState::Draining,
|
||||
outcome: None,
|
||||
timeout_secs: 30,
|
||||
started_epoch_millis: 1,
|
||||
deadline_epoch_millis: 30_001,
|
||||
completed_epoch_millis: None,
|
||||
remaining_sessions: 1,
|
||||
remaining_streams: 0,
|
||||
remaining_websockets: 0,
|
||||
force_close_signalled: false,
|
||||
});
|
||||
runtime
|
||||
.operator_lifecycle
|
||||
.transition_locked(&mut inner, OperatorLifecycleState::Draining);
|
||||
runtime.operator_lifecycle.publish_locked(&inner);
|
||||
}
|
||||
assert!(runtime.operator_lifecycle.commit_force(
|
||||
sequence,
|
||||
WorkCounts {
|
||||
sessions: 1,
|
||||
streams: 0,
|
||||
websockets: 0,
|
||||
}
|
||||
));
|
||||
|
||||
let resumed = runtime.resume_operator().await.unwrap();
|
||||
assert_eq!(resumed.state, OperatorLifecycleState::Running);
|
||||
let drain = resumed.drain.unwrap();
|
||||
assert_eq!(drain.state, OperatorDrainState::Cancelled);
|
||||
assert_eq!(drain.outcome, Some(OperatorDrainOutcome::Cancelled));
|
||||
assert!(drain.force_close_signalled);
|
||||
stop_runtime(runtime, generation).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_cannot_reopen_operator_pause_and_resume_cannot_override_disabled_config() {
|
||||
let mut initial_config = ProxyConfig::default();
|
||||
initial_config.web.enabled = true;
|
||||
let initial = test_runtime_generation(1, initial_config);
|
||||
let active = Arc::new(ArcSwap::from(Arc::clone(&initial)));
|
||||
let runtime = WebProcessRuntime::start(Arc::clone(&active));
|
||||
let paused = runtime.pause_operator().await.unwrap();
|
||||
assert!(!paused.admission_open);
|
||||
|
||||
let disabled = test_runtime_generation(2, ProxyConfig::default());
|
||||
active.store(Arc::clone(&disabled));
|
||||
let after_reload = runtime.operator_lifecycle_status();
|
||||
assert_eq!(after_reload.state, OperatorLifecycleState::Paused);
|
||||
let resumed = runtime.resume_operator().await.unwrap();
|
||||
assert!(resumed.admission_open);
|
||||
assert!(!resumed.effective_new_work_admission);
|
||||
|
||||
runtime.shutdown().await;
|
||||
initial.stop_sessions().await;
|
||||
initial.stop_background_tasks().await;
|
||||
disabled.stop_sessions().await;
|
||||
disabled.stop_background_tasks().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_terminally_prevents_resume() {
|
||||
let (runtime, generation) = test_runtime();
|
||||
runtime.pause_operator().await.unwrap();
|
||||
let drain = runtime.begin_shutdown();
|
||||
assert!(matches!(
|
||||
runtime.resume_operator().await,
|
||||
Err(OperatorLifecycleError::Closed)
|
||||
));
|
||||
let _ = drain
|
||||
.wait_until(tokio::time::Instant::now() + Duration::from_secs(1))
|
||||
.await;
|
||||
generation.stop_sessions().await;
|
||||
generation.stop_background_tasks().await;
|
||||
}
|
||||
@@ -189,6 +189,7 @@ impl WebProcessRuntime {
|
||||
ip_learning_eligible,
|
||||
carrier_deadline_at: entry.carrier_deadline_at.ok_or(ManagerError::Protocol)?,
|
||||
};
|
||||
let _operator_admission = self.try_operator_admission()?;
|
||||
state
|
||||
.bootstraps
|
||||
.get_mut(&bootstrap_hash)
|
||||
@@ -272,6 +273,7 @@ impl WebProcessRuntime {
|
||||
let Some(carrier) = candidates.first().copied() else {
|
||||
return Err(ManagerError::Protocol);
|
||||
};
|
||||
let _operator_admission = self.try_operator_admission()?;
|
||||
if !admit_initial(self, &mut state, now, client_ip, profile_key, &profile) {
|
||||
return Err(ManagerError::Limit);
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ impl Drop for WebSocketConnection {
|
||||
drop(self.slot.take());
|
||||
self.entry.released.cancel();
|
||||
runtime.websocket_notify.notify_waiters();
|
||||
runtime.notify_operator_work_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,12 +288,14 @@ impl WebSession {
|
||||
return None;
|
||||
}
|
||||
let manager = self.manager.upgrade()?;
|
||||
let peer_port = manager.try_acquire_stream(
|
||||
self.profile_key,
|
||||
self.profile.max_streams,
|
||||
self.client_ip,
|
||||
self.profile.public_addr,
|
||||
)?;
|
||||
let peer_port = manager
|
||||
.try_acquire_stream(
|
||||
self.profile_key,
|
||||
self.profile.max_streams,
|
||||
self.client_ip,
|
||||
self.profile.public_addr,
|
||||
)
|
||||
.ok()?;
|
||||
if state.active_peer_ports.insert(peer_port) {
|
||||
return Some(peer_port);
|
||||
}
|
||||
|
||||
@@ -229,14 +229,12 @@ impl WebSession {
|
||||
let Some(manager) = self.manager.upgrade() else {
|
||||
return Err(ManagerError::Closed);
|
||||
};
|
||||
let Some(peer_port) = manager.try_acquire_stream(
|
||||
let peer_port = manager.try_acquire_stream(
|
||||
self.profile_key,
|
||||
self.profile.max_streams,
|
||||
self.client_ip,
|
||||
self.profile.public_addr,
|
||||
) else {
|
||||
return Err(ManagerError::Limit);
|
||||
};
|
||||
)?;
|
||||
if !state.active_peer_ports.insert(peer_port) {
|
||||
manager.release_stream(
|
||||
self.profile_key,
|
||||
|
||||
@@ -153,7 +153,7 @@ async fn rejected_open_retains_stream_quota_until_lane_socket_teardown() {
|
||||
runtime.session.client_ip,
|
||||
runtime.session.profile.public_addr,
|
||||
)
|
||||
.is_none()
|
||||
.is_err()
|
||||
);
|
||||
|
||||
runtime.session.close_websocket_lane(reservation);
|
||||
@@ -271,7 +271,7 @@ async fn closed_session_keeps_stream_owned_quota_until_task_completion() {
|
||||
runtime.session.client_ip,
|
||||
runtime.session.profile.public_addr,
|
||||
)
|
||||
.is_none()
|
||||
.is_err()
|
||||
);
|
||||
runtime.session.wait().await;
|
||||
let peer_port = runtime
|
||||
|
||||
Reference in New Issue
Block a user