test+docs: prepublish hardening from review (failure paths, doc hygiene)
Coverage: - CF-002: demux skipped-bytes budget teardown test (268 MiB skip in-memory; a budgetless demux wedges in the 17th skip, the real one tears down) and the skip-hits-EOF arm (truncated oversized payload ends the loop). - CF-001: retryable CONNECTION_CLOSED pinned for subscribe write failures in both stream modes and single-stream publish request-frame failures; non-retryable INTERNAL pinned for mid-publish failures in both modes (deterministic FailOnFlushN write half). - Gateway: invoke_sink with with_deadline(None) completes a slow sink. Docs: - Fix broken intra-doc link on lib.rs's feature-gated gateway mention (rustdoc warned on default-feature builds). - Re-point 40 src/ references from the old alknet mono-repo ADR numbering (049/050/052/065/070/074/092) to this crate's numbering (021/011/034/007/008/009/005); drop into_sub_streams references removed by ADR-035. Verification: 565 default / 582 all-features (8 new), clippy -D warnings on default/gateway/all-features/wasm32, fmt clean, rustdoc warning-free on default and all-features, publish dry-run clean. Consumer-facing API continuity 0.1.1 -> 0.2.0 verified by compiling an API-surface probe against both versions.
This commit is contained in:
1 parent
d5fd548b8d
commit
ae372c0c7e
12 files changed
+418
-44
No files matched your search
@@ -552,4 +552,142 @@ mod tests {
|
|||||||
assert_eq!(buf, [i; 4], "channel A chunk {i} intact — no data loss");
|
assert_eq!(buf, [i; 4], "channel A chunk {i} intact — no data loss");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CF-002 #1 — the skipped-bytes budget tears down a dribbling peer.
|
||||||
|
/// Sixteen back-to-back `TooLarge` headers each declare 16 MiB + 1 and
|
||||||
|
/// their payloads are genuinely on the wire (the demux must consume
|
||||||
|
/// every byte to reach the next header, so the test cannot avoid
|
||||||
|
/// moving ~268 MiB through the duplex). The 16th skip pushes the
|
||||||
|
/// cumulative total past the 256 MiB budget: a working demux tears
|
||||||
|
/// down and *stops reading there*, while a peer that keeps dribbling
|
||||||
|
/// (a 17th header parked mid-payload) must not wedge it. Without the
|
||||||
|
/// budget the demux blocks skipping the 17th payload forever and the
|
||||||
|
/// timeout fires.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn demux_tears_down_after_cumulative_skipped_bytes_budget_exceeded() {
|
||||||
|
let oversized_len = super::super::wire::MAX_CHUNK_LEN as usize + 1;
|
||||||
|
let burst_count = 16;
|
||||||
|
let (client, server) = tokio::io::duplex(1024 * 1024);
|
||||||
|
let (server_read, server_write) = tokio::io::split(server);
|
||||||
|
let (mux_handle, mux_runner) = MuxRunner::new(Box::new(server_write));
|
||||||
|
let _mux_task = tokio::spawn(async move {
|
||||||
|
let _ = mux_runner.run().await;
|
||||||
|
});
|
||||||
|
let manager = ChannelManager::with_defaults(mux_handle, None);
|
||||||
|
|
||||||
|
let (_id, _send, _recv) = manager
|
||||||
|
.open_channel("alk/tty", "alice", None)
|
||||||
|
.await
|
||||||
|
.expect("open");
|
||||||
|
|
||||||
|
let demux_manager = manager.clone();
|
||||||
|
let demux_task = tokio::spawn(async move {
|
||||||
|
ChannelsAdapter::run_demux_loop_for_client(&demux_manager, Box::new(server_read), None)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Writer task: 16 full oversized payloads (15 × ~16 MiB ≈ 240 MiB
|
||||||
|
// skip cleanly under the budget; the 16th crosses it), then a 17th
|
||||||
|
// header + partial payload — and park instead of finishing (the
|
||||||
|
// peer keeps dribbling, never EOFs).
|
||||||
|
let writer_task = tokio::spawn(async move {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let mut client_write = client;
|
||||||
|
let mut header = [0u8; 8];
|
||||||
|
let chunk = vec![0u8; 64 * 1024];
|
||||||
|
let full = oversized_len / chunk.len();
|
||||||
|
let tail = oversized_len % chunk.len();
|
||||||
|
for _ in 0..burst_count {
|
||||||
|
super::super::wire::write_header(1, oversized_len as u32, &mut header)
|
||||||
|
.expect("write header");
|
||||||
|
client_write.write_all(&header).await.expect("write header");
|
||||||
|
for _ in 0..full {
|
||||||
|
client_write.write_all(&chunk).await.expect("write payload");
|
||||||
|
}
|
||||||
|
if tail > 0 {
|
||||||
|
client_write
|
||||||
|
.write_all(&chunk[..tail])
|
||||||
|
.await
|
||||||
|
.expect("write payload tail");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The budget is already blown by the 16th skip; this header is
|
||||||
|
// the dribble that a budgetless demux would park on forever.
|
||||||
|
super::super::wire::write_header(1, oversized_len as u32, &mut header)
|
||||||
|
.expect("write header");
|
||||||
|
client_write.write_all(&header).await.expect("write header");
|
||||||
|
client_write
|
||||||
|
.write_all(&chunk[..8192])
|
||||||
|
.await
|
||||||
|
.expect("write partial payload");
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let demux_done =
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(120), demux_task).await;
|
||||||
|
assert!(
|
||||||
|
demux_done.is_ok(),
|
||||||
|
"demux must tear down on the skipped-bytes budget, not block in the skip loop"
|
||||||
|
);
|
||||||
|
writer_task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CF-002 #2 — a skip that hits EOF mid-payload (peer declared more
|
||||||
|
/// bytes than it sent) ends the demux loop; nothing routes to the
|
||||||
|
/// channel and the loop does not spin back to header parsing on a
|
||||||
|
/// truncated stream.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn demux_ends_when_oversized_payload_skip_hits_eof() {
|
||||||
|
let (client, server) = tokio::io::duplex(1024 * 1024);
|
||||||
|
let (server_read, server_write) = tokio::io::split(server);
|
||||||
|
let (mux_handle, mux_runner) = MuxRunner::new(Box::new(server_write));
|
||||||
|
let _mux_task = tokio::spawn(async move {
|
||||||
|
let _ = mux_runner.run().await;
|
||||||
|
});
|
||||||
|
let manager = ChannelManager::with_defaults(mux_handle, None);
|
||||||
|
|
||||||
|
let (_id, _send, mut recv) = manager
|
||||||
|
.open_channel("alk/tty", "alice", None)
|
||||||
|
.await
|
||||||
|
.expect("open");
|
||||||
|
|
||||||
|
let demux_manager = manager.clone();
|
||||||
|
let demux_task = tokio::spawn(async move {
|
||||||
|
ChannelsAdapter::run_demux_loop_for_client(&demux_manager, Box::new(server_read), None)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let oversized_len = super::super::wire::MAX_CHUNK_LEN as usize + 1;
|
||||||
|
let mut header = [0u8; 8];
|
||||||
|
super::super::wire::write_header(1, oversized_len as u32, &mut header).expect("header");
|
||||||
|
let mut client_write = client;
|
||||||
|
client_write.write_all(&header).await.expect("write header");
|
||||||
|
client_write
|
||||||
|
.write_all(&[0u8; 4096])
|
||||||
|
.await
|
||||||
|
.expect("write partial payload");
|
||||||
|
drop(client_write);
|
||||||
|
|
||||||
|
let demux_done = tokio::time::timeout(std::time::Duration::from_secs(10), demux_task).await;
|
||||||
|
assert!(
|
||||||
|
demux_done.is_ok(),
|
||||||
|
"demux must end when the skip hits EOF instead of looping"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The oversized chunk was never routed — nothing to assert on the
|
||||||
|
// channel beyond the loop ending; reading would surface EOF since
|
||||||
|
// the demux dropped its sender without delivering a payload.
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
let mut buf = [0u8; 4];
|
||||||
|
let read = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(500),
|
||||||
|
recv.read_exact(&mut buf),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
read.is_err() || read.unwrap().is_err(),
|
||||||
|
"no payload must arrive after the truncated skip"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
//!
|
//!
|
||||||
//! The handler for a channel receives a `Connection` constructed via
|
//! The handler for a channel receives a `Connection` constructed via
|
||||||
//! `Connection::from_source(ChannelBidiStreamSource, alpn)`. The
|
//! `Connection::from_source(ChannelBidiStreamSource, alpn)`. The
|
||||||
//! handler calls `accept_bi()` once (yield-once per channel, ADR-065)
|
//! handler calls `accept_bi()` once (yield-once per channel, ADR-007)
|
||||||
//! and gets a `BiStream` — identical to how it works on a top-level
|
//! and gets a `BiStream` — identical to how it works on a top-level
|
||||||
//! QUIC connection. The `BiStream` is the reassembled read half joined
|
//! QUIC connection. The `BiStream` is the reassembled read half joined
|
||||||
//! to the mux write half via `BiStream::from_joined`.
|
//! to the mux write half via `BiStream::from_joined`.
|
||||||
@@ -25,8 +25,8 @@ use crate::core::types::{BiStream, BidiStreamSource, StreamError};
|
|||||||
///
|
///
|
||||||
/// The `BiStream` is constructed once, in [`ChannelBidiStreamSource::new`],
|
/// The `BiStream` is constructed once, in [`ChannelBidiStreamSource::new`],
|
||||||
/// and held in an `Option` — `accept_bi` takes it. This preserves the
|
/// and held in an `Option` — `accept_bi` takes it. This preserves the
|
||||||
/// yield-once contract (ADR-065) and the "split never crosses a crate
|
/// yield-once contract (ADR-007) and the "split never crosses a crate
|
||||||
/// boundary as part of a constructor" rule (ADR-092) — the join happens
|
/// boundary as part of a constructor" rule (ADR-009) — the join happens
|
||||||
/// here, in the `BidiStreamSource` impl, not per-handler.
|
/// here, in the `BidiStreamSource` impl, not per-handler.
|
||||||
pub struct ChannelBidiStreamSource {
|
pub struct ChannelBidiStreamSource {
|
||||||
stream: Mutex<Option<BiStream>>,
|
stream: Mutex<Option<BiStream>>,
|
||||||
@@ -65,9 +65,9 @@ impl BidiStreamSource for ChannelBidiStreamSource {
|
|||||||
|
|
||||||
/// `code`/`reason` are ignored: a single channel has no
|
/// `code`/`reason` are ignored: a single channel has no
|
||||||
/// QUIC-shaped application-level close codes. The drop is the
|
/// QUIC-shaped application-level close codes. The drop is the
|
||||||
/// close (ADR-065 §"Negative"). The `_` prefix is intentional —
|
/// close (ADR-007 §"Negative"). The `_` prefix is intentional —
|
||||||
/// the signature matches the public `Connection::close` API
|
/// the signature matches the public `Connection::close` API
|
||||||
/// (ADR-070 §"REQ-CORE-02").
|
/// (ADR-008 §"REQ-CORE-02").
|
||||||
fn close(&self, _code: u32, _reason: &str) {
|
fn close(&self, _code: u32, _reason: &str) {
|
||||||
let _ = self.stream.lock().take();
|
let _ = self.stream.lock().take();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use thiserror::Error;
|
|||||||
pub const CHUNK_HEADER_LEN: usize = 8;
|
pub const CHUNK_HEADER_LEN: usize = 8;
|
||||||
|
|
||||||
/// The maximum chunk payload length (16 MiB, matching TTY's cap —
|
/// The maximum chunk payload length (16 MiB, matching TTY's cap —
|
||||||
/// ADR-052 §5). A chunk with `length > MAX_CHUNK_LEN` returns
|
/// ADR-034). A chunk with `length > MAX_CHUNK_LEN` returns
|
||||||
/// [`ChunkError::TooLarge`] and does not corrupt the stream — the demux
|
/// [`ChunkError::TooLarge`] and does not corrupt the stream — the demux
|
||||||
/// drops the chunk and continues. The header is always exactly 8 bytes,
|
/// drops the chunk and continues. The header is always exactly 8 bytes,
|
||||||
/// so the demux can always resync by reading the next 8-byte header.
|
/// so the demux can always resync by reading the next 8-byte header.
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ fn make_forwarding_handler(connection: Arc<CallConnection>, remote_name: String)
|
|||||||
/// leaf: on invocation, calls `CallConnection::subscribe_with_payload()` and
|
/// leaf: on invocation, calls `CallConnection::subscribe_with_payload()` and
|
||||||
/// forwards the remote stream end-to-end. Each `call.responded` from the
|
/// forwards the remote stream end-to-end. Each `call.responded` from the
|
||||||
/// remote becomes a stream item, `call.completed` ends the stream, and
|
/// remote becomes a stream item, `call.completed` ends the stream, and
|
||||||
/// `call.aborted` drops it (ADR-049 §8). No truncation, no first-value
|
/// `call.aborted` drops it (ADR-021 §8). No truncation, no first-value
|
||||||
/// fallback.
|
/// fallback.
|
||||||
///
|
///
|
||||||
/// `forwarded_for` is populated from `context.identity` (ADR-032 §3), exactly
|
/// `forwarded_for` is populated from `context.identity` (ADR-032 §3), exactly
|
||||||
|
|||||||
+28
-26
@@ -227,14 +227,14 @@ pub trait ProtocolHandler: Send + Sync + 'static {
|
|||||||
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>;
|
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- BiStream: the handler leaf (ADR-092) ---------------------------------
|
// --- BiStream: the handler leaf (ADR-009) ---------------------------------
|
||||||
|
|
||||||
/// Internal helper trait — the union of `AsyncRead + AsyncWrite + Send`.
|
/// Internal helper trait — the union of `AsyncRead + AsyncWrite + Send`.
|
||||||
/// Not public; exists only to give `BiStream` a single boxed field.
|
/// Not public; exists only to give `BiStream` a single boxed field.
|
||||||
trait AsyncReadWrite: AsyncRead + AsyncWrite + Send {}
|
trait AsyncReadWrite: AsyncRead + AsyncWrite + Send {}
|
||||||
impl<T: AsyncRead + AsyncWrite + Send> AsyncReadWrite for T {}
|
impl<T: AsyncRead + AsyncWrite + Send> AsyncReadWrite for T {}
|
||||||
|
|
||||||
/// The handler leaf — a bidirectional byte stream (ADR-092).
|
/// The handler leaf — a bidirectional byte stream (ADR-005, ADR-009).
|
||||||
///
|
///
|
||||||
/// `accept_bi`/`open_bi` return a `BiStream`, not a split
|
/// `accept_bi`/`open_bi` return a `BiStream`, not a split
|
||||||
/// `(SendStream, RecvStream)` pair. Handlers that want the split halves call
|
/// `(SendStream, RecvStream)` pair. Handlers that want the split halves call
|
||||||
@@ -316,7 +316,7 @@ impl AsyncWrite for BiStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SendStream / RecvStream: thin newtypes (ADR-092) ---------------------
|
// --- SendStream / RecvStream: thin newtypes (ADR-009) ---------------------
|
||||||
|
|
||||||
pub struct SendStream {
|
pub struct SendStream {
|
||||||
inner: Box<dyn AsyncWrite + Send + Unpin>,
|
inner: Box<dyn AsyncWrite + Send + Unpin>,
|
||||||
@@ -327,10 +327,11 @@ pub struct RecvStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SendStream {
|
impl SendStream {
|
||||||
/// Box a write half into the thin `SendStream` newtype. Used by
|
/// Box a write half into the thin `SendStream` newtype. Used by tests
|
||||||
/// `into_sub_streams()` (ADR-074) and the channels reassembly path.
|
/// and any consumer that holds pre-split halves (the channels layer
|
||||||
/// Not a constructor that feeds `Connection` — the split never crosses
|
/// has its own `MpscSendStream`; `into_sub_streams()` was removed by
|
||||||
/// a crate boundary as part of a constructor (ADR-092).
|
/// ADR-035). Not a constructor that feeds `Connection` — the split
|
||||||
|
/// never crosses a crate boundary as part of a constructor (ADR-009).
|
||||||
pub fn from_stream(stream: impl AsyncWrite + Send + Unpin + 'static) -> Self {
|
pub fn from_stream(stream: impl AsyncWrite + Send + Unpin + 'static) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Box::new(stream),
|
inner: Box::new(stream),
|
||||||
@@ -339,10 +340,11 @@ impl SendStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RecvStream {
|
impl RecvStream {
|
||||||
/// Box a read half into the thin `RecvStream` newtype. Used by
|
/// Box a read half into the thin `RecvStream` newtype. Used by tests
|
||||||
/// `into_sub_streams()` (ADR-074) and the channels reassembly path.
|
/// and any consumer that holds pre-split halves (the channels layer
|
||||||
/// Not a constructor that feeds `Connection` — the split never crosses
|
/// has its own `MpscRecvStream`; `into_sub_streams()` was removed by
|
||||||
/// a crate boundary as part of a constructor (ADR-092).
|
/// ADR-035). Not a constructor that feeds `Connection` — the split
|
||||||
|
/// never crosses a crate boundary as part of a constructor (ADR-009).
|
||||||
pub fn from_stream(stream: impl AsyncRead + Send + Unpin + 'static) -> Self {
|
pub fn from_stream(stream: impl AsyncRead + Send + Unpin + 'static) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Box::new(stream),
|
inner: Box::new(stream),
|
||||||
@@ -386,15 +388,15 @@ impl AsyncRead for RecvStream {
|
|||||||
|
|
||||||
/// Yield bidirectional streams to a `Connection`. Downstream crates implement
|
/// Yield bidirectional streams to a `Connection`. Downstream crates implement
|
||||||
/// this trait to add connection shapes (channels, a future transport, a test
|
/// this trait to add connection shapes (channels, a future transport, a test
|
||||||
/// double beyond the single-stream case) without editing core. See ADR-070
|
/// double beyond the single-stream case) without editing core. See ADR-008
|
||||||
/// for the full rationale and ADR-065 for the yield-once contract the
|
/// for the full rationale and ADR-007 for the yield-once contract the
|
||||||
/// `StreamBidiStreamSource` impl preserves. The return type is `BiStream`
|
/// `StreamBidiStreamSource` impl preserves. The return type is `BiStream`
|
||||||
/// (ADR-092) — the join happens once, in the impl, not per-handler.
|
/// (ADR-009) — the join happens once, in the impl, not per-handler.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait BidiStreamSource: Send + Sync + 'static {
|
pub trait BidiStreamSource: Send + Sync + 'static {
|
||||||
/// Yield the next bidirectional stream this connection provides.
|
/// Yield the next bidirectional stream this connection provides.
|
||||||
///
|
///
|
||||||
/// Transport semantics (carried from ADR-065):
|
/// Transport semantics (carried from ADR-007):
|
||||||
/// - QUIC (quinn/iroh): returns a new bidi stream on each call,
|
/// - QUIC (quinn/iroh): returns a new bidi stream on each call,
|
||||||
/// `ConnectionClosed` when the underlying connection closes.
|
/// `ConnectionClosed` when the underlying connection closes.
|
||||||
/// - Single-stream (TCP+TLS, SSH channel, WebTransport stream, wasm):
|
/// - Single-stream (TCP+TLS, SSH channel, WebTransport stream, wasm):
|
||||||
@@ -407,7 +409,7 @@ pub trait BidiStreamSource: Send + Sync + 'static {
|
|||||||
/// Open a bidirectional stream to the peer.
|
/// Open a bidirectional stream to the peer.
|
||||||
///
|
///
|
||||||
/// Single-stream sources return `StreamClosed` (a single stream cannot
|
/// Single-stream sources return `StreamClosed` (a single stream cannot
|
||||||
/// open new application streams — ADR-065). QUIC and channels sources
|
/// open new application streams — ADR-007). QUIC and channels sources
|
||||||
/// open new streams.
|
/// open new streams.
|
||||||
async fn open_bi(&self) -> Result<BiStream, StreamError>;
|
async fn open_bi(&self) -> Result<BiStream, StreamError>;
|
||||||
|
|
||||||
@@ -416,13 +418,13 @@ pub trait BidiStreamSource: Send + Sync + 'static {
|
|||||||
|
|
||||||
/// Close the connection. The `code`/`reason` args are QUIC application-
|
/// Close the connection. The `code`/`reason` args are QUIC application-
|
||||||
/// level close codes; non-QUIC sources ignore them (the drop is the
|
/// level close codes; non-QUIC sources ignore them (the drop is the
|
||||||
/// close — ADR-065 §"Negative"). See ADR-070 §"REQ-CORE-02" for the
|
/// close — ADR-007 §"Negative"). See ADR-008 §"REQ-CORE-02" for the
|
||||||
/// rationale for keeping the QUIC-shaped signature on the trait.
|
/// rationale for keeping the QUIC-shaped signature on the trait.
|
||||||
fn close(&self, code: u32, reason: &str);
|
fn close(&self, code: u32, reason: &str);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Single-stream `BidiStreamSource` (TCP+TLS, SSH channel, WebTransport
|
/// Single-stream `BidiStreamSource` (TCP+TLS, SSH channel, WebTransport
|
||||||
/// stream, wasm stream — ADR-065). Crate-private; constructed via
|
/// stream, wasm stream — ADR-007). Crate-private; constructed via
|
||||||
/// `Connection::from_bidi`. `accept_bi` yields the underlying `BiStream`
|
/// `Connection::from_bidi`. `accept_bi` yields the underlying `BiStream`
|
||||||
/// once, then `ConnectionClosed`; `open_bi` returns `StreamClosed`.
|
/// once, then `ConnectionClosed`; `open_bi` returns `StreamClosed`.
|
||||||
struct StreamBidiStreamSource {
|
struct StreamBidiStreamSource {
|
||||||
@@ -449,9 +451,9 @@ impl BidiStreamSource for StreamBidiStreamSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `code`/`reason` are ignored: a single stream has no QUIC-shaped
|
/// `code`/`reason` are ignored: a single stream has no QUIC-shaped
|
||||||
/// application-level close codes. The drop is the close (ADR-065
|
/// application-level close codes. The drop is the close (ADR-007
|
||||||
/// §"Negative"). The `_` prefix is intentional — the signature matches
|
/// §"Negative"). The `_` prefix is intentional — the signature matches
|
||||||
/// the public `Connection::close` API (ADR-070 §"REQ-CORE-02").
|
/// the public `Connection::close` API (ADR-008 §"REQ-CORE-02").
|
||||||
fn close(&self, _code: u32, _reason: &str) {
|
fn close(&self, _code: u32, _reason: &str) {
|
||||||
let _ = self.stream.lock().unwrap_or_else(|e| e.into_inner()).take();
|
let _ = self.stream.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||||
}
|
}
|
||||||
@@ -467,11 +469,11 @@ impl Connection {
|
|||||||
/// Construct a `Connection` from a single bidirectional stream (e.g.
|
/// Construct a `Connection` from a single bidirectional stream (e.g.
|
||||||
/// `tokio::io::DuplexStream`, `TlsStream<TcpStream>`,
|
/// `tokio::io::DuplexStream`, `TlsStream<TcpStream>`,
|
||||||
/// `russh::Channel::into_stream()`). The stream is wrapped in a
|
/// `russh::Channel::into_stream()`). The stream is wrapped in a
|
||||||
/// `BiStream` (ADR-092) and yielded by `accept_bi` once, then
|
/// `BiStream` (ADR-009) and yielded by `accept_bi` once, then
|
||||||
/// `ConnectionClosed`. `open_bi` returns `StreamClosed` (a single
|
/// `ConnectionClosed`. `open_bi` returns `StreamClosed` (a single
|
||||||
/// stream can't open new application streams — ADR-065).
|
/// stream can't open new application streams — ADR-007).
|
||||||
///
|
///
|
||||||
/// This is the only public stream constructor (ADR-092): the split
|
/// This is the only public stream constructor (ADR-009): the split
|
||||||
/// never crosses a crate boundary as part of a constructor. Handlers
|
/// never crosses a crate boundary as part of a constructor. Handlers
|
||||||
/// that want the split halves call `tokio::io::split(&mut *stream)` on
|
/// that want the split halves call `tokio::io::split(&mut *stream)` on
|
||||||
/// the `BiStream` they receive from `accept_bi`.
|
/// the `BiStream` they receive from `accept_bi`.
|
||||||
@@ -492,7 +494,7 @@ impl Connection {
|
|||||||
|
|
||||||
/// Construct from a caller-supplied `BidiStreamSource` impl. The
|
/// Construct from a caller-supplied `BidiStreamSource` impl. The
|
||||||
/// extension point for downstream crates — implement the trait and
|
/// extension point for downstream crates — implement the trait and
|
||||||
/// construct a `Connection` from it without editing core. See ADR-070.
|
/// construct a `Connection` from it without editing core. See ADR-008.
|
||||||
pub fn from_source(source: impl BidiStreamSource, alpn: Vec<u8>) -> Self {
|
pub fn from_source(source: impl BidiStreamSource, alpn: Vec<u8>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
source: Box::new(source),
|
source: Box::new(source),
|
||||||
@@ -514,7 +516,7 @@ impl Connection {
|
|||||||
/// Handlers that loop `accept_bi` (e.g. `TtyAdapter`) get one session
|
/// Handlers that loop `accept_bi` (e.g. `TtyAdapter`) get one session
|
||||||
/// per single-stream connection; handlers that call once (e.g.
|
/// per single-stream connection; handlers that call once (e.g.
|
||||||
/// `HttpAdapter`) get the stream directly. Both are correct. The
|
/// `HttpAdapter`) get the stream directly. Both are correct. The
|
||||||
/// return type is `BiStream` (ADR-092); handlers that want the split
|
/// return type is `BiStream` (ADR-009); handlers that want the split
|
||||||
/// halves call `tokio::io::split` on the `BiStream`.
|
/// halves call `tokio::io::split` on the `BiStream`.
|
||||||
pub async fn accept_bi(&self) -> Result<BiStream, StreamError> {
|
pub async fn accept_bi(&self) -> Result<BiStream, StreamError> {
|
||||||
self.source.accept_bi().await
|
self.source.accept_bi().await
|
||||||
@@ -656,7 +658,7 @@ mod tests {
|
|||||||
/// `tokio::io::sink()` + `tokio::io::empty()`: reads yield EOF
|
/// `tokio::io::sink()` + `tokio::io::empty()`: reads yield EOF
|
||||||
/// immediately (zero bytes), writes discard. Exists because
|
/// immediately (zero bytes), writes discard. Exists because
|
||||||
/// `Connection::from_bidi` requires a single value that implements
|
/// `Connection::from_bidi` requires a single value that implements
|
||||||
/// both traits (ADR-092). Used only to construct a `Connection` for
|
/// both traits (ADR-009). Used only to construct a `Connection` for
|
||||||
/// tests that exercise `Connection`-level state (alpn, addr, identity)
|
/// tests that exercise `Connection`-level state (alpn, addr, identity)
|
||||||
/// without ever reading or writing the stream.
|
/// without ever reading or writing the stream.
|
||||||
pub(crate) struct SinkEmpty;
|
pub(crate) struct SinkEmpty;
|
||||||
|
|||||||
@@ -527,6 +527,38 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invoke_sink_with_no_deadline_completes_a_slow_sink_handler() {
|
||||||
|
let mut registry = OperationRegistry::new();
|
||||||
|
registry
|
||||||
|
.register(HandlerRegistration::new(
|
||||||
|
spec("slow/sink", Visibility::External, OperationType::Pub),
|
||||||
|
HandlerKind::Sink(make_sink_handler(|_input, ctx, mut chunks| async move {
|
||||||
|
while let Some(chunk) = chunks.next().await {
|
||||||
|
let _ = chunk;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(80)).await;
|
||||||
|
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({}))
|
||||||
|
})),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let dispatch = GatewayDispatch::new(Arc::new(registry)).with_deadline(None);
|
||||||
|
let chunks: crate::registry::registration::PublishStream = Box::pin(futures::stream::iter(
|
||||||
|
vec![Ok(serde_json::json!({ "n": 1 }))],
|
||||||
|
));
|
||||||
|
let envelope = dispatch
|
||||||
|
.invoke_sink(None, "/slow/sink", serde_json::json!({}), chunks)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
envelope.result.is_ok(),
|
||||||
|
"a no-deadline spine must not time out a slow sink, got {envelope:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn streaming_sub_op_streams_envelopes() {
|
async fn streaming_sub_op_streams_envelopes() {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
//! going forward.
|
//! going forward.
|
||||||
//! - **Registry** ([`registry`]): operation specs, context, dispatch, and
|
//! - **Registry** ([`registry`]): operation specs, context, dispatch, and
|
||||||
//! the operation registry — the call half's dispatch core.
|
//! the operation registry — the call half's dispatch core.
|
||||||
//! - **Gateway** ([`gateway`], feature `gateway`): the transport-neutral
|
//! - **Gateway** (module `gateway`, feature `gateway`): the transport-neutral
|
||||||
//! dispatch spine and `services/schema` disclosure guard — the
|
//! dispatch spine and `services/schema` disclosure guard — the
|
||||||
//! deadline-bounded, re-rooted-context invoke surface for HTTP
|
//! deadline-bounded, re-rooted-context invoke surface for HTTP
|
||||||
//! gateways, hub relays, and other transport front-ends (ADR-048).
|
//! gateways, hub relays, and other transport front-ends (ADR-048).
|
||||||
|
|||||||
+205
-3
@@ -213,7 +213,7 @@ impl CallConnection {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// `open_bi` returns a `BiStream` (ADR-092); split it into halves for
|
// `open_bi` returns a `BiStream` (ADR-009); split it into halves for
|
||||||
// the call protocol's separate write (request) and read (response)
|
// the call protocol's separate write (request) and read (response)
|
||||||
// pumps. The split is the stdlib idiom; no per-handler wrapper.
|
// pumps. The split is the stdlib idiom; no per-handler wrapper.
|
||||||
let stream = match connection.open_bi().await {
|
let stream = match connection.open_bi().await {
|
||||||
@@ -337,7 +337,7 @@ impl CallConnection {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// `open_bi` returns a `BiStream` (ADR-092); split for the separate
|
// `open_bi` returns a `BiStream` (ADR-009); split for the separate
|
||||||
// write (request) and read (subscription events) pumps.
|
// write (request) and read (subscription events) pumps.
|
||||||
let stream = match connection.open_bi().await {
|
let stream = match connection.open_bi().await {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
@@ -600,7 +600,7 @@ impl CallConnection {
|
|||||||
.connection
|
.connection
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "no underlying connection (overlay-only)".to_string())?;
|
.ok_or_else(|| "no underlying connection (overlay-only)".to_string())?;
|
||||||
// `open_bi` returns a `BiStream` (ADR-092). We only need the write
|
// `open_bi` returns a `BiStream` (ADR-009). We only need the write
|
||||||
// half to send the envelope; split and drop the read half.
|
// half to send the envelope; split and drop the read half.
|
||||||
let stream = connection
|
let stream = connection
|
||||||
.open_bi()
|
.open_bi()
|
||||||
@@ -1938,6 +1938,39 @@ mod tests {
|
|||||||
crate::core::types::BiStream::from_joined(read_end, BrokenWrite)
|
crate::core::types::BiStream::from_joined(read_end, BrokenWrite)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AsyncWrite whose writes succeed but whose Nth `poll_flush` fails.
|
||||||
|
/// `FrameFramedWriter::write_frame` flushes exactly once per frame, so
|
||||||
|
/// `remaining_ok_flushes = k` lets frames 1..=k land and fails inside
|
||||||
|
/// frame k+1 — a deterministic mid-stream write failure.
|
||||||
|
struct FailOnFlushN {
|
||||||
|
remaining_ok_flushes: std::sync::atomic::AtomicUsize,
|
||||||
|
}
|
||||||
|
impl AsyncWrite for FailOnFlushN {
|
||||||
|
fn poll_write(
|
||||||
|
self: Pin<&mut Self>,
|
||||||
|
_cx: &mut Context<'_>,
|
||||||
|
buf: &[u8],
|
||||||
|
) -> Poll<std::io::Result<usize>> {
|
||||||
|
Poll::Ready(Ok(buf.len()))
|
||||||
|
}
|
||||||
|
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||||
|
let prev = self
|
||||||
|
.remaining_ok_flushes
|
||||||
|
.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
if prev == 0 {
|
||||||
|
Poll::Ready(Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::BrokenPipe,
|
||||||
|
"mux dead",
|
||||||
|
)))
|
||||||
|
} else {
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn single_stream_call_write_failure_is_retryable_connection_closed() {
|
async fn single_stream_call_write_failure_is_retryable_connection_closed() {
|
||||||
let bi_stream = broken_write_bi_stream();
|
let bi_stream = broken_write_bi_stream();
|
||||||
@@ -2045,6 +2078,175 @@ mod tests {
|
|||||||
assert!(err.retryable);
|
assert!(err.retryable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn single_stream_subscribe_write_failure_is_retryable_connection_closed() {
|
||||||
|
use futures::stream::StreamExt;
|
||||||
|
|
||||||
|
let bi_stream = broken_write_bi_stream();
|
||||||
|
let (writer, reader) = split_single_stream(bi_stream);
|
||||||
|
let conn = CallConnection::new_single_stream(stub_connection(), writer);
|
||||||
|
drop(reader);
|
||||||
|
|
||||||
|
let mut stream = conn.subscribe("test/events", serde_json::json!({})).await;
|
||||||
|
let item = stream.next().await.unwrap();
|
||||||
|
match item.result {
|
||||||
|
Err(e) => {
|
||||||
|
assert_eq!(e.code, "CONNECTION_CLOSED");
|
||||||
|
assert!(e.retryable);
|
||||||
|
}
|
||||||
|
other => panic!("expected retryable CONNECTION_CLOSED, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(stream.next().await.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multi_stream_subscribe_write_failure_is_retryable_connection_closed() {
|
||||||
|
use crate::core::types::StreamError;
|
||||||
|
use crate::core::types::{BiStream, BidiStreamSource, Connection};
|
||||||
|
use futures::stream::StreamExt;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
struct OneBiStream(std::sync::Mutex<Option<crate::core::types::BiStream>>);
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl BidiStreamSource for OneBiStream {
|
||||||
|
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
|
||||||
|
Err(StreamError::ConnectionClosed)
|
||||||
|
}
|
||||||
|
async fn open_bi(&self) -> Result<BiStream, StreamError> {
|
||||||
|
let stream = self.0.lock().expect("open once").take();
|
||||||
|
stream.ok_or(StreamError::StreamClosed)
|
||||||
|
}
|
||||||
|
fn remote_addr(&self) -> Option<SocketAddr> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn close(&self, _code: u32, _reason: &str) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = Connection::from_source(
|
||||||
|
OneBiStream(std::sync::Mutex::new(Some(broken_write_bi_stream()))),
|
||||||
|
b"alk/call".to_vec(),
|
||||||
|
);
|
||||||
|
let call_conn = CallConnection::new(conn);
|
||||||
|
|
||||||
|
let mut stream = call_conn
|
||||||
|
.subscribe("test/events", serde_json::json!({}))
|
||||||
|
.await;
|
||||||
|
let item = stream.next().await.unwrap();
|
||||||
|
match item.result {
|
||||||
|
Err(e) => {
|
||||||
|
assert_eq!(e.code, "CONNECTION_CLOSED");
|
||||||
|
assert!(e.retryable);
|
||||||
|
}
|
||||||
|
other => panic!("expected retryable CONNECTION_CLOSED, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(stream.next().await.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn single_stream_publish_request_frame_write_failure_is_retryable() {
|
||||||
|
let bi_stream = broken_write_bi_stream();
|
||||||
|
let (writer, reader) = split_single_stream(bi_stream);
|
||||||
|
let conn = CallConnection::new_single_stream(stub_connection(), writer);
|
||||||
|
drop(reader);
|
||||||
|
|
||||||
|
let response = conn
|
||||||
|
.publish(
|
||||||
|
"fs/upload",
|
||||||
|
serde_json::json!({}),
|
||||||
|
Box::pin(futures::stream::iter(vec![serde_json::json!({"c": 1})])),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match response.result {
|
||||||
|
Err(e) => {
|
||||||
|
// The request frame never landed — nothing delivered, retry safe.
|
||||||
|
assert_eq!(e.code, "CONNECTION_CLOSED");
|
||||||
|
assert!(e.retryable);
|
||||||
|
}
|
||||||
|
other => panic!("expected retryable CONNECTION_CLOSED, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn single_stream_publish_midpublish_write_failure_stays_internal() {
|
||||||
|
// Flush succeeds for the request frame (1) + first chunk (2), then
|
||||||
|
// fails inside the second chunk — delivery is ambiguous from there.
|
||||||
|
let bi_stream = crate::core::types::BiStream::from_joined(
|
||||||
|
tokio::io::empty(),
|
||||||
|
FailOnFlushN {
|
||||||
|
remaining_ok_flushes: std::sync::atomic::AtomicUsize::new(2),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (writer, reader) = split_single_stream(bi_stream);
|
||||||
|
let conn = CallConnection::new_single_stream(stub_connection(), writer);
|
||||||
|
drop(reader);
|
||||||
|
|
||||||
|
let chunks = vec![serde_json::json!({"c": 1}), serde_json::json!({"c": 2})];
|
||||||
|
let stream: Pin<Box<dyn Stream<Item = Value> + Send>> =
|
||||||
|
Box::pin(futures::stream::iter(chunks));
|
||||||
|
let response = conn
|
||||||
|
.publish("fs/upload", serde_json::json!({}), stream)
|
||||||
|
.await;
|
||||||
|
match response.result {
|
||||||
|
Err(e) => {
|
||||||
|
assert_eq!(e.code, "INTERNAL");
|
||||||
|
assert!(!e.retryable, "mid-publish failure must not be retryable");
|
||||||
|
}
|
||||||
|
other => panic!("expected non-retryable INTERNAL, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multi_stream_publish_midpublish_write_failure_stays_internal() {
|
||||||
|
use crate::core::types::StreamError;
|
||||||
|
use crate::core::types::{BiStream, BidiStreamSource, Connection};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
struct OneBiStream(std::sync::Mutex<Option<crate::core::types::BiStream>>);
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl BidiStreamSource for OneBiStream {
|
||||||
|
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
|
||||||
|
Err(StreamError::ConnectionClosed)
|
||||||
|
}
|
||||||
|
async fn open_bi(&self) -> Result<BiStream, StreamError> {
|
||||||
|
let stream = self.0.lock().expect("open once").take();
|
||||||
|
stream.ok_or(StreamError::StreamClosed)
|
||||||
|
}
|
||||||
|
fn remote_addr(&self) -> Option<SocketAddr> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn close(&self, _code: u32, _reason: &str) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = Connection::from_source(
|
||||||
|
OneBiStream(std::sync::Mutex::new(Some(
|
||||||
|
crate::core::types::BiStream::from_joined(
|
||||||
|
tokio::io::empty(),
|
||||||
|
FailOnFlushN {
|
||||||
|
remaining_ok_flushes: std::sync::atomic::AtomicUsize::new(2),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
))),
|
||||||
|
b"alk/call".to_vec(),
|
||||||
|
);
|
||||||
|
let call_conn = CallConnection::new(conn);
|
||||||
|
|
||||||
|
let chunks = vec![serde_json::json!({"c": 1}), serde_json::json!({"c": 2})];
|
||||||
|
let stream: Pin<Box<dyn Stream<Item = Value> + Send>> =
|
||||||
|
Box::pin(futures::stream::iter(chunks));
|
||||||
|
let response = call_conn
|
||||||
|
.publish("fs/upload", serde_json::json!({}), stream)
|
||||||
|
.await;
|
||||||
|
match response.result {
|
||||||
|
Err(e) => {
|
||||||
|
// The pump died on a *chunk* frame — the request landed,
|
||||||
|
// delivery is ambiguous, INTERNAL (non-retryable) is correct.
|
||||||
|
assert_eq!(e.code, "INTERNAL");
|
||||||
|
assert!(!e.retryable);
|
||||||
|
}
|
||||||
|
other => panic!("expected non-retryable INTERNAL, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn shared_frame_writer_concurrent_writes_do_not_interleave() {
|
async fn shared_frame_writer_concurrent_writes_do_not_interleave() {
|
||||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ impl Dispatcher {
|
|||||||
connection: Arc<CallConnection>,
|
connection: Arc<CallConnection>,
|
||||||
stream: crate::core::types::BiStream,
|
stream: crate::core::types::BiStream,
|
||||||
) {
|
) {
|
||||||
// `stream` is a `BiStream` (ADR-092) — `AsyncRead + AsyncWrite + Send
|
// `stream` is a `BiStream` (ADR-009) — `AsyncRead + AsyncWrite + Send
|
||||||
// + Unpin`. Split into the read and write halves the call protocol's
|
// + Unpin`. Split into the read and write halves the call protocol's
|
||||||
// frame reader/writer consume. The split is the stdlib idiom; no
|
// frame reader/writer consume. The split is the stdlib idiom; no
|
||||||
// per-handler wrapper.
|
// per-handler wrapper.
|
||||||
@@ -441,7 +441,7 @@ impl Dispatcher {
|
|||||||
/// for `Ok`, `call.error` for `Err`). On natural stream end (the stream
|
/// for `Ok`, `call.error` for `Err`). On natural stream end (the stream
|
||||||
/// returned `None` without the last item being an `Err`), write a
|
/// returned `None` without the last item being an `Err`), write a
|
||||||
/// `call.completed` frame. An `Err` envelope is terminal — the stream
|
/// `call.completed` frame. An `Err` envelope is terminal — the stream
|
||||||
/// ends after it and we do NOT write `call.completed` (ADR-049 §6).
|
/// ends after it and we do NOT write `call.completed` (ADR-021 §6).
|
||||||
///
|
///
|
||||||
/// If a frame write fails the pump stops early; the stream is dropped on
|
/// If a frame write fails the pump stops early; the stream is dropped on
|
||||||
/// return, releasing the handler's resources via `Drop` (ADR-016). A
|
/// return, releasing the handler's resources via `Drop` (ADR-016). A
|
||||||
@@ -1477,7 +1477,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- streaming dispatch branch (ADR-049 §6) ---------------------------
|
// --- streaming dispatch branch (ADR-021 §6) ---------------------------
|
||||||
|
|
||||||
fn subscription_spec(name: &str, acl: AccessControl) -> OperationSpec {
|
fn subscription_spec(name: &str, acl: AccessControl) -> OperationSpec {
|
||||||
OperationSpec::new(
|
OperationSpec::new(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Shared test helpers for the call protocol's inline `#[cfg(test)]`
|
//! Shared test helpers for the call protocol's inline `#[cfg(test)]`
|
||||||
//! modules. Kept here (not in each test module) so the `stub_connection()`
|
//! modules. Kept here (not in each test module) so the `stub_connection()`
|
||||||
//! shape is defined once — `Connection::from_stream` was removed (ADR-092)
|
//! shape is defined once — `Connection::from_stream` was removed (ADR-009)
|
||||||
//! and every test stub that previously called it now calls
|
//! and every test stub that previously called it now calls
|
||||||
//! `Connection::from_bidi(SinkEmpty, ...)` via `sink_empty_connection()`.
|
//! `Connection::from_bidi(SinkEmpty, ...)` via `sink_empty_connection()`.
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
|||||||
/// A test-only `AsyncRead + AsyncWrite` pair equivalent to
|
/// A test-only `AsyncRead + AsyncWrite` pair equivalent to
|
||||||
/// `tokio::io::sink() + tokio::io::empty()`: reads yield EOF immediately
|
/// `tokio::io::sink() + tokio::io::empty()`: reads yield EOF immediately
|
||||||
/// (zero bytes), writes discard. Exists because `Connection::from_bidi`
|
/// (zero bytes), writes discard. Exists because `Connection::from_bidi`
|
||||||
/// (ADR-092 — the only public stream constructor, replacing
|
/// (ADR-009 — the only public stream constructor, replacing
|
||||||
/// `from_stream`) requires a single value that implements both traits.
|
/// `from_stream`) requires a single value that implements both traits.
|
||||||
/// Used only to construct a `Connection` for tests that exercise
|
/// Used only to construct a `Connection` for tests that exercise
|
||||||
/// `Connection`-level state (alpn, addr, identity, dispatcher run loop
|
/// `Connection`-level state (alpn, addr, identity, dispatcher run loop
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ pub struct OperationContext {
|
|||||||
pub internal: bool,
|
pub internal: bool,
|
||||||
/// `None` when no ownership provider is wired (backward compat —
|
/// `None` when no ownership provider is wired (backward compat —
|
||||||
/// `check` falls back to static `Identity.resources` path). Wired by
|
/// `check` falls back to static `Identity.resources` path). Wired by
|
||||||
/// the assembly layer via `CallAdapter`/`Dispatcher` (ADR-050).
|
/// the assembly layer via `CallAdapter`/`Dispatcher` (ADR-011).
|
||||||
pub ownership: Option<Arc<dyn OwnershipProvider>>,
|
pub ownership: Option<Arc<dyn OwnershipProvider>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -662,7 +662,7 @@ where
|
|||||||
/// shallow nested lookups (translated to `/a/b`).
|
/// shallow nested lookups (translated to `/a/b`).
|
||||||
///
|
///
|
||||||
/// Returns `None` if the path is malformed, the field is missing, or the
|
/// Returns `None` if the path is malformed, the field is missing, or the
|
||||||
/// value at the path is not a string. Graceful — never panics (ADR-050 §2a):
|
/// value at the path is not a string. Graceful — never panics (ADR-011 §2a):
|
||||||
/// a missing field simply yields `resource_id: None`, and the caller's
|
/// a missing field simply yields `resource_id: None`, and the caller's
|
||||||
/// `AccessControl::check` decides whether to deny based on whether the spec
|
/// `AccessControl::check` decides whether to deny based on whether the spec
|
||||||
/// requires a specific resource ID.
|
/// requires a specific resource ID.
|
||||||
|
|||||||
Reference in new issue
Block a user