feat(websocket): WsTimeouts request extension for WS pump knobs (WS-17)

- WsTimeouts { idle, write } request extension mirrors ChannelsPolicy:
  a deployment layers it on a WS route (bare-registry routes included)
  to set the pump knobs per route
- precedence: extension present replaces the router state entirely;
  absent falls back to SessionState (adapter-configured idle) and the
  crate default write window — a Default impl never clobbers the
  adapter-configured idle knob
- upgrade.rs module + handler docs now state the real defaults for
  bare-registry routes (60 s idle + 60 s write, 64-session semaphore,
  handler-private WsSessions) and the extension surface
- split_ws_to_bytes_idle_with_write exposes the WS-18 write window to
  run_channels_session; acceptance test drives a bare-registry route
  with a 150 ms extension idle window (1001 eviction observed)

Verification: scripts/verify.sh OK (343 passed), test-support suite
ok, clippy -D warnings clean, fmt clean
This commit is contained in:
2026-08-30 21:48:03 +00:00
parent da65e5b6db
commit 674120ad72
4 changed files with 142 additions and 11 deletions
+51
View File
@@ -918,3 +918,54 @@ async fn idle_progress_knob_none_disables_eviction_over_axum_upgrade() {
);
ws.close().await;
}
/// WS-17 acceptance: a bare-registry upgrade route takes the pump
/// knobs per request via the `WsTimeouts` extension (mirroring
/// `ChannelsPolicy`) — a client that completes no chunk is evicted
/// with 1001 inside the extension's short idle window, not the 60 s
/// default.
#[tokio::test]
async fn ws_timeouts_extension_sets_the_idle_window_on_a_bare_registry_route() {
let registry = std::sync::Arc::new(OperationRegistry::new());
struct StaticTok;
impl IdentityProvider for StaticTok {
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, token: &alkcall::core::auth::AuthToken) -> Option<Identity> {
let s = String::from_utf8_lossy(&token.raw).to_string();
(s == "tok-1").then(|| identity("alice", &[]))
}
}
let app = axum::Router::new()
.route(
"/alk/channels",
axum::routing::get(alkhttp::websocket::ws_upgrade_handler),
)
.layer(axum::middleware::from_fn_with_state(
std::sync::Arc::new(StaticTok) as std::sync::Arc<dyn IdentityProvider>,
alkhttp::websocket::ws_bearer_auth,
))
.layer(axum::Extension(alkhttp::websocket::WsTimeouts {
idle: Some(std::time::Duration::from_millis(150)),
write: None,
}))
.with_state(registry);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = format!("ws://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let mut ws = WsClient::connect_authorized(&format!("{addr}/alk/channels"), "tok-1")
.await
.unwrap();
let close = ws.next_close(std::time::Duration::from_secs(5)).await;
assert_eq!(
close,
Some(Some(alkhttp::websocket::WS_GOING_AWAY)),
"evicted with 1001 inside the extension's idle window"
);
ws.close().await;
}