feat: producer-listen — listen establisher (shape 2) + AcceptQueue contract
- AcceptFn: the injected accepted-connection source (the assembly layer owns the listener + accept loop; the protocol never binds — OQ-TN-04). - AcceptQueue: the protocol-side queue contract (async push/pop/ close; FIFO always-before-take ordering; close-while-waiting resolves None). Empty-queue posture is assembly-owned (a late accept is legitimate; resource_shortage is the closure's mapping; the wrapper's 10s establishment timeout is the backstop). - listen_establisher: same open op, same params, same typed errors — registry namespace gate → accept() → Establishment::new(plan); the pump handler is untouched (plan-flow with a different source). - register_tunnel_listen_openable: same spec/pump registration with the listen establisher (the honest shape vs a dial/accept enum: the establisher is the only difference; one establisher per op id per session registry — documented). - Tests (tests/producer_listen.rs, 6): listen flow end-to-end, FIFO ordering across two opens (R-01 plan-flow, listen-flavored), empty queue -> resource_shortage, closed listener -> dial_failed, unknown resource -> unknown_resource, late-push wait-then-resolve. - Harness: RegistrationMode enum + wire_listen. - TargetHandle gains a structural Debug impl (test ergonomics). Verified: cargo test green (44), clippy -D warnings (native + wasm32), fmt clean, wasm32 check passes.
This commit is contained in:
@@ -28,4 +28,8 @@ pub use consumer::{open_reverse_channel, TakenHalves, TunnelSession};
|
|||||||
pub use error::{ReverseOpenError, TunnelError, TunnelIoError, TunnelOpenError};
|
pub use error::{ReverseOpenError, TunnelError, TunnelIoError, TunnelOpenError};
|
||||||
pub use params::ChannelOpenError;
|
pub use params::ChannelOpenError;
|
||||||
pub use params::{establishment_reason, TUNNEL_ALPN, TUNNEL_OPEN_SCOPE};
|
pub use params::{establishment_reason, TUNNEL_ALPN, TUNNEL_OPEN_SCOPE};
|
||||||
|
pub use producer::{
|
||||||
|
register_tunnel_listen_openable, AcceptFn, AcceptQueue, DialFn, ResourceRegistry, TargetHandle,
|
||||||
|
TunnelEstablishError,
|
||||||
|
};
|
||||||
pub use wire::MAX_DATAGRAM_LEN;
|
pub use wire::MAX_DATAGRAM_LEN;
|
||||||
|
|||||||
+157
@@ -47,6 +47,15 @@ pub struct TargetHandle {
|
|||||||
pub write: Box<dyn AsyncWrite + Send + Sync + Unpin>,
|
pub write: Box<dyn AsyncWrite + Send + Sync + Unpin>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for TargetHandle {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("TargetHandle")
|
||||||
|
.field("read", &"<boxed>")
|
||||||
|
.field("write", &"<boxed>")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The typed establishment-failure vocabulary (ADR-049 §3's reason
|
/// The typed establishment-failure vocabulary (ADR-049 §3's reason
|
||||||
/// codes as this crate's establisher errors; mapped 1:1 onto
|
/// codes as this crate's establisher errors; mapped 1:1 onto
|
||||||
/// `EstablishmentError`).
|
/// `EstablishmentError`).
|
||||||
@@ -179,6 +188,129 @@ fn parse_params(input: &Value) -> Result<TunnelParams, TunnelEstablishError> {
|
|||||||
.map_err(|e| TunnelEstablishError::HandlerError(format!("invalid params: {e}")))
|
.map_err(|e| TunnelEstablishError::HandlerError(format!("invalid params: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The listen establisher (shape 2 — producer.md §The Establisher):
|
||||||
|
/// the same open op, same params, same typed errors; the establisher
|
||||||
|
/// pops the next accepted connection from the injected [`AcceptFn`]
|
||||||
|
/// instead of dialing. The resource lookup follows the same registry
|
||||||
|
/// miss mapping (`unknown_resource`); the accept closure's failures
|
||||||
|
/// map at the closure (empty queue → `resource_shortage`, closed
|
||||||
|
/// listener → `dial_failed` — producer.md's table). The pump handler
|
||||||
|
/// is UNCHANGED: the listen variant is plan-flow with a different
|
||||||
|
/// halves source.
|
||||||
|
///
|
||||||
|
/// The registry check is a namespace gate only — the listen producer's
|
||||||
|
/// "backing" is its accept queue, not a dial target. A resource the
|
||||||
|
/// producer does not produce is still `unknown_resource` (same as
|
||||||
|
/// dial); an openable resource with nothing yet accepted is
|
||||||
|
/// `resource_shortage` (the accept closure's call).
|
||||||
|
pub fn listen_establisher(registry: ResourceRegistry, accept: AcceptFn) -> OpenEstablisher {
|
||||||
|
Arc::new(move |input: Value, _auth| {
|
||||||
|
let registry = registry.clone();
|
||||||
|
let accept = Arc::clone(&accept);
|
||||||
|
Box::pin(async move {
|
||||||
|
let params: TunnelParams = parse_params(&input)?;
|
||||||
|
registry
|
||||||
|
.lookup(params.substrate, ¶ms.resource)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| {
|
||||||
|
TunnelEstablishError::UnknownResource(format!(
|
||||||
|
"resource `{}` ({:?}) not produced by this producer",
|
||||||
|
params.resource, params.substrate
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let accepted: TargetHandle = accept().await?;
|
||||||
|
Ok(Establishment::new(Arc::new(accepted) as ChannelPlan))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accepted-connection source the assembly layer injects for a
|
||||||
|
/// LISTEN producer (shape 2 — the `-R` far-side listener; producer.md
|
||||||
|
/// §The Establisher): pops one accepted connection's halves. The
|
||||||
|
/// assembly layer owns the listener + its accept loop — the protocol
|
||||||
|
/// crate never binds (OQ-TN-04). The queue contract lives in
|
||||||
|
/// [`AcceptQueue`]; the closure pops from it (or from whatever the
|
||||||
|
/// assembly chose).
|
||||||
|
pub type AcceptFn = Arc<
|
||||||
|
dyn Fn() -> futures::future::BoxFuture<'static, Result<TargetHandle, TunnelEstablishError>>
|
||||||
|
+ Send
|
||||||
|
+ Sync,
|
||||||
|
>;
|
||||||
|
|
||||||
|
/// The bounded queue between an assembly-owned accept loop and the
|
||||||
|
/// listen establisher (the protocol-side queue contract). The accept
|
||||||
|
/// loop feeds ([`AcceptQueue::push`]); the establisher pops during the
|
||||||
|
/// open op's establishment phase ([`AcceptQueue::pop`]). `pop` is
|
||||||
|
/// bounded by the establisher's own deadline (ADR-049 §2) — but an
|
||||||
|
/// empty queue is NOT inherently an error (a late accept is
|
||||||
|
/// legitimate); the mapping to `resource_shortage` is the ASSEMBLY
|
||||||
|
/// layer's posture (its accept closure knows the listener's bound —
|
||||||
|
/// a drained listener or an accept budget). The wrapper's
|
||||||
|
/// establishment timeout is the backstop above that.
|
||||||
|
///
|
||||||
|
/// `pop` returns `None` when the queue is closed-and-empty (the
|
||||||
|
/// listener is gone — the assembly layer calls [`AcceptQueue::close`]
|
||||||
|
/// on teardown). FIFO order: every popped handle was accepted BEFORE
|
||||||
|
/// that pop began (always-before-take ordering — push enqueues under
|
||||||
|
/// the same lock that pops dequeue under, and `pop` re-checks the
|
||||||
|
/// queue after every wake).
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct AcceptQueue {
|
||||||
|
inner: Arc<tokio::sync::Mutex<AcceptQueueInner>>,
|
||||||
|
notify: Arc<tokio::sync::Notify>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct AcceptQueueInner {
|
||||||
|
handles: std::collections::VecDeque<TargetHandle>,
|
||||||
|
closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AcceptQueue {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one accepted handle (the accept loop's step). `Err` once
|
||||||
|
/// the queue is closed (the listener is gone; the accept loop
|
||||||
|
/// should stop feeding).
|
||||||
|
pub async fn push(&self, handle: TargetHandle) -> Result<(), TargetHandle> {
|
||||||
|
{
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
if inner.closed {
|
||||||
|
return Err(handle);
|
||||||
|
}
|
||||||
|
inner.handles.push_back(handle);
|
||||||
|
}
|
||||||
|
self.notify.notify_waiters();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop the next accepted handle, waiting while the queue is empty
|
||||||
|
/// and open. `None` = the queue is closed-and-empty (the listener
|
||||||
|
/// is gone) or the queue was closed while waiting.
|
||||||
|
pub async fn pop(&self) -> Option<TargetHandle> {
|
||||||
|
loop {
|
||||||
|
{
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
if let Some(handle) = inner.handles.pop_front() {
|
||||||
|
return Some(handle);
|
||||||
|
}
|
||||||
|
if inner.closed {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.notify.notified().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the queue: waiting `pop`s resolve `None` immediately.
|
||||||
|
pub async fn close(&self) {
|
||||||
|
self.inner.lock().await.closed = true;
|
||||||
|
self.notify.notify_waiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The pump handler: accept the channel's yield-once `BiStream`,
|
/// The pump handler: accept the channel's yield-once `BiStream`,
|
||||||
/// downcast the plan to `TargetHandle`, and await `pump_bidi` inline
|
/// downcast the plan to `TargetHandle`, and await `pump_bidi` inline
|
||||||
/// (R-02 — the returned `JoinHandle` tracks the data-plane lifetime;
|
/// (R-02 — the returned `JoinHandle` tracks the data-plane lifetime;
|
||||||
@@ -236,3 +368,28 @@ pub fn register_tunnel_openable(
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register the tunnel open op with a LISTEN establisher (shape 2):
|
||||||
|
/// the same spec + pump handler as [`register_tunnel_openable`]; the
|
||||||
|
/// establisher pops accepted handles from the injected [`AcceptFn`]
|
||||||
|
/// instead of dialing. One registration per operation id per registry
|
||||||
|
/// — a session serves either a dial establisher or a listen
|
||||||
|
/// establisher for `channels/tunnel/sub`; a producer that does both
|
||||||
|
/// registers on separate registries (per-connection registries are
|
||||||
|
/// independent, producer.md §Registration API).
|
||||||
|
pub fn register_tunnel_listen_openable(
|
||||||
|
core: &ChannelCore,
|
||||||
|
registry: &ResourceRegistry,
|
||||||
|
on_registry: &Arc<OperationRegistry>,
|
||||||
|
auth: AuthContext,
|
||||||
|
accept: AcceptFn,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
core.register_openable_with_establisher(
|
||||||
|
crate::params::tunnel_open_spec(),
|
||||||
|
Some(listen_establisher(registry.clone(), accept)),
|
||||||
|
make_tunnel_pump_handler(),
|
||||||
|
on_registry,
|
||||||
|
auth,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: tunnels/producer-listen
|
id: tunnels/producer-listen
|
||||||
name: Listen establisher — the producer-side listener variant
|
name: Listen establisher — the producer-side listener variant
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [tunnels/producer-open-op]
|
depends_on: [tunnels/producer-open-op]
|
||||||
scope: narrow
|
scope: narrow
|
||||||
risk: medium
|
risk: medium
|
||||||
@@ -87,6 +87,59 @@ queue → `resource_shortage`, closed listener → `dial_failed`.
|
|||||||
|
|
||||||
> Agent fills during implementation.
|
> Agent fills during implementation.
|
||||||
|
|
||||||
|
- **Registration shape (the "pick the honest shape" decision):** a
|
||||||
|
separate `register_tunnel_listen_openable(core, registry, on_registry,
|
||||||
|
auth, accept: AcceptFn)` — not a dial/accept enum on
|
||||||
|
`register_tunnel_openable`. Rationale: the establisher is the only
|
||||||
|
difference; `register_openable_with_establisher` takes a pre-built
|
||||||
|
`OpenEstablisher`, so both variants share the same spec + pump
|
||||||
|
handler + registration call, and a session serves ONE establisher
|
||||||
|
per op id (a dual-shape producer registers on separate per-session
|
||||||
|
registries). An enum would have added a mode flag the wrapper does
|
||||||
|
not need.
|
||||||
|
- **`AcceptQueue::push` is async** (lock-acquiring): the accept loop's
|
||||||
|
step is async anyway; a sync `push` would need a sync mutex with
|
||||||
|
cross-thread notify — not worth it for a queue that drains one
|
||||||
|
handle per open.
|
||||||
|
- **Empty-queue posture is assembly-owned, not queue-owned:** an
|
||||||
|
empty queue during establishment is NOT inherently an error (a late
|
||||||
|
accept is legitimate — `accept_wait_resolves_when_push_arrives_late`
|
||||||
|
pins the wait-then-resolve shape). The mapping to
|
||||||
|
`resource_shortage` belongs to the accept closure (the assembly
|
||||||
|
knows its listener's bound: drained listener, accept budget); the
|
||||||
|
wrapper's establishment timeout (10s) is the backstop. The task
|
||||||
|
sketch's "empty queue → resource_shortage" is realized by the
|
||||||
|
assembly mapping — the test pins the fail-fast closure posture.
|
||||||
|
- **`closed_listener → dial_failed`** is likewise the closure's
|
||||||
|
mapping (the queue's `close()` + pop → `None` → the closure maps).
|
||||||
|
The typed-error table holds: both reasons verified on the wire via
|
||||||
|
`channel:open_failed` details.
|
||||||
|
- **AcceptFn = the queue pop wrapped in a closure** in the tests; the
|
||||||
|
`local`-gated listener helper (local-socket-halves) feeds real
|
||||||
|
accepted sockets through the same queue.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> Agent fills this on completion.
|
> Agent fills this on completion.
|
||||||
|
|
||||||
|
Implemented the listen establisher (shape 2) per producer.md §The
|
||||||
|
Establisher: `AcceptFn` (the injected accepted-connection source),
|
||||||
|
`AcceptQueue` (the protocol-side queue contract: async push/pop/
|
||||||
|
close, FIFO with always-before-take ordering, close-while-waiting
|
||||||
|
resolves None), `listen_establisher` (registry namespace gate →
|
||||||
|
accept() → `Establishment::new(plan)` — the same plan flow, a
|
||||||
|
different halves source; the pump handler is untouched), and
|
||||||
|
`register_tunnel_listen_openable` (the same spec/pump registration,
|
||||||
|
listen establisher).
|
||||||
|
|
||||||
|
Tests: `tests/producer_listen.rs` (6) — the listen flow end-to-end
|
||||||
|
(pre-accepted handle round-trips through the pump), FIFO ordering
|
||||||
|
across two opens (the R-01 plan-flow property, listen-flavored),
|
||||||
|
empty queue → `resource_shortage` (fail-fast closure posture pinned),
|
||||||
|
closed listener → `dial_failed`, unknown resource →
|
||||||
|
`unknown_resource`, and the late-push wait-then-resolve shape.
|
||||||
|
Harness: `RegistrationMode` enum + `wire_listen` on the existing
|
||||||
|
topology.
|
||||||
|
|
||||||
|
Verified: cargo test green (44 total), clippy `-D warnings` clean
|
||||||
|
(native + wasm32), fmt clean, wasm32 check passes.
|
||||||
+40
-2
@@ -92,9 +92,16 @@ pub struct Topology {
|
|||||||
/// `serving_identity` is the explicit `ServingConfig.identity`
|
/// `serving_identity` is the explicit `ServingConfig.identity`
|
||||||
/// override (CF-005 remediation (a)) — `None` in the primary path.
|
/// override (CF-005 remediation (a)) — `None` in the primary path.
|
||||||
/// `provider` resolves payload tokens (the hub-forwarding path).
|
/// `provider` resolves payload tokens (the hub-forwarding path).
|
||||||
|
/// The open-op registration mode: a dial establisher (shape 1) or a
|
||||||
|
/// listen establisher over an assembly-fed [`AcceptQueue`] (shape 2).
|
||||||
|
pub enum RegistrationMode {
|
||||||
|
Dial(alktunnels::producer::DialFn),
|
||||||
|
Listen(alktunnels::producer::AcceptFn),
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn wire_with(
|
pub async fn wire_with(
|
||||||
registry: ResourceRegistry,
|
registry: ResourceRegistry,
|
||||||
dial: alktunnels::producer::DialFn,
|
mode: RegistrationMode,
|
||||||
transport_identity: Option<Identity>,
|
transport_identity: Option<Identity>,
|
||||||
serving_identity: Option<Identity>,
|
serving_identity: Option<Identity>,
|
||||||
provider: Arc<dyn IdentityProvider>,
|
provider: Arc<dyn IdentityProvider>,
|
||||||
@@ -185,6 +192,8 @@ pub async fn wire_with(
|
|||||||
producer_client.manager().clone(),
|
producer_client.manager().clone(),
|
||||||
alkcall::channels::policy::default_policy(),
|
alkcall::channels::policy::default_policy(),
|
||||||
);
|
);
|
||||||
|
match mode {
|
||||||
|
RegistrationMode::Dial(dial) => {
|
||||||
register_tunnel_openable(
|
register_tunnel_openable(
|
||||||
&core,
|
&core,
|
||||||
®istry,
|
®istry,
|
||||||
@@ -194,6 +203,18 @@ pub async fn wire_with(
|
|||||||
Some(Arc::clone(&identity_witness)),
|
Some(Arc::clone(&identity_witness)),
|
||||||
)
|
)
|
||||||
.expect("register tunnel openable");
|
.expect("register tunnel openable");
|
||||||
|
}
|
||||||
|
RegistrationMode::Listen(accept) => {
|
||||||
|
alktunnels::producer::register_tunnel_listen_openable(
|
||||||
|
&core,
|
||||||
|
®istry,
|
||||||
|
&producer_op_registry,
|
||||||
|
AuthContext::anonymous(b"alk/tunnel"),
|
||||||
|
accept,
|
||||||
|
)
|
||||||
|
.expect("register tunnel listen openable");
|
||||||
|
}
|
||||||
|
}
|
||||||
let producer_client = Arc::new(producer_client);
|
let producer_client = Arc::new(producer_client);
|
||||||
|
|
||||||
// --- consumer pieces (captured from the hook) -------------------------
|
// --- consumer pieces (captured from the hook) -------------------------
|
||||||
@@ -215,7 +236,24 @@ pub async fn wire_with(
|
|||||||
pub async fn wire(registry: ResourceRegistry, dial: alktunnels::producer::DialFn) -> Topology {
|
pub async fn wire(registry: ResourceRegistry, dial: alktunnels::producer::DialFn) -> Topology {
|
||||||
wire_with(
|
wire_with(
|
||||||
registry,
|
registry,
|
||||||
dial,
|
RegistrationMode::Dial(dial),
|
||||||
|
Some(consumer_identity()),
|
||||||
|
None,
|
||||||
|
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The listen topology (shape 2): the producer's establisher pops
|
||||||
|
/// accepted handles from the assembly-fed [`AcceptQueue`] instead of
|
||||||
|
/// dialing. Same identity posture as [`wire`].
|
||||||
|
pub async fn wire_listen(
|
||||||
|
registry: ResourceRegistry,
|
||||||
|
accept: alktunnels::producer::AcceptFn,
|
||||||
|
) -> Topology {
|
||||||
|
wire_with(
|
||||||
|
registry,
|
||||||
|
RegistrationMode::Listen(accept),
|
||||||
Some(consumer_identity()),
|
Some(consumer_identity()),
|
||||||
None,
|
None,
|
||||||
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
//! Integration tests for the listen establisher (shape 2 — producer.md
|
||||||
|
//! §The Establisher): a producer whose resource is a LISTENER. The
|
||||||
|
//! assembly layer owns the listener + its accept loop and feeds an
|
||||||
|
//! [`AcceptQueue`]; the establisher pops one accepted handle during
|
||||||
|
//! the open op's establishment phase. Same open op, same params, same
|
||||||
|
//! typed errors — the pump handler is unchanged.
|
||||||
|
//!
|
||||||
|
//! Covered: the listen flow end-to-end (in-process listener queue fed
|
||||||
|
//! by a test accept loop; consumer opens toward it; the accepted
|
||||||
|
//! handle is the plan payload; the two-pump round-trip completes),
|
||||||
|
//! pop-during-establishment ordering (always-before-take), empty
|
||||||
|
//! queue → `resource_shortage`, closed listener → `dial_failed`,
|
||||||
|
//! unknown resource → `unknown_resource`, and the out-of-band close.
|
||||||
|
|
||||||
|
mod harness;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use alktunnels::params::{Substrate, TunnelParams};
|
||||||
|
use alktunnels::producer::{AcceptQueue, ResourceRegistry, TargetHandle, TunnelEstablishError};
|
||||||
|
use alktunnels::{open_reverse_channel, TunnelSession};
|
||||||
|
|
||||||
|
use harness::{wire_listen, Topology};
|
||||||
|
|
||||||
|
fn params(resource: &str) -> TunnelParams {
|
||||||
|
TunnelParams {
|
||||||
|
resource: resource.to_string(),
|
||||||
|
substrate: Substrate::Tcp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An accept closure popping from the queue (the assembly-side
|
||||||
|
/// contract the real `local` listener helper will present).
|
||||||
|
fn accept_from_queue(queue: AcceptQueue) -> alktunnels::producer::AcceptFn {
|
||||||
|
Arc::new(move || {
|
||||||
|
let queue = queue.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
queue
|
||||||
|
.pop()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| TunnelEstablishError::DialFailed("listener closed".to_string()))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An in-process accepted handle: a duplex pair, the far end driven by
|
||||||
|
/// an echo task (the same shape the dial harness echoes with).
|
||||||
|
fn accepted_handle() -> TargetHandle {
|
||||||
|
let (consumer_side, target_side) = tokio::io::duplex(64 * 1024);
|
||||||
|
let (mut t_read, mut t_write) = tokio::io::split(target_side);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
let mut buf = vec![0u8; 8192];
|
||||||
|
loop {
|
||||||
|
match t_read.read(&mut buf).await {
|
||||||
|
Ok(0) | Err(_) => return,
|
||||||
|
Ok(n) => {
|
||||||
|
if t_write.write_all(&buf[..n]).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let (c_read, c_write) = tokio::io::split(consumer_side);
|
||||||
|
TargetHandle {
|
||||||
|
read: Box::new(c_read),
|
||||||
|
write: Box::new(c_write),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn only_channel_0(topo: &Topology) -> bool {
|
||||||
|
topo.consumer_manager
|
||||||
|
.channel_ids()
|
||||||
|
.iter()
|
||||||
|
.all(|&id| id == 0)
|
||||||
|
&& topo
|
||||||
|
.producer
|
||||||
|
.manager()
|
||||||
|
.channel_ids()
|
||||||
|
.iter()
|
||||||
|
.all(|&id| id == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn listen_flow_round_trips() {
|
||||||
|
// The listen topology: the assembly accept loop pre-accepts one
|
||||||
|
// connection; the consumer opens toward the resource; the accepted
|
||||||
|
// handle is the plan payload; the pump round-trips.
|
||||||
|
let queue = AcceptQueue::new();
|
||||||
|
queue.push(accepted_handle()).await.expect("push");
|
||||||
|
|
||||||
|
let registry = ResourceRegistry::new();
|
||||||
|
registry
|
||||||
|
.register("listener", Substrate::Tcp, "assembly-owned")
|
||||||
|
.await;
|
||||||
|
let topo = wire_listen(registry, accept_from_queue(queue.clone())).await;
|
||||||
|
|
||||||
|
let channel_id = open_reverse_channel(&topo.consumer_call, ¶ms("listener"), None)
|
||||||
|
.await
|
||||||
|
.expect("listen open");
|
||||||
|
let mut session = TunnelSession::adopt(
|
||||||
|
&topo.consumer_manager,
|
||||||
|
channel_id,
|
||||||
|
Substrate::Tcp,
|
||||||
|
alktunnels::TUNNEL_ALPN,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("adopt");
|
||||||
|
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
let (read, write) = session.stream_halves().expect("stream halves");
|
||||||
|
write.write_all(b"listen-ping").await.expect("write");
|
||||||
|
write.flush().await.expect("flush");
|
||||||
|
let mut buf = vec![0u8; 11];
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
|
||||||
|
.await
|
||||||
|
.expect("round trip")
|
||||||
|
.expect("read");
|
||||||
|
assert_eq!(&buf, b"listen-ping");
|
||||||
|
|
||||||
|
session.close().await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||||
|
assert!(only_channel_0(&topo));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pop_during_establishment_is_always_before_take() {
|
||||||
|
// The always-before-take guarantee: two accepts pushed BEFORE an
|
||||||
|
// open are popped in FIFO order across two opens — each open's
|
||||||
|
// establisher sees the handle accepted before it (the plan flow:
|
||||||
|
// every open's result flows to ITS pump).
|
||||||
|
let queue = AcceptQueue::new();
|
||||||
|
queue.push(accepted_handle()).await.expect("push 1");
|
||||||
|
queue.push(accepted_handle()).await.expect("push 2");
|
||||||
|
|
||||||
|
let registry = ResourceRegistry::new();
|
||||||
|
registry
|
||||||
|
.register("listener", Substrate::Tcp, "assembly-owned")
|
||||||
|
.await;
|
||||||
|
let topo = wire_listen(registry, accept_from_queue(queue.clone())).await;
|
||||||
|
|
||||||
|
let id1 = open_reverse_channel(&topo.consumer_call, ¶ms("listener"), None)
|
||||||
|
.await
|
||||||
|
.expect("open 1");
|
||||||
|
let id2 = open_reverse_channel(&topo.consumer_call, ¶ms("listener"), None)
|
||||||
|
.await
|
||||||
|
.expect("open 2");
|
||||||
|
assert_ne!(id1, id2);
|
||||||
|
|
||||||
|
// Each session round-trips its own marker (the R-01 plan-flow
|
||||||
|
// property, listen-flavored: distinct handles per open).
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
for (channel_id, marker) in [(id1, "one"), (id2, "two")] {
|
||||||
|
let mut session = TunnelSession::adopt(
|
||||||
|
&topo.consumer_manager,
|
||||||
|
channel_id,
|
||||||
|
Substrate::Tcp,
|
||||||
|
alktunnels::TUNNEL_ALPN,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("adopt");
|
||||||
|
let (read, write) = session.stream_halves().expect("halves");
|
||||||
|
write.write_all(marker.as_bytes()).await.expect("write");
|
||||||
|
write.flush().await.expect("flush");
|
||||||
|
let mut buf = vec![0u8; marker.len()];
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
|
||||||
|
.await
|
||||||
|
.expect("round trip")
|
||||||
|
.expect("read");
|
||||||
|
assert_eq!(buf, marker.as_bytes());
|
||||||
|
session.close().await;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||||
|
assert!(only_channel_0(&topo));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_queue_is_resource_shortage() {
|
||||||
|
// producer.md's error mapping: an empty queue during establishment
|
||||||
|
// → `resource_shortage`. The QUEUE's pop waits (a late accept is
|
||||||
|
// legitimate — see accept_wait_resolves_when_push_arrives_late);
|
||||||
|
// the assembly layer decides its own posture. An assembly that
|
||||||
|
// knows nothing is coming (a drained listener, a bounded accept
|
||||||
|
// budget) closes its queue or maps empty-pop to the typed error —
|
||||||
|
// here, the closure maps it directly (fail-fast posture).
|
||||||
|
let queue = AcceptQueue::new();
|
||||||
|
queue.close().await;
|
||||||
|
let registry = ResourceRegistry::new();
|
||||||
|
registry
|
||||||
|
.register("listener", Substrate::Tcp, "assembly-owned")
|
||||||
|
.await;
|
||||||
|
let accept: alktunnels::producer::AcceptFn = Arc::new(move || {
|
||||||
|
let queue = queue.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
// Bounded wait (the assembly's own deadline, not the
|
||||||
|
// wrapper's): a closed queue is an empty listen backlog —
|
||||||
|
// resource_shortage per producer.md's table.
|
||||||
|
match tokio::time::timeout(std::time::Duration::from_millis(50), queue.pop()).await {
|
||||||
|
Ok(Some(handle)) => Ok(handle),
|
||||||
|
_ => Err(TunnelEstablishError::ResourceShortage(
|
||||||
|
"no accepted connection available".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let topo = wire_listen(registry, accept).await;
|
||||||
|
|
||||||
|
match open_reverse_channel(&topo.consumer_call, ¶ms("listener"), None).await {
|
||||||
|
Ok(_) => panic!("empty-queue open must fail"),
|
||||||
|
Err(alktunnels::ReverseOpenError::Call(call_err)) => {
|
||||||
|
assert_eq!(call_err.code, "channel:open_failed");
|
||||||
|
let reason = call_err
|
||||||
|
.details
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|d| d.get("reason"))
|
||||||
|
.and_then(|r| r.as_str())
|
||||||
|
.expect("reason in details");
|
||||||
|
assert_eq!(reason, "resource_shortage");
|
||||||
|
}
|
||||||
|
Err(other) => panic!("expected Call error, got {other:?}"),
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||||
|
assert!(only_channel_0(&topo), "failed open leaves no channel");
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn closed_listener_is_dial_failed() {
|
||||||
|
// producer.md's error mapping: a closed listener (the queue closed
|
||||||
|
// before the open) → `dial_failed`-class.
|
||||||
|
let queue = AcceptQueue::new();
|
||||||
|
queue.close().await;
|
||||||
|
let registry = ResourceRegistry::new();
|
||||||
|
registry
|
||||||
|
.register("listener", Substrate::Tcp, "assembly-owned")
|
||||||
|
.await;
|
||||||
|
let topo = wire_listen(registry, accept_from_queue(queue.clone())).await;
|
||||||
|
|
||||||
|
match open_reverse_channel(&topo.consumer_call, ¶ms("listener"), None).await {
|
||||||
|
Ok(_) => panic!("closed-listener open must fail"),
|
||||||
|
Err(alktunnels::ReverseOpenError::Call(call_err)) => {
|
||||||
|
assert_eq!(call_err.code, "channel:open_failed");
|
||||||
|
let reason = call_err
|
||||||
|
.details
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|d| d.get("reason"))
|
||||||
|
.and_then(|r| r.as_str())
|
||||||
|
.expect("reason in details");
|
||||||
|
assert_eq!(reason, "dial_failed");
|
||||||
|
}
|
||||||
|
Err(other) => panic!("expected Call error, got {other:?}"),
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||||
|
assert!(only_channel_0(&topo));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_resource_stays_unknown() {
|
||||||
|
// The registry namespace gate: a resource the listen producer does
|
||||||
|
// not produce is `unknown_resource` (same as the dial shape).
|
||||||
|
let queue = AcceptQueue::new();
|
||||||
|
queue.push(accepted_handle()).await.expect("push");
|
||||||
|
let registry = ResourceRegistry::new();
|
||||||
|
registry
|
||||||
|
.register("listener", Substrate::Tcp, "assembly-owned")
|
||||||
|
.await;
|
||||||
|
let topo = wire_listen(registry, accept_from_queue(queue.clone())).await;
|
||||||
|
|
||||||
|
match open_reverse_channel(&topo.consumer_call, ¶ms("ghost"), None).await {
|
||||||
|
Ok(_) => panic!("unknown resource must fail"),
|
||||||
|
Err(alktunnels::ReverseOpenError::Call(call_err)) => {
|
||||||
|
assert_eq!(call_err.code, "channel:open_failed");
|
||||||
|
let reason = call_err
|
||||||
|
.details
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|d| d.get("reason"))
|
||||||
|
.and_then(|r| r.as_str())
|
||||||
|
.expect("reason in details");
|
||||||
|
assert_eq!(reason, "unknown_resource");
|
||||||
|
}
|
||||||
|
Err(other) => panic!("expected Call error, got {other:?}"),
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||||
|
assert!(only_channel_0(&topo));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn accept_wait_resolves_when_push_arrives_late() {
|
||||||
|
// Pop-during-establishment ordering under a LIVE accept loop: the
|
||||||
|
// establisher's pop waits; the accept loop pushes after a tick;
|
||||||
|
// the open resolves once the push lands (the wrapper's bounded
|
||||||
|
// wait — ADR-049 §2's deadline applies above us).
|
||||||
|
let queue = AcceptQueue::new();
|
||||||
|
let accept_loop = tokio::spawn({
|
||||||
|
let queue = queue.clone();
|
||||||
|
async move {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
queue.push(accepted_handle()).await.expect("push");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let registry = ResourceRegistry::new();
|
||||||
|
registry
|
||||||
|
.register("listener", Substrate::Tcp, "assembly-owned")
|
||||||
|
.await;
|
||||||
|
let topo = wire_listen(registry, accept_from_queue(queue.clone())).await;
|
||||||
|
|
||||||
|
let channel_id = open_reverse_channel(&topo.consumer_call, ¶ms("listener"), None)
|
||||||
|
.await
|
||||||
|
.expect("open resolves after the late push");
|
||||||
|
accept_loop.await.expect("accept loop task");
|
||||||
|
|
||||||
|
let session = TunnelSession::adopt(
|
||||||
|
&topo.consumer_manager,
|
||||||
|
channel_id,
|
||||||
|
Substrate::Tcp,
|
||||||
|
alktunnels::TUNNEL_ALPN,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("adopt");
|
||||||
|
let reaped = session.close().await;
|
||||||
|
assert!(reaped);
|
||||||
|
}
|
||||||
@@ -188,7 +188,7 @@ async fn open_denied_without_any_identity() {
|
|||||||
.await;
|
.await;
|
||||||
let topo = wire_with(
|
let topo = wire_with(
|
||||||
registry,
|
registry,
|
||||||
echo_dial(),
|
harness::RegistrationMode::Dial(echo_dial()),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
||||||
@@ -239,7 +239,7 @@ async fn open_token_overrides_transport_identity() {
|
|||||||
.await;
|
.await;
|
||||||
let topo = wire_with(
|
let topo = wire_with(
|
||||||
registry,
|
registry,
|
||||||
echo_dial(),
|
harness::RegistrationMode::Dial(echo_dial()),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Arc::new(harness::TestIdProvider),
|
Arc::new(harness::TestIdProvider),
|
||||||
|
|||||||
Reference in New Issue
Block a user