feat(websocket): data-channel + op/register wiring (review 006 Unit 2+3)

The WS path wires the alkcall 0.3 per-session mechanisms — the OQ-05
deferred half (review-003 WS-20/21/22/25/26, the decisions WS-24/WS-25
resolved upstream):

- install_channel_zero reworked per the ADR-047 §4 amendment #2 shape:
  fork the deployment's base registry, register the generic channel
  ops (channel/close, channel/control, channel/resources/subscribe —
  WS-21), the deployment's openable ALPNs (ChannelCore::register_openable,
  WS-22), the bootstrap discovery set closed over the fork
  (install_bootstrap_discovery, F-06), and op/register (WS-25; the
  collision set is the fork per review-005 G-03), then dispatch over
  the fork. The session's ChannelsPolicy rides the hook (one policy
  instance across open wrappers and the demux teardown path).
- OpenableAlpn { spec, open_handler } + HttpAdapter::with_ws_openable_alpns,
  threaded RouterState -> SessionState -> hook, with the OpenableAlpns
  request-extension fallback (mirroring ChannelsPolicy/WsTimeouts).
- WsSessions retains the channel-0 Arc<CallConnection> (WS-26) with a
  self-removing guard (ConnectionGuard); live_connections() is the
  deployment-visible surface.
- UP-01: ALREADY_EXISTS maps to 409 Conflict in the gateway error map.
- from_wss import excludes the protocol-session ops (bootstrap set +
  channel lifecycle ops): the fork serves them per session, and proxying
  session-scoped machinery (e.g. channel/close across sessions) would be
  nonsense. Discovery runs first, the filter is the listing minus those
  names.
- adapter_install_channel_zero cfg matches its caller (WS-27); it
  inherits the reworked hook (session ops now served in the from_wss
  test-server producer too).

Gates (Unit 3, tests/ws_upgrade_session.rs; the WS-23 e2e set):
open -> channel_id -> discoverable in services/list -> chunks both
ways -> handler sees bytes; channel/close resolves + ledger decrement;
cap denial (channel:-prefixed); mid-open disconnect teardown; TooLarge
demux resync through the WS path (16 MiB + 1 skip consumed);
op/register announce + overlay-collision + serving-registry-collision
ALREADY_EXISTS through the WS path.

call_and_await now filters by request id and tolerates data-channel
chunks (a prior Sub's trailing call.completed may interleave).

Verification: cargo test 454 (default) / 582 (all-features), clippy
both sides -D warnings clean, fmt clean, doc clean.
This commit is contained in:
2026-09-04 15:47:15 +00:00
parent 90790374fa
commit 030c5efa51
8 changed files with 891 additions and 28 deletions
+7
View File
@@ -48,6 +48,11 @@ pub(crate) struct RouterState {
pub(crate) ws_session_slots: Arc<tokio::sync::Semaphore>,
/// Idle-read timeout for the WS pumps (WS-01); `None` disables.
pub(crate) ws_idle_timeout: Option<std::time::Duration>,
/// The openable-ALPN set for WS sessions (WS-22): the per-ALPN
/// open-op specs + handlers registered on each session's fork.
/// `None` (the default) declares no openables — a WS session then
/// carries no data-channel open ops (the pre-Unit-2 shape).
pub(crate) ws_openable_alpns: Option<Arc<[crate::websocket::OpenableAlpn]>>,
}
impl axum::extract::FromRef<RouterState> for crate::websocket::SessionState {
@@ -57,6 +62,7 @@ impl axum::extract::FromRef<RouterState> for crate::websocket::SessionState {
Arc::clone(&state.ws_sessions),
Arc::clone(&state.ws_session_slots),
state.ws_idle_timeout,
state.ws_openable_alpns.clone(),
)
}
}
@@ -96,6 +102,7 @@ mod tests {
crate::websocket::DEFAULT_WS_MAX_SESSIONS,
)),
ws_idle_timeout: Some(crate::websocket::DEFAULT_WS_IDLE_TIMEOUT),
ws_openable_alpns: None,
};
let extracted: DecoyConfig = axum::extract::FromRef::from_ref(&state);
assert!(matches!(extracted, DecoyConfig::Redirect { .. }));