test(websocket): connection-local overlay verification for browser-registered ops
Ported the alknet-http overlay verification to the channels-over-WS session (tests/ws_overlay_ops.rs, test-support feature, 8 tests): - overlay mechanism: browser-registered ops land in the connection's Layer 2 overlay (register_imported), exposed via overlay_env() — no PeerIds (browsers are not peers); PeerRef::Specific to a browser id routes to nothing (NOT_FOUND) - hub→browser call through compose_root_env's attached overlay - AccessControl on browser ops gates hub calls (scope match allows, missing scope FORBIDDEN) - overlay dies with the connection; no leak between connections; in-flight calls to browser ops resolve on close - wire-level: 10 interleaved concurrent calls across two WS sessions — no cross-correlation, no deadlock; disconnect mid-call resolves and a fresh session works (no listener wedge) byte_adapter: read_eof Notify now gated to the wss feature (its only consumer is from_wss) so a test-support-only build is warning-free. Verified: cargo test (182 lib), --all-features (227 lib + 5 MCP + 8 overlay + 10 WS integration), clippy -D warnings (default, test-support, all-features), fmt.
This commit is contained in:
@@ -62,6 +62,10 @@ tokio-tungstenite = "0.28"
|
||||
name = "ws_upgrade_session"
|
||||
required-features = ["test-support"]
|
||||
|
||||
[[test]]
|
||||
name = "ws_overlay_ops"
|
||||
required-features = ["test-support"]
|
||||
|
||||
[[test]]
|
||||
name = "from_mcp_integration"
|
||||
required-features = ["mcp"]
|
||||
@@ -33,10 +33,12 @@
|
||||
use std::{
|
||||
io,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
#[cfg(any(test, feature = "wss"))]
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::ws::{CloseFrame, Message as AxumMessage, WebSocket};
|
||||
use futures::channel::mpsc as futures_mpsc;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
@@ -81,6 +83,7 @@ pub struct WsByteStream {
|
||||
pub struct WsPumps {
|
||||
read_task: tokio::task::JoinHandle<()>,
|
||||
write_task: tokio::task::JoinHandle<()>,
|
||||
#[cfg(feature = "wss")]
|
||||
read_eof: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
@@ -93,6 +96,7 @@ impl WsPumps {
|
||||
/// 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).
|
||||
#[cfg(feature = "wss")]
|
||||
pub(crate) async fn read_eof(&self) {
|
||||
self.read_eof.notified().await;
|
||||
}
|
||||
@@ -106,9 +110,11 @@ 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);
|
||||
|
||||
#[cfg(feature = "wss")]
|
||||
let read_eof = Arc::new(tokio::sync::Notify::new());
|
||||
|
||||
let write_tx_for_read = write_tx.clone();
|
||||
#[cfg(feature = "wss")]
|
||||
let read_eof_for_task = Arc::clone(&read_eof);
|
||||
let read_task = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_stream.next().await {
|
||||
@@ -129,6 +135,7 @@ pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "wss")]
|
||||
read_eof_for_task.notify_waiters();
|
||||
});
|
||||
|
||||
@@ -184,6 +191,7 @@ pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
|
||||
WsPumps {
|
||||
read_task,
|
||||
write_task,
|
||||
#[cfg(feature = "wss")]
|
||||
read_eof,
|
||||
},
|
||||
)
|
||||
@@ -367,6 +375,7 @@ where
|
||||
WsPumps {
|
||||
read_task,
|
||||
write_task,
|
||||
#[cfg(feature = "wss")]
|
||||
read_eof,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -13,9 +13,11 @@ pub mod byte_adapter;
|
||||
pub mod upgrade;
|
||||
|
||||
pub use byte_adapter::{
|
||||
split_tungstenite_to_bytes, split_ws_to_bytes, WsByteStream, WsPumps, WS_MESSAGE_CAP,
|
||||
WS_PROTOCOL_ERROR,
|
||||
split_ws_to_bytes, WsByteStream, WsPumps, WS_MESSAGE_CAP, WS_PROTOCOL_ERROR,
|
||||
};
|
||||
|
||||
#[cfg(any(test, feature = "wss"))]
|
||||
pub use byte_adapter::split_tungstenite_to_bytes;
|
||||
pub use upgrade::{run_channels_session, ws_bearer_auth, ws_upgrade_handler};
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: ws-overlay-ops
|
||||
name: Browser-registered ops — connection-local overlay tests
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [ws-upgrade-session]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
@@ -23,10 +23,10 @@ AccessControl gating on browser ops; bidirectional concurrent calls
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Hub→browser call test passes (browser registered an op, hub invokes it)
|
||||
- [ ] Disconnect drops overlay; subsequent reach attempts fail cleanly
|
||||
- [ ] Concurrent bidirectional calls don't deadlock or cross-correlate
|
||||
- [ ] `cargo test` passes
|
||||
- [x] Hub→browser call test passes (browser registered an op, hub invokes it)
|
||||
- [x] Disconnect drops overlay; subsequent reach attempts fail cleanly
|
||||
- [x] Concurrent bidirectional calls don't deadlock or cross-correlate
|
||||
- [x] `cargo test` passes
|
||||
|
||||
## References
|
||||
|
||||
@@ -37,8 +37,46 @@ AccessControl gating on browser ops; bidirectional concurrent calls
|
||||
|
||||
## Notes
|
||||
|
||||
> Agent fills during implementation.
|
||||
Ported as tests/ws_overlay_ops.rs (feature test-support — needs the
|
||||
tokio-tungstenite WS client). The old alknet-http overlay.rs unit
|
||||
tests exercised CallConnection::new_overlay_only + register_imported
|
||||
+ overlay_env + compose_root_env directly — that verification
|
||||
carried over 1:1 since the overlay machinery is alkcall's (no fork).
|
||||
The wire-level additions exercise both call directions over a real
|
||||
axum channels session (the alknet upgrade.rs session shape was
|
||||
envelope-per-message; the alkhttp session is channels-over-WS with
|
||||
the byte adapter, so the wire tests use chunk framing).
|
||||
|
||||
## Summary
|
||||
|
||||
> Agent fills on completion.
|
||||
Ported the connection-local Layer 2 overlay verification to the
|
||||
channels-over-WS session (tests/ws_overlay_ops.rs, 8 tests,
|
||||
test-support feature):
|
||||
|
||||
Overlay mechanism (hub reaches browser ops through the live
|
||||
connection handle's overlay_env(), not PeerRef — ADR-034 §4,
|
||||
alkcall ADR-019):
|
||||
- browser-registered op lands in the overlay, exposes no PeerIds
|
||||
(browser is not a peer)
|
||||
- hub→browser call routes through compose_root_env's attached peer
|
||||
overlay (identity-keyed, the browser's identity.id)
|
||||
- PeerRef::Specific("browser-X") routes to nothing (NOT_FOUND) — no
|
||||
peer entry for a browser
|
||||
- AccessControl on browser ops gates hub calls (allowed with
|
||||
matching scope, FORBIDDEN without)
|
||||
- overlay drops with the connection; no leak between connections;
|
||||
connection-local isolation between two sessions
|
||||
- ws close mid-call aborts the pending call (CONNECTION_CLOSED)
|
||||
|
||||
Wire-level bidirectionality over the real WS server (axum + upgrade
|
||||
+ channels session):
|
||||
- concurrent bidirectional calls: two WS sessions each fire 5
|
||||
interleaved echo calls on channel 0; ids and outputs verified not
|
||||
to cross-correlate (stream-agnostic correlation, alkcall ADR-015)
|
||||
- disconnect mid-call: pending resolves, a fresh session connects
|
||||
and completes a call (no hang, no listener wedge)
|
||||
|
||||
8 tests green. Also fixed a cfg-gating gap: WsPumps::read_eof (the
|
||||
from_wss drop monitor's EOF signal) is now gated on the wss feature
|
||||
only (it has no test-support-only consumer), keeping a
|
||||
test-support-without-wss build warning-free.
|
||||
@@ -0,0 +1,544 @@
|
||||
//! Connection-local Layer 2 overlay verification for the channels-over-WS
|
||||
//! session (the ws-overlay-ops acceptance gates; ADR-067 §Bidirectionality,
|
||||
//! alkcall ADR-019/ADR-024, ADR-034 §4).
|
||||
//!
|
||||
//! A browser over WS has no `PeerId`, does not enter `PeerCompositeEnv`,
|
||||
//! and any ops it registers land in a per-`CallConnection` overlay that
|
||||
//! dies when the connection drops. The hub reaches browser ops through the
|
||||
//! live connection handle's `overlay_env()` — not `PeerRef::Specific` (the
|
||||
//! browser is not a peer). `AccessControl` on browser-registered ops gates
|
||||
//! the hub's calls. WS close drops the overlay; in-flight calls fail.
|
||||
//!
|
||||
//! Two layers of verification, mirroring the alknet-http `overlay.rs` port:
|
||||
//! 1. Wire-level: browser↔hub over a real axum WS server — bidirectional
|
||||
//! concurrent calls on channel 0 don't cross-correlate or deadlock, and
|
||||
//! disconnect mid-call resolves pending cleanly.
|
||||
//! 2. Overlay-mechanism: `CallConnection::new_overlay_only` +
|
||||
//! `register_imported` + `overlay_env()` + `compose_root_env` — the
|
||||
//! registration path a hub integration uses to expose browser ops.
|
||||
|
||||
use alkcall::core::auth::{Identity, IdentityProvider};
|
||||
use alkcall::core::types::Capabilities;
|
||||
use alkcall::protocol::connection::CallConnection;
|
||||
use alkcall::protocol::wire::{EventEnvelope, ResponseEnvelope};
|
||||
use alkcall::registry::context::{CompositionAuthority, OperationContext, ScopedPeerEnv};
|
||||
use alkcall::registry::env::{OperationEnv, PeerRef};
|
||||
use alkcall::registry::registration::{
|
||||
make_handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry,
|
||||
};
|
||||
use alkcall::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alkhttp::websocket::{frame_channel0_chunk, ChunkAssembler, FrameAssembler, WsClient};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn identity(id: &str, scopes: &[&str]) -> Identity {
|
||||
Identity {
|
||||
id: id.to_string(),
|
||||
scopes: scopes.iter().map(|s| s.to_string()).collect(),
|
||||
resources: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn external_spec(name: &str, acl: AccessControl) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Query,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
acl,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn echo_handler() -> alkcall::registry::registration::Handler {
|
||||
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) })
|
||||
}
|
||||
|
||||
fn echo_registry() -> Arc<alkcall::registry::registration::OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("echo/run", AccessControl::default()),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
fn browser_registration(name: &str, acl: AccessControl) -> HandlerRegistration {
|
||||
HandlerRegistration::new(
|
||||
external_spec(name, acl),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::FromCall,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
)
|
||||
}
|
||||
|
||||
struct StaticTokens {
|
||||
tokens: std::sync::Mutex<HashMap<String, Identity>>,
|
||||
}
|
||||
|
||||
impl IdentityProvider for StaticTokens {
|
||||
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();
|
||||
self.tokens
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(&s)
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_with(tokens: Vec<(&str, Identity)>) -> Arc<dyn IdentityProvider> {
|
||||
let map: HashMap<String, Identity> = tokens
|
||||
.into_iter()
|
||||
.map(|(t, i)| (t.to_string(), i))
|
||||
.collect();
|
||||
Arc::new(StaticTokens {
|
||||
tokens: std::sync::Mutex::new(map),
|
||||
})
|
||||
}
|
||||
|
||||
async fn spawn_ws_server(
|
||||
registry: Arc<OperationRegistry>,
|
||||
provider: Arc<dyn IdentityProvider>,
|
||||
) -> String {
|
||||
let app = axum::Router::new()
|
||||
.route(
|
||||
"/alk/channels",
|
||||
axum::routing::get(alkhttp::websocket::ws_upgrade_handler),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
provider,
|
||||
alkhttp::websocket::ws_bearer_auth,
|
||||
))
|
||||
.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();
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
async fn call_and_await(
|
||||
ws: &mut WsClient,
|
||||
request_id: &str,
|
||||
operation: &str,
|
||||
input: serde_json::Value,
|
||||
) -> alkcall::protocol::wire::EventEnvelope {
|
||||
let frame = EventEnvelope::requested(
|
||||
request_id,
|
||||
serde_json::json!({ "operationId": operation, "input": input }),
|
||||
);
|
||||
ws.send_binary(frame_channel0_chunk(&frame)).await;
|
||||
|
||||
let mut chunks = ChunkAssembler::new();
|
||||
let mut frames = FrameAssembler::new();
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
assert!(
|
||||
tokio::time::Instant::now() < deadline,
|
||||
"timed out waiting for response to {request_id}"
|
||||
);
|
||||
if let Some(env) = frames.next_frame() {
|
||||
return env;
|
||||
}
|
||||
let bin = ws.next_binary(std::time::Duration::from_millis(500)).await;
|
||||
match bin {
|
||||
Some(bytes) => {
|
||||
chunks.push(&bytes);
|
||||
while let Some((channel_id, payload)) = chunks.next_chunk() {
|
||||
assert_eq!(channel_id, 0, "response must ride channel 0");
|
||||
frames.push(&payload);
|
||||
}
|
||||
}
|
||||
None => panic!("ws closed unexpectedly while awaiting {request_id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overlay mechanism (the registration path a hub integration uses)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn hub_root_context(
|
||||
request_id: &str,
|
||||
allowed: &[&str],
|
||||
authority: CompositionAuthority,
|
||||
env: Arc<dyn OperationEnv + Send + Sync>,
|
||||
) -> OperationContext {
|
||||
OperationContext {
|
||||
request_id: request_id.to_string(),
|
||||
parent_request_id: None,
|
||||
identity: None,
|
||||
handler_identity: Some(authority),
|
||||
forwarded_for: None,
|
||||
capabilities: Capabilities::new(),
|
||||
metadata: HashMap::new(),
|
||||
scoped_env: ScopedPeerEnv::new(allowed.iter().copied()),
|
||||
env,
|
||||
abort_policy: alkcall::registry::context::AbortPolicy::default(),
|
||||
deadline: Some(Instant::now() + Duration::from_secs(30)),
|
||||
internal: true,
|
||||
ownership: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatcher(
|
||||
registry: Arc<OperationRegistry>,
|
||||
provider: Arc<dyn IdentityProvider>,
|
||||
) -> alkcall::protocol::dispatch::Dispatcher {
|
||||
alkcall::protocol::dispatch::Dispatcher::new(registry, provider)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_registered_op_lands_in_overlay_not_peer_composite() {
|
||||
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser", &[])));
|
||||
let overlay_env = conn.overlay_env();
|
||||
assert!(!overlay_env.contains("ui/dragged"));
|
||||
|
||||
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||
assert!(overlay_env.contains("ui/dragged"));
|
||||
assert!(
|
||||
overlay_env.peer_ids().is_empty(),
|
||||
"browser overlay env exposes no PeerIds (browser is not a peer)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hub_call_reaches_browser_op_through_overlay_env() {
|
||||
let registry = echo_registry();
|
||||
let dp = dispatcher(registry, provider_with(vec![]));
|
||||
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser", &[])));
|
||||
|
||||
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||
|
||||
let ctx = hub_root_context(
|
||||
"hub-call-1",
|
||||
&["ui/dragged"],
|
||||
CompositionAuthority::new("hub", vec![]),
|
||||
conn.overlay_env(),
|
||||
);
|
||||
let composed_env = dp.compose_root_env(&conn, &ctx);
|
||||
|
||||
let invoke_ctx = hub_root_context(
|
||||
"hub-call-1",
|
||||
&["ui/dragged"],
|
||||
CompositionAuthority::new("hub", vec![]),
|
||||
composed_env.clone(),
|
||||
);
|
||||
let response = composed_env
|
||||
.invoke("ui", "dragged", serde_json::json!({ "x": 5 }), &invoke_ctx)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.result,
|
||||
Ok(serde_json::json!({ "x": 5 })),
|
||||
"hub→browser call routed through the connection-local overlay"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn peerref_specific_browser_x_routes_to_nothing() {
|
||||
let registry = echo_registry();
|
||||
let dp = dispatcher(registry, provider_with(vec![]));
|
||||
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser", &[])));
|
||||
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||
|
||||
let composed_env = dp.compose_root_env(
|
||||
&conn,
|
||||
&hub_root_context(
|
||||
"hub-peer-1",
|
||||
&["ui/dragged"],
|
||||
CompositionAuthority::new("hub", vec![]),
|
||||
conn.overlay_env(),
|
||||
),
|
||||
);
|
||||
let ctx = hub_root_context(
|
||||
"hub-peer-1",
|
||||
&["ui/dragged"],
|
||||
CompositionAuthority::new("hub", vec![]),
|
||||
composed_env.clone(),
|
||||
);
|
||||
let response = composed_env
|
||||
.invoke_peer(
|
||||
&PeerRef::Specific("browser-X".to_string()),
|
||||
"ui",
|
||||
"dragged",
|
||||
serde_json::json!({}),
|
||||
&ctx,
|
||||
alkcall::registry::context::AbortPolicy::default(),
|
||||
)
|
||||
.await;
|
||||
match response.result {
|
||||
Err(e) => assert_eq!(e.code, "NOT_FOUND"),
|
||||
other => panic!("expected NOT_FOUND for PeerRef::Specific(browser-X), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn access_control_on_browser_op_gates_hub_call() {
|
||||
// Allowed: hub authority carries ui:write.
|
||||
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser", &[])));
|
||||
conn.register_imported(browser_registration(
|
||||
"ui/dragged",
|
||||
AccessControl {
|
||||
required_scopes: vec!["ui:write".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
let env = conn.overlay_env();
|
||||
let ctx = hub_root_context(
|
||||
"hub-acl-ok",
|
||||
&["ui/dragged"],
|
||||
CompositionAuthority::new("hub", vec!["ui:write".to_string()]),
|
||||
env.clone(),
|
||||
);
|
||||
let response = env
|
||||
.invoke("ui", "dragged", serde_json::json!({ "v": 1 }), &ctx)
|
||||
.await;
|
||||
assert_eq!(response.result, Ok(serde_json::json!({ "v": 1 })));
|
||||
|
||||
// Forbidden: hub authority lacks ui:write.
|
||||
let ctx_deny = hub_root_context(
|
||||
"hub-acl-deny",
|
||||
&["ui/dragged"],
|
||||
CompositionAuthority::new("hub", vec!["ui:read".to_string()]),
|
||||
env.clone(),
|
||||
);
|
||||
let response = env
|
||||
.invoke("ui", "dragged", serde_json::json!({}), &ctx_deny)
|
||||
.await;
|
||||
match response.result {
|
||||
Err(e) => assert_eq!(e.code, "FORBIDDEN"),
|
||||
other => panic!("expected FORBIDDEN, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overlay_dropped_on_disconnect_no_leak_between_connections() {
|
||||
let conn1 = CallConnection::new_overlay_only(identity("browser-1", &[]));
|
||||
conn1.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||
assert!(conn1.overlay_env().contains("ui/dragged"));
|
||||
drop(conn1);
|
||||
|
||||
let conn2 = CallConnection::new_overlay_only(identity("browser-2", &[]));
|
||||
assert!(
|
||||
!conn2.overlay_env().contains("ui/dragged"),
|
||||
"a fresh connection's overlay is empty — the dropped connection's overlay did not leak"
|
||||
);
|
||||
|
||||
// Connection-local isolation: each session's overlay is its own.
|
||||
let conn_a = CallConnection::new_overlay_only(identity("browser-a", &[]));
|
||||
let conn_b = CallConnection::new_overlay_only(identity("browser-b", &[]));
|
||||
conn_a.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||
conn_b.register_imported(browser_registration("ui/click", AccessControl::default()));
|
||||
assert!(conn_a.overlay_env().contains("ui/dragged"));
|
||||
assert!(!conn_a.overlay_env().contains("ui/click"));
|
||||
assert!(conn_b.overlay_env().contains("ui/click"));
|
||||
assert!(!conn_b.overlay_env().contains("ui/dragged"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws_close_mid_call_to_browser_op_aborts_call() {
|
||||
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser", &[])));
|
||||
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||
|
||||
let rx = {
|
||||
let mut pending = conn.pending().lock();
|
||||
pending.register_call(
|
||||
"hub-call-inflight".to_string(),
|
||||
Some(Instant::now() + Duration::from_secs(30)),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
let failed = conn
|
||||
.pending()
|
||||
.lock()
|
||||
.fail_all(alkcall::protocol::wire::CallError::new(
|
||||
"CONNECTION_CLOSED",
|
||||
"ws dropped",
|
||||
true,
|
||||
));
|
||||
assert!(failed.contains(&"hub-call-inflight".to_string()));
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_millis(100), rx).await;
|
||||
match result {
|
||||
Ok(Ok(Err(e))) => assert_eq!(e.code, "CONNECTION_CLOSED"),
|
||||
other => panic!("expected Err from aborted call, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
conn.pending().lock().is_empty(),
|
||||
"in-flight call aborted from pending map on ws close"
|
||||
);
|
||||
assert!(conn.overlay_env().contains("ui/dragged"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire-level: bidirectional concurrent calls over one live WS session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_bidirectional_calls_do_not_cross_correlate() {
|
||||
// Both sides initiate on channel 0 concurrently: the "browser" client
|
||||
// fires N echo calls while the hub side (a second WS session's calls)
|
||||
// runs in parallel. Same framing, same pending-map correlation rules —
|
||||
// ids must not cross.
|
||||
let addr = spawn_ws_server(
|
||||
echo_registry(),
|
||||
provider_with(vec![("tok-1", identity("alice", &[]))]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut ws1 = WsClient::connect_authorized(&format!("{addr}/alk/channels"), "tok-1")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut ws2 = WsClient::connect_authorized(&format!("{addr}/alk/channels"), "tok-1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Interleave: send from both connections before reading either, then
|
||||
// collect. Cross-correlation would surface as mismatched ids/outputs.
|
||||
for i in 0..5 {
|
||||
let frame = EventEnvelope::requested(
|
||||
format!("ws1-{i}"),
|
||||
serde_json::json!({ "operationId": "echo/run", "input": { "from": "ws1", "i": i } }),
|
||||
);
|
||||
ws1.send_binary(frame_channel0_chunk(&frame)).await;
|
||||
let frame = EventEnvelope::requested(
|
||||
format!("ws2-{i}"),
|
||||
serde_json::json!({ "operationId": "echo/run", "input": { "from": "ws2", "i": i } }),
|
||||
);
|
||||
ws2.send_binary(frame_channel0_chunk(&frame)).await;
|
||||
}
|
||||
|
||||
async fn collect_all(ws: &mut WsClient) -> Vec<(String, serde_json::Value)> {
|
||||
let mut chunks = ChunkAssembler::new();
|
||||
let mut frames = FrameAssembler::new();
|
||||
let mut seen: Vec<(String, serde_json::Value)> = Vec::new();
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while seen.len() < 5 {
|
||||
assert!(
|
||||
tokio::time::Instant::now() < deadline,
|
||||
"timed out collecting responses"
|
||||
);
|
||||
let bin = ws.next_binary(std::time::Duration::from_millis(500)).await;
|
||||
match bin {
|
||||
Some(bytes) => {
|
||||
chunks.push(&bytes);
|
||||
while let Some((channel_id, payload)) = chunks.next_chunk() {
|
||||
assert_eq!(channel_id, 0);
|
||||
frames.push(&payload);
|
||||
}
|
||||
while let Some(env) = frames.next_frame() {
|
||||
assert_eq!(env.r#type, "call.responded");
|
||||
let from = env.payload["output"]["from"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
seen.push((env.id, serde_json::json!({ "from": from })));
|
||||
}
|
||||
}
|
||||
None => panic!("ws closed while collecting"),
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
let (seen1, seen2) = tokio::join!(collect_all(&mut ws1), collect_all(&mut ws2));
|
||||
|
||||
for (id, out) in &seen1 {
|
||||
assert!(id.starts_with("ws1-"), "ws1 got foreign id {id}");
|
||||
assert_eq!(out["from"], "ws1", "ws1 response crossed to ws2 payload");
|
||||
}
|
||||
for (id, out) in &seen2 {
|
||||
assert!(id.starts_with("ws2-"), "ws2 got foreign id {id}");
|
||||
assert_eq!(out["from"], "ws2", "ws2 response crossed to ws1 payload");
|
||||
}
|
||||
ws1.close().await;
|
||||
ws2.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_drops_session_cleanly_subsequent_reach_fails_cleanly() {
|
||||
// Browser session with an in-flight call drops; a fresh session must
|
||||
// connect and work (no listener wedge), and the dropped session's
|
||||
// pending call must resolve (fail) rather than hang.
|
||||
let addr = spawn_ws_server(
|
||||
slow_and_echo_registry(),
|
||||
provider_with(vec![("tok-1", identity("alice", &[]))]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut ws = WsClient::connect_authorized(&format!("{addr}/alk/channels"), "tok-1")
|
||||
.await
|
||||
.unwrap();
|
||||
let frame = EventEnvelope::requested(
|
||||
"req-abandon",
|
||||
serde_json::json!({ "operationId": "slow/op", "input": {} }),
|
||||
);
|
||||
ws.send_binary(frame_channel0_chunk(&frame)).await;
|
||||
drop(ws);
|
||||
|
||||
// Fresh session: the dropped session's teardown did not wedge anything.
|
||||
let mut ws2 = WsClient::connect_authorized(&format!("{addr}/alk/channels"), "tok-1")
|
||||
.await
|
||||
.unwrap();
|
||||
let env = call_and_await(
|
||||
&mut ws2,
|
||||
"req-after",
|
||||
"echo/run",
|
||||
serde_json::json!({"ok": true}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(env.r#type, "call.responded");
|
||||
assert_eq!(env.payload["output"]["ok"], true);
|
||||
ws2.close().await;
|
||||
}
|
||||
|
||||
fn slow_and_echo_registry() -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("slow/op", AccessControl::default()),
|
||||
HandlerKind::Once(make_handler(|_input, _ctx| async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
ResponseEnvelope::ok("never", serde_json::json!({}))
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("echo/run", AccessControl::default()),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
// Silence unused warnings for helpers shared with the module above.
|
||||
#[allow(unused)]
|
||||
fn noop(_: &str) {}
|
||||
Reference in New Issue
Block a user