feat(review 006 Unit 1): channel-open establishment phase + typed client error (E-01, N-1)

Implements ADR-049 Unit 1 — the open-op wrapper gains an awaited,
bounded establishment phase, and the client stops erasing the error.

- OpenEstablisher hook + Establishment/EstablishmentError types:
  register_openable_with_establisher awaits the establisher bounded
  (earlier of dispatch deadline and per-registration timeout, else
  ESTABLISHMENT_TIMEOUT = 10s) after allocation, before the reply and
  before the pump handler is spawned (ADR-049 §1/§2). Implementation
  note: the establisher takes (input, auth) only — the channel's
  yield-once BiStream belongs exclusively to the pump handler
  (amendment recorded in ADR-049).
- Establishment failure: teardown_channel + opener-ledger take +
  policy.on_close un-increment (allocation and teardown balance;
  the ledger take is the atomic gate, ADR-047 §7), reply
  channel:open_failed with details {reason, message} — reason ∈
  dial_failed / unknown_resource / resource_shortage / handler_error
  / timeout (ADR-049 §3). SSH contract consumer-visible: a failed
  open never returns a channel_id.
- register_openable unchanged (no establisher = always-OK; existing
  registrations compile and behave identically — compat gate test).
- ChannelClient::open_channel returns ChannelOpenError (breaking at
  0.5.0): CallFailed { error: CallError } carries the wire error
  verbatim (establishment_reason() branches on details.reason);
  MissingChannelId / AdoptFailed cover the local-only shapes
  (ADR-049 §4, review 006 N-1).
- Tests cover all four verification gates from the review: e2e
  establisher failure through a real channels connection (typed
  reason + no-channel + ledger un-increment), bounded timeout,
  no-establisher compat, establisher-success pump round-trip; plus
  reason-vocabulary mapping and bound arithmetic.
- Bump to 0.5.0 (open_channel error-type change is semver-relevant).

Verification: cargo test (608 passed), clippy --all-targets -D
warnings, fmt --check, doc --no-deps, wasm32 check — all clean.
This commit is contained in:
glm-5.3-flash committed 2026-09-06 19:00:04 +00:00
1 parent 48ceeba55c
commit 2586c3b217
9 files changed
+1106 -31

No files matched your search

