fix(review 005 Unit 1): concurrent serving loops + stub-exercising gates (G-01, G-02)
- Split dispatch() into dispatch_start() (sync prefix) + spawned invocation: both single-stream loops (serve_single_stream and the accept-side run_loop_single_stream) spawn Once invocations, Sub pumps, and sink response writers; only the Pub sink start stays inline (chunk_tx must register before the next call.published). Inline dispatch deadlocked same-connection nested composition: the read loop awaited the parent handler, which awaited a nested call whose response only the same read loop could resolve (resolved only via the 30s sweeper). Spawned handles tracked + aborted at loop exit; in_flight_sinks behind an Arc<parking_lot::Mutex> with guards dropped before awaits. - run_loop_single_stream gains the pending-resolution arms (RESPONDED/COMPLETED/ERROR): the accept side previously served only and had no loop resolving its own outbound pendings in single-stream mode — the latent accept-side imported-op composition hazard is mechanized shut. - Write-failure in the spawned Once path warns instead of closing the loop (matches the Sink arm; dying transport still surfaces via ConnectionClosed on the next read). - G-02 gate: hub_handler_composes_peer_announced_op_via_nested_composition — announce -> consumer calls hub/compose -> hub's serving loop wire-dispatches it -> handler composes via ctx.env -> forwarding stub's nested call crosses back to the consumer. The F-05 gate bypassed this path entirely. - Interleaved-directions gate: outbound_call_resolves_while_inbound_ subscription_is_being_served — consumer serves a live Sub while a wire-dispatched hub handler issues an outbound call on the same connection. - Both gates verified load-bearing: run against the pre-fix loop each reproduces the G-01 hang (no progress, bounded-timeout failure); post-fix both resolve in <0.2s, no sweeper evictions. Verification: cargo test 583 / --all-features 600, clippy (all-targets, all-features, wasm32) clean, fmt clean, doc clean. Refs docs/reviews/005-...md (G-01, G-02; Units 2-3 open).
This commit is contained in:
1 parent
435ae9da2f
commit
1cbb7c6536
3 files changed
+846
-157
No files matched your search
+437
-1
@@ -271,7 +271,7 @@ mod tests {
|
||||
use crate::protocol::dispatch::Dispatcher;
|
||||
use crate::registry::context::OperationContext;
|
||||
use crate::registry::registration::{
|
||||
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
};
|
||||
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
@@ -1542,6 +1542,442 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Interleaved-directions gate (review 005 G-01, third consequence):
|
||||
/// the consumer's outbound call resolves **while** its serving loop
|
||||
/// is mid-pump on a long-lived inbound Sub to the same peer. Under
|
||||
/// the pre-G-01 loop the Sub pump held the read loop inline and the
|
||||
/// outbound call's `call.responded` queued unresolved until the
|
||||
/// sweeper; sequential-direction gates (hub→consumer, *then*
|
||||
/// consumer→hub) could never catch it. The consumer also issues the
|
||||
/// outbound call *from inside* a wire-dispatched handler, the
|
||||
/// tightest interleaving.
|
||||
#[tokio::test]
|
||||
async fn outbound_call_resolves_while_inbound_subscription_is_being_served() {
|
||||
// Consumer side: serves `consumer/echo` (Once) and
|
||||
// `consumer/tick` (Sub, unbounded — held open until aborted).
|
||||
let consumer_registry = crate::registry::registration::OperationRegistry::new();
|
||||
consumer_registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_query_spec("consumer/echo"),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
crate::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
consumer_registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"consumer/tick",
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
),
|
||||
HandlerKind::Stream(make_streaming_handler(|_input, ctx: OperationContext| {
|
||||
let request_id = ctx.request_id.clone();
|
||||
futures::stream::unfold((0u32, request_id), |(n, request_id)| async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
Some((
|
||||
ResponseEnvelope::ok(
|
||||
request_id.clone(),
|
||||
serde_json::json!({ "tick": n }),
|
||||
),
|
||||
(n + 1, request_id),
|
||||
))
|
||||
})
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
crate::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let consumer_registry = Arc::new(consumer_registry);
|
||||
|
||||
// Handle channel: the install hook yields the accept side's
|
||||
// channel-0 `CallConnection` so the test driver can subscribe
|
||||
// (the accept side initiates the Sub) and so the
|
||||
// `hub/interleave` handler can reach it.
|
||||
let (accept_conn_tx, mut accept_conn_rx) =
|
||||
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
|
||||
|
||||
// Accept side (hub): serves `accept/echo` and
|
||||
// `hub/interleave` — the latter, when wire-dispatched from the
|
||||
// consumer, issues an outbound call on the *same* connection.
|
||||
let accept_registry = crate::registry::registration::OperationRegistry::new();
|
||||
accept_registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_query_spec("accept/echo"),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
crate::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let accept_conn_for_handler =
|
||||
Arc::new(tokio::sync::Mutex::new(None::<Arc<CallConnection>>));
|
||||
let accept_conn_slot = Arc::clone(&accept_conn_for_handler);
|
||||
accept_registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_query_spec("hub/interleave"),
|
||||
HandlerKind::Once(make_handler(move |input, ctx| {
|
||||
let conn_slot = Arc::clone(&accept_conn_for_handler);
|
||||
async move {
|
||||
let conn = conn_slot
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
.expect("hub call connection set");
|
||||
let response = conn
|
||||
.call("consumer/echo", serde_json::json!({ "via": "interleave" }))
|
||||
.await;
|
||||
let echo = response.result.expect("outbound call resolves");
|
||||
ResponseEnvelope::ok(
|
||||
ctx.request_id,
|
||||
serde_json::json!({
|
||||
"input": input,
|
||||
"outbound": echo,
|
||||
}),
|
||||
)
|
||||
}
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
crate::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let accept_registry = Arc::new(accept_registry);
|
||||
|
||||
let install_hook: crate::channels::adapter::InstallChannelZero =
|
||||
Arc::new(move |_manager, channel0_conn, _auth| {
|
||||
let accept_registry = Arc::clone(&accept_registry);
|
||||
let accept_conn_tx = accept_conn_tx.clone();
|
||||
let accept_conn_slot = Arc::clone(&accept_conn_slot);
|
||||
tokio::spawn(async move {
|
||||
let channel0_bidi = match channel0_conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
accept_conn_slot
|
||||
.lock()
|
||||
.await
|
||||
.replace(Arc::clone(&call_connection));
|
||||
let _ = accept_conn_tx.send(Arc::clone(&call_connection)).await;
|
||||
let dp = Dispatcher::new(
|
||||
accept_registry,
|
||||
Arc::new(NoopIdProvider) as Arc<dyn IdentityProvider>,
|
||||
);
|
||||
dp.serve_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
});
|
||||
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn =
|
||||
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let server_conn =
|
||||
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
|
||||
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
|
||||
let auth = AuthContext::anonymous(b"alk/channels");
|
||||
let _server_handle = tokio::spawn(async move {
|
||||
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
|
||||
});
|
||||
|
||||
let client = ChannelClient::from_connection_with_serving(
|
||||
client_conn,
|
||||
Some(ServingConfig {
|
||||
registry: Arc::clone(&consumer_registry),
|
||||
identity_provider: Arc::new(NoopIdProvider),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("channel client init");
|
||||
|
||||
let accept_conn = accept_conn_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("accept side channel-0 connection handle");
|
||||
|
||||
// Phase 1: the consumer subscribes to the hub's `consumer/tick`
|
||||
// — the consumer's serving loop pumps the Sub inline (pre-fix)
|
||||
// or in a spawned task (post-fix). The stream stays open.
|
||||
use futures::stream::StreamExt;
|
||||
let mut ticks = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
accept_conn.subscribe("consumer/tick", serde_json::json!({})),
|
||||
)
|
||||
.await
|
||||
.expect("subscribe timed out");
|
||||
let first = tokio::time::timeout(std::time::Duration::from_secs(5), ticks.next())
|
||||
.await
|
||||
.expect("first tick timed out")
|
||||
.expect("first tick item");
|
||||
assert!(
|
||||
first.result.is_ok(),
|
||||
"first tick ok, got {:?}",
|
||||
first.result
|
||||
);
|
||||
|
||||
// Phase 2: with the Sub live, the consumer calls the hub's
|
||||
// `hub/interleave` — the wire-dispatched handler issues an
|
||||
// outbound hub→consumer call on the same connection while the
|
||||
// consumer's serving loop is serving the Sub. Pre-fix, the
|
||||
// consumer's serving loop held the Sub pump inline and this
|
||||
// call starved (30s sweeper); post-fix it resolves.
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client.call_open_op(
|
||||
"hub/interleave",
|
||||
serde_json::json!({ "when": "while-sub-live" }),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("interleaved outbound call starved while Sub served (G-01 shape)");
|
||||
assert!(
|
||||
response.result.is_ok(),
|
||||
"interleaved call resolves, got {:?}",
|
||||
response.result
|
||||
);
|
||||
let out = response.result.unwrap();
|
||||
assert_eq!(
|
||||
out.get("outbound"),
|
||||
Some(&serde_json::json!({ "via": "interleave" })),
|
||||
"the nested outbound hub→consumer call resolved through the serving loop"
|
||||
);
|
||||
|
||||
// The Sub is still live after the interleaved call.
|
||||
let second = tokio::time::timeout(std::time::Duration::from_secs(5), ticks.next())
|
||||
.await
|
||||
.expect("second tick timed out")
|
||||
.expect("second tick item");
|
||||
assert!(
|
||||
second.result.is_ok(),
|
||||
"sub still live after interleaved call, got {:?}",
|
||||
second.result
|
||||
);
|
||||
}
|
||||
|
||||
/// G-02 gate (review 005): the forwarding stub is exercised
|
||||
/// through **nested composition from a wire-dispatched hub
|
||||
/// handler** — the exact path the direct wire call in
|
||||
/// `op_register_announce_then_hub_call_routes_back_to_consumer`
|
||||
/// bypasses, and the path G-01 deadlocked (the inline dispatch
|
||||
/// blocked the read loop that had to resolve the stub's nested
|
||||
/// call; it resolved only via the 30s sweeper). The hub registry
|
||||
/// serves `hub/compose`, whose handler invokes the announced op
|
||||
/// through `context.env` — the `PeerCompositeEnv` →
|
||||
/// `OverlayOperationEnv` resolution a real hub handler produces.
|
||||
/// Resolving quickly (bounded well under the sweeper interval)
|
||||
/// proves the serving loop resolves same-connection nested
|
||||
/// composition live.
|
||||
#[tokio::test]
|
||||
async fn hub_handler_composes_peer_announced_op_via_nested_composition() {
|
||||
// Consumer side: serves `consumer/exec` locally; announces it.
|
||||
let consumer_registry = crate::registry::registration::OperationRegistry::new();
|
||||
consumer_registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_query_spec("consumer/exec"),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(
|
||||
ctx.request_id,
|
||||
serde_json::json!({ "ran_on": "consumer", "input": input }),
|
||||
)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
crate::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let consumer_registry = Arc::new(consumer_registry);
|
||||
|
||||
// Accept side (hub): serves `op/register` + `hub/compose` on a
|
||||
// fork. `hub/compose` composes the announced op via
|
||||
// `context.env` — the nested-composition shape ADR-022's
|
||||
// amendment promises. The stub is not invoked by the assertion
|
||||
// below directly; it is resolved through the connection
|
||||
// overlay `compose_root_env` attaches. The channel-0
|
||||
// connection carries the peer's identity (the assembly layer
|
||||
// resolves it from the accepted connection's `AuthContext`) —
|
||||
// `compose_root_env` attaches the connection overlay keyed by
|
||||
// that identity (ADR-030 §5); an identity-less connection gets
|
||||
// no overlay.
|
||||
let (accept_conn_tx, mut accept_conn_rx) =
|
||||
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
|
||||
let accept_registry_for_hook =
|
||||
Arc::new(crate::registry::registration::OperationRegistry::new());
|
||||
let accept_tx_for_hook = accept_conn_tx.clone();
|
||||
let install_hook: crate::channels::adapter::InstallChannelZero =
|
||||
Arc::new(move |_manager, channel0_conn, auth| {
|
||||
if let Some(identity) = auth.identity.clone() {
|
||||
let _ = channel0_conn.set_identity(identity);
|
||||
}
|
||||
let accept_registry = Arc::clone(&accept_registry_for_hook);
|
||||
let accept_conn_tx = accept_tx_for_hook.clone();
|
||||
tokio::spawn(async move {
|
||||
let channel0_bidi = match channel0_conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
let _ = accept_conn_tx.send(Arc::clone(&call_connection)).await;
|
||||
|
||||
let reg = accept_registry.fork();
|
||||
reg.register(HandlerRegistration::new(
|
||||
crate::registry::op_register::op_register_spec(AccessControl::default()),
|
||||
HandlerKind::Once(crate::registry::op_register::op_register_handler(
|
||||
Arc::clone(&call_connection),
|
||||
)),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
crate::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
reg.register(HandlerRegistration::new(
|
||||
external_query_spec("hub/compose"),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
let name = input
|
||||
.get("op")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let (namespace, operation) = match name.split_once('/') {
|
||||
Some(parts) => parts,
|
||||
None => {
|
||||
return ResponseEnvelope::error(
|
||||
ctx.request_id,
|
||||
crate::protocol::wire::CallError::invalid_input(
|
||||
"hub/compose input missing `op` as `ns/op`",
|
||||
),
|
||||
)
|
||||
}
|
||||
};
|
||||
let response = ctx.env.invoke(namespace, operation, input, &ctx).await;
|
||||
ResponseEnvelope {
|
||||
request_id: ctx.request_id,
|
||||
result: response.result,
|
||||
}
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
Some(crate::registry::context::ScopedPeerEnv::new([
|
||||
"consumer/exec",
|
||||
])),
|
||||
crate::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let dp = Dispatcher::new(
|
||||
Arc::new(reg),
|
||||
Arc::new(NoopIdProvider) as Arc<dyn IdentityProvider>,
|
||||
);
|
||||
dp.serve_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
});
|
||||
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn =
|
||||
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let server_conn =
|
||||
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
|
||||
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
|
||||
let auth = AuthContext {
|
||||
identity: Some(crate::core::auth::Identity {
|
||||
id: "consumer-peer".to_string(),
|
||||
scopes: vec![],
|
||||
resources: Default::default(),
|
||||
}),
|
||||
..AuthContext::anonymous(b"alk/channels")
|
||||
};
|
||||
let _server_handle = tokio::spawn(async move {
|
||||
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
|
||||
});
|
||||
|
||||
let client = ChannelClient::from_connection_with_serving(
|
||||
client_conn,
|
||||
Some(ServingConfig {
|
||||
registry: Arc::clone(&consumer_registry),
|
||||
identity_provider: Arc::new(NoopIdProvider),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("channel client init");
|
||||
|
||||
let _accept_conn = accept_conn_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("accept side channel-0 connection handle");
|
||||
|
||||
// Consumer announces `consumer/exec` over channel 0.
|
||||
let announce = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
client.call_open_op(
|
||||
crate::registry::op_register::OP_REGISTER_NAME,
|
||||
crate::registry::op_register::OpRegisterRequest {
|
||||
spec: external_query_spec("consumer/exec"),
|
||||
replace: false,
|
||||
}
|
||||
.to_json(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("op/register announce timed out");
|
||||
assert!(
|
||||
announce.result.is_ok(),
|
||||
"announce ok, got {:?}",
|
||||
announce.result
|
||||
);
|
||||
|
||||
// A wire-dispatched hub handler composes the announced op via
|
||||
// nested composition: the consumer calls `hub/compose` over the
|
||||
// wire, the hub's serving loop dispatches it, and the handler
|
||||
// resolves `consumer/exec` through the forwarding stub. Under
|
||||
// G-01 this resolved only at 30s via the sweeper; the bounded
|
||||
// timeout proves live resolution.
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client.call_open_op(
|
||||
"hub/compose",
|
||||
serde_json::json!({ "op": "consumer/exec", "task": "nested" }),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("nested composition through the forwarding stub timed out (G-01 shape)");
|
||||
|
||||
assert!(
|
||||
response.result.is_ok(),
|
||||
"nested composition resolves through the forwarding stub, got {:?}",
|
||||
response.result
|
||||
);
|
||||
assert_eq!(
|
||||
response.result.unwrap(),
|
||||
serde_json::json!({ "ran_on": "consumer", "input": { "op": "consumer/exec", "task": "nested" } })
|
||||
);
|
||||
}
|
||||
|
||||
// --- review 004 Unit 2 acceptance gate (F-02/F-06 fork) ----------------
|
||||
|
||||
/// F-02/F-06 gate: the open op is registered on a **fork** of the
|
||||
|
||||
Reference in new issue
Block a user