feat(adapters): from_wss consumer adapter behind the wss feature (ADR-070)

- FromWss: dial wss:// -> split_tungstenite_to_bytes (client-side twin
  of the axum WS byte-adapter; one seam, both directions, OQ-01) ->
  Connection::from_bidi(b"alk/channels") -> alkcall ChannelClient
  (channel 0 install + dispatch loop) -> alkcall from_call importer.
  No protocol fork: specs mirror the remote, provenance FromCall.
- Drop semantics (OQ-03 v1): session drop -> monitor fails all
  in-flight pendings retryable CONNECTION_CLOSED (WsPumps::read_eof
  Notify); no 30s-deadline hang.
- Bearer token via constructor/assembly layer (ADR-014 no-env-vars).

Production fix in the WS server half (upgrade.rs): the upgrade
identity now propagates to channel 0's CallConnection (was
AuthContext::anonymous -> dispatcher saw no identity, ACL checks ran
unauthenticated; services/list filtered scoped ops for all callers).

9 in-module tests incl. full round-trip consumer<->server (both halves
of the adapter together), ACL end-to-end, drop-no-hang.

Verified: cargo test (227 lib default), --all-features (227 lib + 5
MCP + 10 WS integration), clippy -D warnings (both), fmt.
This commit is contained in:
2026-08-28 14:54:09 +00:00
parent 4ac337c3a5
commit 3a906cbd6a
6 changed files with 824 additions and 19 deletions
+124 -6
View File
@@ -33,10 +33,11 @@
use std::{
io,
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
use axum::extract::ws::{CloseFrame, Message, WebSocket};
use axum::extract::ws::{CloseFrame, Message as AxumMessage, WebSocket};
use futures::channel::mpsc as futures_mpsc;
use futures::{SinkExt, StreamExt};
use tokio::io::{AsyncRead, AsyncWrite};
@@ -80,6 +81,7 @@ pub struct WsByteStream {
pub struct WsPumps {
read_task: tokio::task::JoinHandle<()>,
write_task: tokio::task::JoinHandle<()>,
read_eof: Arc<tokio::sync::Notify>,
}
impl WsPumps {
@@ -87,6 +89,13 @@ impl WsPumps {
self.read_task.abort();
self.write_task.abort();
}
/// Fires when the WS read side reaches EOF (socket close from either
/// side) — used by `from_wss`'s connection-drop monitor to await
/// socket EOF (ADR-070).
pub(crate) async fn read_eof(&self) {
self.read_eof.notified().await;
}
}
/// Split a `WebSocket` into the byte stream + the pump tasks. The
@@ -97,26 +106,30 @@ pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
let (read_tx, read_rx) = mpsc::channel::<Vec<u8>>(READ_SLOTS);
let (write_tx, mut write_rx) = futures_mpsc::channel::<WriteMsg>(WRITE_SLOTS);
let read_eof = Arc::new(tokio::sync::Notify::new());
let write_tx_for_read = write_tx.clone();
let read_eof_for_task = Arc::clone(&read_eof);
let read_task = tokio::spawn(async move {
while let Some(msg) = ws_stream.next().await {
match msg {
Ok(Message::Binary(b)) => {
Ok(AxumMessage::Binary(b)) => {
if read_tx.send(b.to_vec()).await.is_err() {
break;
}
}
Ok(Message::Text(_)) => {
Ok(AxumMessage::Text(_)) => {
let _ = write_tx_for_read
.clone()
.send(WriteMsg::CloseWith(WS_PROTOCOL_ERROR))
.await;
break;
}
Ok(Message::Close(_)) | Err(_) => break,
Ok(AxumMessage::Close(_)) | Err(_) => break,
Ok(_) => {}
}
}
read_eof_for_task.notify_waiters();
});
let write_task = tokio::spawn(async move {
@@ -125,7 +138,7 @@ pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
match msg {
WriteMsg::CloseWith(code) => {
let _ = ws_sink
.send(Message::Close(Some(CloseFrame {
.send(AxumMessage::Close(Some(CloseFrame {
code,
reason: "text messages not supported".into(),
})))
@@ -147,7 +160,7 @@ pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
let chunk: Vec<u8> = pending.drain(..total).collect();
for piece in chunk.chunks(WS_MESSAGE_CAP) {
if ws_sink
.send(Message::Binary(piece.to_vec().into()))
.send(AxumMessage::Binary(piece.to_vec().into()))
.await
.is_err()
{
@@ -171,6 +184,7 @@ pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
WsPumps {
read_task,
write_task,
read_eof,
},
)
}
@@ -253,3 +267,107 @@ impl AsyncWrite for WsByteStream {
Poll::Ready(Ok(()))
}
}
/// Client-side split for a tokio-tungstenite `WebSocketStream`
/// (`from_wss`, ADR-070): the same WS↔byte-stream seam as
/// [`split_ws_to_bytes`], over the tungstenite socket instead of axum's
/// server-side `WebSocket`. Message semantics are identical: binary
/// messages carry the byte stream, text is a protocol error (close
/// 1002), close → read EOF.
#[cfg(any(test, feature = "wss"))]
pub fn split_tungstenite_to_bytes<S>(
socket: tokio_tungstenite::WebSocketStream<S>,
) -> (WsByteStream, WsPumps)
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let (mut ws_sink, mut ws_stream) = socket.split();
let (read_tx, read_rx) = mpsc::channel::<Vec<u8>>(READ_SLOTS);
let (write_tx, mut write_rx) = futures_mpsc::channel::<WriteMsg>(WRITE_SLOTS);
let read_eof = Arc::new(tokio::sync::Notify::new());
let write_tx_for_read = write_tx.clone();
let read_eof_for_task = Arc::clone(&read_eof);
let read_task = tokio::spawn(async move {
while let Some(msg) = ws_stream.next().await {
match msg {
Ok(tokio_tungstenite::tungstenite::Message::Binary(b)) => {
if read_tx.send(b.to_vec()).await.is_err() {
break;
}
}
Ok(tokio_tungstenite::tungstenite::Message::Text(_)) => {
let _ = write_tx_for_read
.clone()
.send(WriteMsg::CloseWith(WS_PROTOCOL_ERROR))
.await;
break;
}
Ok(tokio_tungstenite::tungstenite::Message::Close(_)) | Err(_) => break,
Ok(_) => {}
}
}
read_eof_for_task.notify_waiters();
});
let write_task = tokio::spawn(async move {
let mut pending: Vec<u8> = Vec::new();
while let Some(msg) = write_rx.next().await {
match msg {
WriteMsg::CloseWith(code) => {
let _ = ws_sink
.send(tokio_tungstenite::tungstenite::Message::Close(Some(
tokio_tungstenite::tungstenite::protocol::CloseFrame {
code: code.into(),
reason: "text messages not supported".into(),
},
)))
.await;
break;
}
WriteMsg::Bytes(b) => pending.extend_from_slice(&b),
}
loop {
if pending.len() < 8 {
break;
}
let len =
u32::from_be_bytes([pending[4], pending[5], pending[6], pending[7]]) as usize;
let total = 8 + len;
if pending.len() < total {
break;
}
let chunk: Vec<u8> = pending.drain(..total).collect();
for piece in chunk.chunks(WS_MESSAGE_CAP) {
if ws_sink
.send(tokio_tungstenite::tungstenite::Message::Binary(
piece.to_vec().into(),
))
.await
.is_err()
{
return;
}
}
}
}
let _ = ws_sink.close().await;
});
(
WsByteStream {
read_rx,
read_buf: Vec::new(),
read_pos: 0,
eof: false,
write_tx,
write_open: true,
},
WsPumps {
read_task,
write_task,
read_eof,
},
)
}