+323 -6
View File
@@ -25,13 +25,69 @@ use tokio::sync::Mutex;
use crate::core::types::{Connection, StreamError};
use crate::protocol::connection::CallConnection;
use crate::protocol::wire::ResponseEnvelope;
use crate::protocol::wire::{CallError, ResponseEnvelope};
use crate::registry::registration::OperationRegistry;
use super::manager::{ChannelManager, ChannelSide};
use super::mux::MuxRunner;
use super::reassembly::{MpscRecvStream, MpscSendStream};
/// The typed error from [`ChannelClient::open_channel`] (ADR-049 §4 —
/// review 006 N-1). The open op's `CallError` — including the
/// `channel:open_failed` code with `details: { reason, message }`
/// (ADR-049 §3) — is carried verbatim instead of flattened into a
/// string, so the consumer can branch on the establishment-failure
/// reason (`dial_failed`, `unknown_resource`, `resource_shortage`,
/// `handler_error`, `timeout`).
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum ChannelOpenError {
/// The open op failed — the `CallError` carries the wire code and
/// typed `details` (`channel:open_failed` + `details.reason` per
/// ADR-049 §3, `channel:too_many_channels`, `FORBIDDEN`, …).
#[error("open op failed: {error:?}")]
CallFailed { error: CallError },
/// The open op succeeded but the response carried no `channel_id`
/// (malformed responder).
#[error("open op response missing channel_id")]
MissingChannelId,
/// The open op succeeded but local channel adoption failed (the
/// per-connection cap, or an ID collision).
#[error("adopt_channel failed: {0}")]
AdoptFailed(#[from] super::manager::ManagerError),
}
impl ChannelOpenError {
/// The wire `CallError` behind this failure, when the open op
/// itself failed. `None` for the local-only variants
/// (`MissingChannelId` comes from a success envelope;
/// `AdoptFailed` is post-reply).
pub fn call_error(&self) -> Option<&CallError> {
match self {
ChannelOpenError::CallFailed { error } => Some(error),
ChannelOpenError::MissingChannelId | ChannelOpenError::AdoptFailed(_) => None,
}
}
/// The establishment-failure reason code — `details.reason` of a
/// `channel:open_failed` error (`dial_failed`, `unknown_resource`,
/// `resource_shortage`, `handler_error`, `timeout`). `None` for
/// any other failure shape.
pub fn establishment_reason(&self) -> Option<&str> {
match self {
ChannelOpenError::CallFailed { error }
if error.code == super::operations::CHANNEL_OPEN_FAILED =>
{
error
.details
.as_ref()
.and_then(|d| d.get("reason"))
.and_then(|r| r.as_str())
}
_ => None,
}
}
}
/// The client-side handle for a channels connection. Constructed via
/// [`ChannelClient::from_connection`] from an established
/// `Connection`. Holds the `ChannelManager` (for the demux/mux state)
@@ -224,26 +280,33 @@ impl ChannelClient {
///
/// `alpn` is the data-plane ALPN (e.g. `alk/tty`), used for
/// observability in the local manager.
///
/// The error is typed (ADR-049 §4 — review 006 N-1): a failed open
/// resolves [`ChannelOpenError::CallFailed`] carrying the
/// wire `CallError` verbatim — branch on
/// [`ChannelOpenError::establishment_reason`] for
/// `channel:open_failed`'s reason code (`dial_failed`,
/// `unknown_resource`, `resource_shortage`, `handler_error`,
/// `timeout`).
pub async fn open_channel(
&self,
operation_id: &str,
input: Value,
alpn: &str,
) -> Result<(u32, MpscSendStream, MpscRecvStream), String> {
) -> Result<(u32, MpscSendStream, MpscRecvStream), ChannelOpenError> {
let response = self.call_open_op(operation_id, input).await;
let out = response
.result
.map_err(|e| format!("open op failed: {e:?}"))?;
.map_err(|error| ChannelOpenError::CallFailed { error })?;
let channel_id = out
.get("channel_id")
.and_then(|v| v.as_u64())
.ok_or_else(|| "open op response missing channel_id".to_string())?
as u32;
.ok_or(ChannelOpenError::MissingChannelId)? as u32;
self.manager
.adopt_channel(channel_id, alpn, None)
.await
.map_err(|e| format!("adopt_channel failed: {e}"))
.map_err(ChannelOpenError::AdoptFailed)
.map(|(send, recv)| (channel_id, send, recv))
}
@@ -1256,6 +1319,260 @@ mod tests {
);
}
/// ADR-049 Unit 1 acceptance gate (review 006 E-01/N-1) — the
/// establisher fails after channel allocation: the consumer's
/// `open_channel` resolves `Err` carrying the wire `CallError`
/// (`channel:open_failed` + `details.reason`), no `channel_id` was
/// ever returned (the SSH "channel never exists opener-side"
/// property, consumer-visible), and the accept side's manager has
/// no channel afterward (teardown + ledger un-increment).
#[tokio::test]
async fn establisher_failure_resolves_typed_open_failed_on_the_client() {
use crate::channels::operations::{
ChannelCore, EstablishmentError, OpenEstablisher, OpenHandler,
};
use crate::channels::policy::PerIdentityChannelPolicy;
use crate::registry::spec::ChannelOpenSpec;
let policy: Arc<PerIdentityChannelPolicy> = Arc::new(PerIdentityChannelPolicy::new(256));
let policy_for_hook: Arc<dyn ChannelLifecyclePolicy> =
Arc::clone(&policy) as Arc<dyn ChannelLifecyclePolicy>;
let policy_for_assert = Arc::clone(&policy);
let open_handler: OpenHandler =
Arc::new(|_input, _channel_conn, _auth| tokio::spawn(async {}));
let establisher: OpenEstablisher = Arc::new(|_input, _auth| {
Box::pin(async {
Err(EstablishmentError::DialFailed {
message: "target refused the connection".to_string(),
})
})
});
let install_hook: crate::channels::adapter::InstallChannelZero =
Arc::new(move |manager, channel0_conn, auth| {
let open_handler = Arc::clone(&open_handler);
let establisher = Arc::clone(&establisher);
let policy = Arc::clone(&policy_for_hook);
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 core = ChannelCore::new(manager, policy);
let registry = crate::registry::registration::OperationRegistry::new();
let spec = OperationSpec::new(
"channels/tty/sub",
OperationType::Sub,
Visibility::External,
serde_json::json!({}),
serde_json::json!({
"type": "object",
"properties": { "channel_id": { "type": "integer" } }
}),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/tty"));
core.register_openable_with_establisher(
spec,
Some(establisher),
open_handler,
&registry,
auth.clone(),
None,
)
.expect("register_openable_with_establisher");
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let dp = Dispatcher::new(registry, provider);
dp.run_loop_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(client_conn)
.await
.expect("channel client init");
let err = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.open_channel(
"channels/tty/sub",
serde_json::json!({ "container": "abc" }),
"alk/tty",
),
)
.await
.expect("open_channel timed out");
let err = match err {
Err(e) => e,
Ok(_) => panic!("establisher failure must resolve Err through the client"),
};
// N-1 gate: the typed error carries the wire CallError verbatim.
let call_error = err.call_error().expect("CallFailed carries the CallError");
assert_eq!(call_error.code, "channel:open_failed");
assert_eq!(
err.establishment_reason(),
Some("dial_failed"),
"consumer branches on the typed reason code"
);
// The SSH property, consumer-visible: no channel_id was ever
// returned, and the accept side holds no channel for the failed
// open (channel 0 is the pre-negotiated call channel — always
// present, never the failed open's).
assert!(
client.manager().channel_ids().into_iter().all(|id| id == 0),
"no data channel exists for the failed open"
);
let anonymous = crate::core::auth::Identity {
id: "anonymous".to_string(),
scopes: vec![],
resources: Default::default(),
};
assert_eq!(
policy_for_assert.count_for(&anonymous),
0,
"ledger un-incremented on the accept side after establishment failure"
);
}
/// ADR-049 Unit 1 acceptance gate (companion to the E-01 gate) —
/// establisher success through a real channels connection: the
/// client's `open_channel` adopts and the pumps flow (the happy
/// path is unchanged by the establishment phase).
#[tokio::test]
async fn establisher_success_opens_and_pumps_data_end_to_end() {
use crate::channels::operations::{
ChannelCore, Establishment, OpenEstablisher, OpenHandler,
};
use crate::channels::policy::NoCap;
use crate::registry::spec::ChannelOpenSpec;
use tokio::io::AsyncReadExt;
let (data_tx, mut data_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(1);
let open_handler: OpenHandler = Arc::new(move |_input, channel_conn, _auth| {
let data_tx = data_tx.clone();
tokio::spawn(async move {
let mut bidi = channel_conn.accept_bi().await.expect("accept_bi");
let mut buf = [0u8; 4];
bidi.read_exact(&mut buf).await.expect("read");
data_tx.send(buf.to_vec()).await.expect("send to channel");
})
});
let establisher: OpenEstablisher =
Arc::new(|_input, _auth| Box::pin(async { Ok(Establishment::default()) }));
let establisher_for_hook = Arc::clone(&establisher);
let open_handler_for_hook = Arc::clone(&open_handler);
let install_hook: crate::channels::adapter::InstallChannelZero =
Arc::new(move |manager, channel0_conn, auth| {
let establisher = Arc::clone(&establisher_for_hook);
let open_handler = Arc::clone(&open_handler_for_hook);
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 core = ChannelCore::new(manager, Arc::new(NoCap));
let registry = crate::registry::registration::OperationRegistry::new();
let spec = OperationSpec::new(
"channels/tty/sub",
OperationType::Sub,
Visibility::External,
serde_json::json!({
"type": "object",
"properties": { "container": { "type": "string" } },
"required": ["container"]
}),
serde_json::json!({
"type": "object",
"properties": { "channel_id": { "type": "integer" } }
}),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/tty"));
core.register_openable_with_establisher(
spec,
Some(establisher),
open_handler,
&registry,
auth.clone(),
None,
)
.expect("register_openable_with_establisher");
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let dp = Dispatcher::new(registry, provider);
dp.run_loop_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(client_conn)
.await
.expect("channel client init");
let (channel_id, mut send, _recv) = client
.open_channel(
"channels/tty/sub",
serde_json::json!({ "container": "abc" }),
"alk/tty",
)
.await
.expect("open_channel with establisher success");
assert!(channel_id > 0, "channel_id should be non-zero");
send.write_all(b"ping").await.expect("write ping");
drop(send);
let data = tokio::time::timeout(std::time::Duration::from_secs(5), data_rx.recv())
.await
.expect("timed out waiting for handler data")
.expect("handler should receive data");
assert_eq!(&data, b"ping", "handler received ping post-establishment");
}
// --- review 004 Unit 3 acceptance gates (F-04 serving half) -----------
/// F-04 gate 1: hub→consumer call over an existing `ChannelClient`
+642 -15
View File
@@ -14,7 +14,9 @@
//! See `docs/architecture/channel-operations.md` for the spec.
use std::sync::Arc;
use std::time::{Duration, Instant};
use futures::future::BoxFuture;
use serde_json::{json, Value};
use crate::core::auth::{AuthContext, Identity};
@@ -295,7 +297,10 @@ fn make_resources_subscribe_handler(manager: ChannelManager) -> StreamingHandler
}
/// `ChannelCore` — the channel machinery the ALPN crate's open-op
/// wrapper uses (ADR-047 §3). Provides [`ChannelCore::register_openable`],
/// wrapper uses (ADR-047 §3, as amended by ADR-049 — the wrapper
/// gains an awaited, bounded establishment phase via
/// [`ChannelCore::register_openable_with_establisher`]). Provides
/// [`ChannelCore::register_openable`],
/// which wraps the ALPN's open handler with channel-id allocation,
/// `ChannelManager` integration, opener-ledger recording,
/// `ChannelLifecyclePolicy` consultation, and teardown hooks.
@@ -333,6 +338,85 @@ pub struct ChannelCore {
pub type OpenHandler =
Arc<dyn Fn(Value, Connection, AuthContext) -> tokio::task::JoinHandle<()> + Send + Sync>;
/// The establishment-phase result (ADR-049 §1). Reserved for a channel
/// plan — today the wrapper consults only success/failure, so `()`
/// carries no data.
#[derive(Debug, Clone, Default)]
pub struct Establishment {}
/// The establishment-failure reason code the wrapper puts in the open
/// reply's `details.reason` (ADR-049 §3). The vocabulary maps 1:1 onto
/// what an establisher can actually produce (per the SSH four;
/// review 006's survey) plus the wrapper's own deadline.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum EstablishmentError {
/// The backend/target could not be reached or refused the
/// connection.
#[error("dial failed: {message}")]
DialFailed { message: String },
/// The requested resource does not exist.
#[error("unknown resource: {message}")]
UnknownResource { message: String },
/// The backend is out of capacity (ports, fds, slots).
#[error("resource shortage: {message}")]
ResourceShortage { message: String },
/// Establisher-internal failure not covered above.
#[error("handler error: {message}")]
HandlerError { message: String },
}
impl EstablishmentError {
/// The wire-visible reason code (`details.reason` in
/// `channel:open_failed`).
pub fn reason(&self) -> &'static str {
match self {
EstablishmentError::DialFailed { .. } => "dial_failed",
EstablishmentError::UnknownResource { .. } => "unknown_resource",
EstablishmentError::ResourceShortage { .. } => "resource_shortage",
EstablishmentError::HandlerError { .. } => "handler_error",
}
}
/// The human-readable detail message.
pub fn message(&self) -> &str {
match self {
EstablishmentError::DialFailed { message }
| EstablishmentError::UnknownResource { message }
| EstablishmentError::ResourceShortage { message }
| EstablishmentError::HandlerError { message } => message,
}
}
}
/// The establisher hook (ADR-049 §1): the awaited establishment phase
/// of a channel open — validate params semantically, consult ownership,
/// prepare/dial the backend. **Awaited by the open-op wrapper,
/// bounded** (dispatch deadline or `ESTABLISHMENT_TIMEOUT`, whichever
/// is earlier) — before the reply is written, before the pump handler
/// is spawned. On failure the wrapper tears down the just-allocated
/// channel and replies `channel:open_failed` with `details.reason`.
///
/// The establisher takes the open op's `input` (params) and the peer's
/// `AuthContext`. It deliberately does **not** receive the channel's
/// `Connection`: the channel `BiStream` is yield-once
/// (`ChannelBidiStreamSource`) and belongs exclusively to the pump
/// handler (the `OpenHandler`); the establisher is pre-data-plane (its
/// dial targets the backend, not the channel). Amendment to the
/// ADR-049 §1 signature sketch.
pub type OpenEstablisher = Arc<
dyn Fn(Value, AuthContext) -> BoxFuture<'static, Result<Establishment, EstablishmentError>>
+ Send
+ Sync,
>;
/// The default bound on the establisher await (ADR-049 §2) when the
/// `OperationContext` carries no deadline and the registration set no
/// override.
pub const ESTABLISHMENT_TIMEOUT: Duration = Duration::from_secs(10);
/// The wire-visible error code for establishment failure (ADR-049 §3).
pub const CHANNEL_OPEN_FAILED: &str = "channel:open_failed";
/// The error code prefix for channel-open failures (ADR-047 §3). The
/// wrapper maps `ChannelError` / `ManagerError` to `CallError` with
/// codes prefixed by `channel:` so the initiator can branch on the
@@ -374,10 +458,17 @@ impl ChannelCore {
/// spawn the protocol on the channel's `BiStream`). This wrapper
/// does the channel machinery: ACL check (run by the registry's
/// `invoke`/`invoke_streaming`/`invoke_sink` before the wrapper) →
/// `check_open(identity)` → `manager.open_channel(alpn, opener)`
/// → spawn the ALPN handler on the channel's `Connection` →
/// `check_open(identity)` → `manager.open_channel(alpn, opener)` →
/// spawn the ALPN handler on the channel's `Connection` →
/// respond with `{ "channel_id": <id> }`.
///
/// **No establisher**: the open op cannot fail after allocation —
/// establishment work inside the handler is invisible to the open
/// reply (review 006 E-01's subject). Use
/// [`ChannelCore::register_openable_with_establisher`] when the
/// establishment phase can fail and the failure belongs in the
/// open reply (ADR-049).
///
/// The op is registered on the given `registry` (the connection
/// overlay registry, Layer 2 per ADR-019 — this is the
/// per-connection registration the §4 amendment blesses). The
@@ -402,6 +493,38 @@ impl ChannelCore {
open_handler: OpenHandler,
registry: &OperationRegistry,
auth: AuthContext,
) -> Result<(), String> {
self.register_openable_with_establisher(spec, None, open_handler, registry, auth, None)
}
/// Register a per-ALPN open op with an establishment phase
/// (ADR-049 §1/§5). The `establisher` is **awaited by the wrapper,
/// bounded** — after allocation, before the reply and before the
/// pump handler is spawned. On establisher failure (error or
/// deadline) the wrapper tears down the just-allocated channel and
/// replies `channel:open_failed` with
/// `details: { reason, message }`; on success the pump handler is
/// spawned exactly as [`ChannelCore::register_openable`] does.
///
/// `timeout` bounds the establisher when the dispatch carries no
/// deadline (`None` = [`ESTABLISHMENT_TIMEOUT`], 10s). When the
/// `OperationContext` carries a deadline, the effective bound is
/// the earlier of the two (ADR-049 §2). The bound applies only to
/// the establisher — the spawned pump's lifetime is governed by
/// the existing teardown machinery, unchanged.
///
/// Backward compatible by construction: `establisher: None` behaves
/// exactly like [`ChannelCore::register_openable`] (the
/// no-establisher shape), and existing `OpenHandler`s compile
/// unchanged.
pub fn register_openable_with_establisher(
&self,
spec: OperationSpec,
establisher: Option<OpenEstablisher>,
open_handler: OpenHandler,
registry: &OperationRegistry,
auth: AuthContext,
timeout: Option<Duration>,
) -> Result<(), String> {
let op_type = spec.op_type;
let channel_open = spec.channel_open.clone();
@@ -417,17 +540,22 @@ impl ChannelCore {
.alpn
.to_string();
let establisher = establisher.map(|establisher| EstablisherHook {
establisher,
timeout,
});
let manager = self.manager.clone();
let policy = Arc::clone(&self.policy);
let open_handler = Arc::clone(&open_handler);
let handler_kind = match op_type {
OperationType::Query | OperationType::Mutation => HandlerKind::Once(
make_open_handler_once(manager, policy, open_handler, auth, alpn),
make_open_handler_once(manager, policy, establisher, open_handler, auth, alpn),
),
OperationType::Sub => HandlerKind::Stream(make_open_handler_stream(
manager,
policy,
establisher,
open_handler,
auth,
alpn,
@@ -435,6 +563,7 @@ impl ChannelCore {
OperationType::Pub => HandlerKind::Sink(make_open_handler_sink(
manager,
policy,
establisher,
open_handler,
auth,
alpn,
@@ -467,22 +596,103 @@ fn opener_identity_from_context(ctx: &OperationContext) -> Identity {
})
}
/// The establisher hook + its per-registration bound override,
/// threaded from `ChannelCore::register_openable_with_establisher`
/// into the wrapper factories. `timeout: None` = `ESTABLISHMENT_TIMEOUT`.
#[derive(Clone)]
struct EstablisherHook {
establisher: OpenEstablisher,
timeout: Option<Duration>,
}
/// The effective bound on the establisher await (ADR-049 §2): the
/// earlier of the dispatch deadline (when the `OperationContext`
/// carries one) and the establishment timeout (the per-registration
/// override, else `ESTABLISHMENT_TIMEOUT`). A deadline already in the
/// past yields a zero bound — the timeout fires immediately.
fn establishment_bound(timeout_override: Option<Duration>, deadline: Option<Instant>) -> Duration {
let timeout = timeout_override.unwrap_or(ESTABLISHMENT_TIMEOUT);
deadline
.map(|d| d.saturating_duration_since(Instant::now()).min(timeout))
.unwrap_or(timeout)
}
/// Tear down a just-allocated channel after establishment failure
/// (ADR-049 §3): drop the demux sender (EOF to nothing — no handler
/// was ever spawned), take the opener-ledger entry, and un-reserve the
/// per-identity count (`policy.on_close` — the same un-increment path
/// the allocation-failure arms run; the ledger `take` is the atomic
/// gate, ADR-047 §7).
fn teardown_failed_channel(
manager: &ChannelManager,
policy: &Arc<dyn ChannelLifecyclePolicy>,
channel_id: u32,
opener: &Identity,
) {
if let Err(e) = manager.teardown_channel(channel_id) {
tracing::debug!(
channel_id,
error = %e,
"open wrapper: establisher-failure teardown found no channel to remove"
);
}
if manager.opener_ledger().take(channel_id).is_some() {
policy.on_close(opener);
}
}
/// The `channel:open_failed` reply for an establisher error
/// (ADR-049 §3): `details` carries the reason code + message.
fn establishment_error_to_call_error(err: &EstablishmentError) -> CallError {
CallError::new(
CHANNEL_OPEN_FAILED,
format!("channel establishment failed: {}", err.message()),
false,
)
.with_details(json!({ "reason": err.reason(), "message": err.message() }))
}
/// The `channel:open_failed` reply for a deadline expiry — same
/// `details` shape, reason `timeout`.
fn establishment_timeout_call_error() -> CallError {
CallError::new(
CHANNEL_OPEN_FAILED,
"channel establishment exceeded the deadline",
false,
)
.with_details(json!({
"reason": "timeout",
"message": "establishment exceeded the deadline",
}))
}
/// Run the open-op wrapper's shared post-ACL steps: `check_open` →
/// `open_channel` → build the channel `Connection` → spawn the ALPN
/// handler → return `{ channel_id }` on success, or a `CallError` on
/// any failure. The ACL check has already run (by the registry's
/// `invoke`/`invoke_streaming`/`invoke_sink` before the wrapper); this
/// function is the `check_open` → `allocate` → `ledger` → `spawn` →
/// `respond` tail of the ADR-047 §3 flow.
/// `open_channel` → **await the establisher bounded** (ADR-049 §1/§2 —
/// no-op when no establisher is registered) → build the channel
/// `Connection` → spawn the ALPN handler → return `{ channel_id }` on
/// success, or a `CallError` on any failure. The ACL check has already
/// run (by the registry's `invoke`/`invoke_streaming`/`invoke_sink`
/// before the wrapper); this function is the `check_open` →
/// `allocate` → `establish` → `ledger` → `spawn` → `respond` tail of
/// the ADR-047 §3 flow (as amended by ADR-049).
///
/// On establishment failure (error or deadline) the wrapper tears down
/// the just-allocated channel (demux sender, opener-ledger entry,
/// `policy.on_close` un-increment) and replies `channel:open_failed`
/// with `details: { reason, message }` — the SSH "channel never exists
/// opener-side" property: the consumer's open resolves `Err` and no
/// `channel_id` was ever returned.
///
/// `input` is the open op's input (params), passed through to the
/// ALPN's `OpenHandler` so it can validate params and prepare the
/// backend. `opener_id` is the `PeerId` recorded in the opener ledger
/// (the direct caller's identity id).
/// establisher (when registered) and the ALPN's `OpenHandler`.
/// `opener_id` is the `PeerId` recorded in the opener ledger (the
/// direct caller's identity id). `opener_identity` is the identity the
/// cap was reserved against (un-reserved on failure paths).
#[allow(clippy::too_many_arguments)]
async fn run_open_wrapper(
manager: &ChannelManager,
policy: &Arc<dyn ChannelLifecyclePolicy>,
establisher: Option<&EstablisherHook>,
open_handler: &OpenHandler,
auth: &AuthContext,
alpn: &str,
@@ -490,6 +700,7 @@ async fn run_open_wrapper(
opener_id: String,
opener_identity: Identity,
request_id: String,
deadline: Option<Instant>,
) -> ResponseEnvelope {
if let Err(channel_err) = policy.check_open(&opener_identity) {
return ResponseEnvelope::error(request_id, map_channel_error_to_call_error(&channel_err));
@@ -497,6 +708,39 @@ async fn run_open_wrapper(
let channel_id = match manager.open_channel(alpn, opener_id, None).await {
Ok((id, send, recv)) => {
// The establishment phase (ADR-049 §1): awaited bounded,
// before the reply and before the pump handler is spawned.
// No establisher registered = an always-OK establisher
// (the pre-ADR-049 shape, unchanged).
if let Some(hook) = establisher {
let bound = establishment_bound(hook.timeout, deadline);
let establishment =
tokio::time::timeout(bound, (hook.establisher)(input.clone(), auth.clone()))
.await;
match establishment {
Err(_elapsed) => {
teardown_failed_channel(manager, policy, id, &opener_identity);
return ResponseEnvelope::error(
request_id,
establishment_timeout_call_error(),
);
}
Ok(Err(e)) => {
tracing::debug!(
channel_id = id,
reason = e.reason(),
"open wrapper: establisher failed; tearing down channel"
);
teardown_failed_channel(manager, policy, id, &opener_identity);
return ResponseEnvelope::error(
request_id,
establishment_error_to_call_error(&e),
);
}
Ok(Ok(_establishment)) => {}
}
}
let remote_addr = manager.remote_addr();
let source = super::source::channel_source(recv, send, remote_addr);
let channel_conn = Connection::from_source(source, alpn.as_bytes().to_vec());
@@ -584,6 +828,7 @@ fn map_channel_error_to_call_error(err: &ChannelError) -> CallError {
fn make_open_handler_once(
manager: ChannelManager,
policy: Arc<dyn ChannelLifecyclePolicy>,
establisher: Option<EstablisherHook>,
open_handler: OpenHandler,
auth: AuthContext,
alpn: String,
@@ -591,6 +836,7 @@ fn make_open_handler_once(
Arc::new(move |input: Value, ctx: OperationContext| {
let manager = manager.clone();
let policy = Arc::clone(&policy);
let establisher = establisher.clone();
let open_handler = Arc::clone(&open_handler);
let auth = auth.clone();
let alpn = alpn.clone();
@@ -601,6 +847,7 @@ fn make_open_handler_once(
run_open_wrapper(
&manager,
&policy,
establisher.as_ref(),
&open_handler,
&auth,
&alpn,
@@ -608,6 +855,7 @@ fn make_open_handler_once(
opener_id,
opener_identity,
request_id,
ctx.deadline,
)
.await
})
@@ -625,6 +873,7 @@ fn make_open_handler_once(
fn make_open_handler_stream(
manager: ChannelManager,
policy: Arc<dyn ChannelLifecyclePolicy>,
establisher: Option<EstablisherHook>,
open_handler: OpenHandler,
auth: AuthContext,
alpn: String,
@@ -632,6 +881,7 @@ fn make_open_handler_stream(
Arc::new(move |input: Value, ctx: OperationContext| {
let manager = manager.clone();
let policy = Arc::clone(&policy);
let establisher = establisher.clone();
let open_handler = Arc::clone(&open_handler);
let auth = auth.clone();
let alpn = alpn.clone();
@@ -642,6 +892,7 @@ fn make_open_handler_stream(
run_open_wrapper(
&manager,
&policy,
establisher.as_ref(),
&open_handler,
&auth,
&alpn,
@@ -649,6 +900,7 @@ fn make_open_handler_stream(
opener_id,
opener_identity,
request_id,
ctx.deadline,
)
.await
}))
@@ -675,6 +927,7 @@ fn make_open_handler_stream(
fn make_open_handler_sink(
_manager: ChannelManager,
_policy: Arc<dyn ChannelLifecyclePolicy>,
_establisher: Option<EstablisherHook>,
_open_handler: OpenHandler,
_auth: AuthContext,
_alpn: String,
@@ -1039,6 +1292,7 @@ mod tests {
let handler = make_open_handler_once(
manager.clone(),
policy,
None,
open_handler,
auth,
"alk/tty".to_string(),
@@ -1069,6 +1323,7 @@ mod tests {
let handler = make_open_handler_stream(
manager.clone(),
policy,
None,
open_handler,
auth,
"alk/tty".to_string(),
@@ -1096,8 +1351,14 @@ mod tests {
let policy = super::super::policy::default_policy();
let open_handler: OpenHandler = Arc::new(|_input, _conn, _auth| tokio::spawn(async {}));
let auth = AuthContext::anonymous(b"alk/call");
let handler =
make_open_handler_sink(manager, policy, open_handler, auth, "alk/tty".to_string());
let handler = make_open_handler_sink(
manager,
policy,
None,
open_handler,
auth,
"alk/tty".to_string(),
);
let publish_stream: crate::registry::registration::PublishStream =
Box::pin(futures::stream::empty());
let env = handler(json!({}), test_context("open-sink-1"), publish_stream).await;
@@ -1265,6 +1526,7 @@ mod tests {
let env = run_open_wrapper(
&manager,
&policy,
None,
&open_handler,
&auth,
"alk/tty",
@@ -1272,6 +1534,7 @@ mod tests {
opener_id.id.clone(),
opener_id,
"req-cap-deny".to_string(),
None,
)
.await;
match env.result {
@@ -1279,4 +1542,368 @@ mod tests {
Ok(_) => panic!("cap 0 should deny open"),
}
}
// --- ADR-049 Unit 1: the establishment phase -------------------------
fn failing_establisher() -> OpenEstablisher {
Arc::new(|_input, _auth| {
Box::pin(async {
Err(EstablishmentError::DialFailed {
message: "connection refused".to_string(),
})
})
})
}
fn ok_establisher() -> OpenEstablisher {
Arc::new(|_input, _auth| Box::pin(async { Ok(Establishment::default()) }))
}
fn hook(establisher: OpenEstablisher, timeout: Option<Duration>) -> Option<EstablisherHook> {
Some(EstablisherHook {
establisher,
timeout,
})
}
fn identity(id: &str) -> Identity {
Identity {
id: id.to_string(),
scopes: vec![],
resources: HashMap::new(),
}
}
#[tokio::test]
async fn run_open_wrapper_establisher_failure_tears_down_and_replies_open_failed() {
let manager = make_manager().await;
let concrete_policy = Arc::new(super::super::policy::PerIdentityChannelPolicy::new(8));
let policy: Arc<dyn ChannelLifecyclePolicy> =
Arc::clone(&concrete_policy) as Arc<dyn ChannelLifecyclePolicy>;
let spawned = Arc::new(AtomicBool::new(false));
let spawned_clone = Arc::clone(&spawned);
let open_handler: OpenHandler = Arc::new(move |_input, _conn, _auth| {
spawned_clone.store(true, Ordering::SeqCst);
tokio::spawn(async {})
});
let auth = AuthContext::anonymous(b"alk/call");
let env = run_open_wrapper(
&manager,
&policy,
hook(failing_establisher(), None).as_ref(),
&open_handler,
&auth,
"alk/tty",
json!({}),
"alice".to_string(),
identity("alice"),
"req-est-fail".to_string(),
None,
)
.await;
match env.result {
Err(e) => {
assert_eq!(e.code, "channel:open_failed");
assert!(!e.retryable);
let details = e.details.expect("details carry the reason");
assert_eq!(details["reason"], "dial_failed");
assert_eq!(details["message"], "connection refused");
}
Ok(_) => panic!("establisher failure must fail the open"),
}
// The SSH "channel never exists opener-side" property: the
// channel was torn down and no `channel_id` was ever returned.
assert!(
manager.channel_ids().is_empty(),
"no channel survives a failed establishment"
);
assert_eq!(manager.open_count(), 0);
// The per-identity cap reservation was un-incremented.
assert_eq!(
concrete_policy.count_for(&identity("alice")),
0,
"ledger take + policy.on_close restored the cap count"
);
// The pump handler was never spawned.
assert!(
!spawned.load(Ordering::SeqCst),
"pump handler must not spawn on establishment failure"
);
}
#[tokio::test]
async fn run_open_wrapper_establisher_timeout_replies_timeout_reason() {
let manager = make_manager().await;
let concrete_policy = Arc::new(super::super::policy::PerIdentityChannelPolicy::new(8));
let policy: Arc<dyn ChannelLifecyclePolicy> =
Arc::clone(&concrete_policy) as Arc<dyn ChannelLifecyclePolicy>;
let open_handler: OpenHandler = Arc::new(|_input, _conn, _auth| tokio::spawn(async {}));
let auth = AuthContext::anonymous(b"alk/call");
let hanging: OpenEstablisher = Arc::new(|_input, _auth| {
Box::pin(async {
tokio::time::sleep(Duration::from_secs(5)).await;
Ok(Establishment::default())
})
});
let env = run_open_wrapper(
&manager,
&policy,
hook(hanging, Some(Duration::from_millis(50))).as_ref(),
&open_handler,
&auth,
"alk/tty",
json!({}),
"alice".to_string(),
identity("alice"),
"req-est-timeout".to_string(),
None,
)
.await;
match env.result {
Err(e) => {
assert_eq!(e.code, "channel:open_failed");
let details = e.details.expect("details carry the reason");
assert_eq!(details["reason"], "timeout");
}
Ok(_) => panic!("a hung establisher must fail the open within the bound"),
}
assert!(manager.channel_ids().is_empty(), "channel torn down");
assert_eq!(
concrete_policy.count_for(&identity("alice")),
0,
"ledger decremented on timeout"
);
}
#[tokio::test]
async fn run_open_wrapper_dispatch_deadline_bounds_establisher() {
let manager = make_manager().await;
let policy = super::super::policy::default_policy();
let open_handler: OpenHandler = Arc::new(|_input, _conn, _auth| tokio::spawn(async {}));
let auth = AuthContext::anonymous(b"alk/call");
let hanging: OpenEstablisher = Arc::new(|_input, _auth| {
Box::pin(async {
tokio::time::sleep(Duration::from_secs(5)).await;
Ok(Establishment::default())
})
});
// No per-registration override; the dispatch deadline (50ms) is
// the bound — the earlier of the two per ADR-049 §2.
let env = run_open_wrapper(
&manager,
&policy,
hook(hanging, None).as_ref(),
&open_handler,
&auth,
"alk/tty",
json!({}),
"alice".to_string(),
identity("alice"),
"req-est-deadline".to_string(),
Some(Instant::now() + Duration::from_millis(50)),
)
.await;
match env.result {
Err(e) => {
assert_eq!(e.code, "channel:open_failed");
assert_eq!(e.details.as_ref().expect("details")["reason"], "timeout");
}
Ok(_) => panic!("deadline must bound the establisher"),
}
assert!(manager.channel_ids().is_empty());
}
#[tokio::test]
async fn run_open_wrapper_establisher_success_spawns_pumps_and_replies_channel_id() {
let manager = make_manager().await;
let policy = super::super::policy::default_policy();
let spawned = Arc::new(AtomicBool::new(false));
let spawned_clone = Arc::clone(&spawned);
let open_handler: OpenHandler = Arc::new(move |_input, _conn, _auth| {
spawned_clone.store(true, Ordering::SeqCst);
tokio::spawn(async {})
});
let auth = AuthContext::anonymous(b"alk/call");
let env = run_open_wrapper(
&manager,
&policy,
hook(ok_establisher(), None).as_ref(),
&open_handler,
&auth,
"alk/tty",
json!({}),
"alice".to_string(),
identity("alice"),
"req-est-ok".to_string(),
None,
)
.await;
match env.result {
Ok(v) => {
let channel_id = v["channel_id"].as_u64().expect("channel_id");
assert!(channel_id > 0);
assert!(manager.has_channel(channel_id as u32));
}
Err(e) => panic!("successful establishment should open, got: {e:?}"),
}
assert!(spawned.load(Ordering::SeqCst));
}
#[test]
fn establishment_error_reason_mapping_covers_the_vocabulary() {
let cases: Vec<(EstablishmentError, &str)> = vec![
(
EstablishmentError::DialFailed {
message: "refused".into(),
},
"dial_failed",
),
(
EstablishmentError::UnknownResource {
message: "gone".into(),
},
"unknown_resource",
),
(
EstablishmentError::ResourceShortage {
message: "no fds".into(),
},
"resource_shortage",
),
(
EstablishmentError::HandlerError {
message: "boom".into(),
},
"handler_error",
),
];
for (err, reason) in cases {
let call_err = establishment_error_to_call_error(&err);
assert_eq!(call_err.code, "channel:open_failed");
let details = call_err.details.expect("details");
assert_eq!(details["reason"], reason);
assert_eq!(details["message"], err.message());
}
}
#[test]
fn establishment_timeout_error_carries_timeout_reason() {
let err = establishment_timeout_call_error();
assert_eq!(err.code, "channel:open_failed");
assert_eq!(err.details.as_ref().expect("details")["reason"], "timeout");
}
#[test]
fn establishment_bound_is_the_earlier_of_deadline_and_timeout() {
// No deadline → the override (or the 10s default).
assert_eq!(establishment_bound(None, None), ESTABLISHMENT_TIMEOUT);
assert_eq!(
establishment_bound(Some(Duration::from_secs(3)), None),
Duration::from_secs(3)
);
// Deadline earlier than the timeout → the deadline.
let soon = Instant::now() + Duration::from_millis(100);
let bound = establishment_bound(None, Some(soon));
assert!(bound <= Duration::from_millis(100));
// Deadline later than the timeout → the timeout wins.
let late = Instant::now() + Duration::from_secs(60);
assert_eq!(
establishment_bound(Some(Duration::from_secs(3)), Some(late)),
Duration::from_secs(3)
);
// Deadline already past → zero bound (timeout fires immediately).
let past = Instant::now() - Duration::from_secs(1);
assert_eq!(establishment_bound(None, Some(past)), Duration::ZERO);
}
#[tokio::test]
async fn register_openable_with_establisher_fails_via_invoke_streaming() {
let manager = make_manager().await;
let concrete_policy = Arc::new(super::super::policy::PerIdentityChannelPolicy::new(8));
let policy: Arc<dyn ChannelLifecyclePolicy> =
Arc::clone(&concrete_policy) as Arc<dyn ChannelLifecyclePolicy>;
let core = ChannelCore::new(manager.clone(), policy);
let spec = OperationSpec::new(
"channels/tty/sub",
OperationType::Sub,
Visibility::External,
json!({}),
json!({ "type": "object", "properties": { "channel_id": { "type": "integer" } } }),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/tty"));
let open_handler: OpenHandler = Arc::new(|_input, _conn, _auth| tokio::spawn(async {}));
let registry = OperationRegistry::new();
core.register_openable_with_establisher(
spec,
Some(failing_establisher()),
open_handler,
&registry,
AuthContext::anonymous(b"alk/call"),
None,
)
.expect("register");
let mut stream =
registry.invoke_streaming("channels/tty/sub", json!({}), test_context("reg-est-fail"));
let env = stream.next().await.expect("one envelope");
match env.result {
Err(e) => {
assert_eq!(e.code, "channel:open_failed");
assert_eq!(
e.details.as_ref().expect("details")["reason"],
"dial_failed"
);
}
Ok(_) => panic!("establisher failure must surface as a call error"),
}
assert!(manager.channel_ids().is_empty());
assert_eq!(
concrete_policy.count_for(&identity("alice")),
0,
"no-establisher compat: registry-level failure un-reserves the ledger"
);
}
#[tokio::test]
async fn register_openable_no_establisher_behaves_as_today() {
let manager = make_manager().await;
let policy = super::super::policy::default_policy();
let core = ChannelCore::new(manager.clone(), policy);
let spec = OperationSpec::new(
"channels/tty/sub",
OperationType::Query,
Visibility::External,
json!({}),
json!({ "type": "object", "properties": { "channel_id": { "type": "integer" } } }),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/tty"));
let spawned = Arc::new(AtomicBool::new(false));
let spawned_clone = Arc::clone(&spawned);
let open_handler: OpenHandler = Arc::new(move |_input, _conn, _auth| {
spawned_clone.store(true, Ordering::SeqCst);
tokio::spawn(async {})
});
let registry = OperationRegistry::new();
core.register_openable(
spec,
open_handler,
&registry,
AuthContext::anonymous(b"alk/call"),
)
.expect("register");
let env = registry
.invoke("channels/tty/sub", json!({}), test_context("reg-compat"))
.await;
match env.result {
Ok(v) => assert!(v["channel_id"].as_u64().is_some()),
Err(e) => panic!("no-establisher open should succeed, got: {e:?}"),
}
assert!(spawned.load(Ordering::SeqCst));
assert_eq!(manager.open_count(), 1, "channel stays open (compat)");
}
}