feat: channels path carries the negotiation in the open op (L1, L3)
The channels path no longer carries a second negotiation frame on the channel's data stream (ADR-009). The open op's registry-validated input IS the negotiation: - producer: make_tty_open_handler parses the open op's input into a NegotiateRequest and drives the new drive_session_pre_negotiated (same three-pump driver as drive_session, minus the wire-frame negotiation phase; validate/allocate factored into validate_and_allocate, shared by both paths). Post-open failures (unknown backend, allocate_failed, ownership denial) still go to the client as a 0x00-prefixed negotiation error frame, so the consumer's M1 disambiguation read applies unchanged. The tty:open scope gate is enforced by the registry's AccessControl (not re-checked in the handler). - consumer: open_via_channels parses params locally (fail-fast before a channel is allocated), opens the channel, and starts raw-chunk mode directly (from_halves_raw — no negotiation write; the 0x00-error-frame peek retained). - tty_open_spec's input schema is now the partial NegotiateRequest shape (carriage/backend/cmd required; backend params stay free-form — raw JSON Schema is permissive on unknown keys). Prerequisites landed upstream: alkcall 0.4.0 enforces OperationSpec.input_schema at dispatch (the registry check this design leans on never existed before); alkcall 0.4.1 parks early-arrival chunks for un-adopted channels instead of dropping them — the open response / producer's-first-write race was silently losing the first chunks (found by L3's test; the session never resolved). L3: open_via_channels + from_bidi_stream_via now covered end-to-end (5 session tests + pre-negotiated adapter test + shared-harness tests in the new crate::testing module; the channels harness moved there so session tests share it). Verification: cargo test 93 lib (default), 116 lib + 19 integration (--all-features); clippy -D warnings native + wasm clean; fmt clean; doc 0 warnings; wasm check clean.
This commit is contained in:
+245
-22
@@ -17,8 +17,10 @@
|
||||
//! `open_via_channels(client, params)`, which invokes
|
||||
//! `channels/tty/sub` on channel 0, adopts the resulting channel,
|
||||
//! builds a `Connection` from the reassembled read half + mux write
|
||||
//! half, and runs the same negotiation + typed-methods flow on the
|
||||
//! channel's `BiStream`.
|
||||
//! half, and runs the typed-methods flow directly in raw chunk mode
|
||||
//! (ADR-009: the open op's `params` — validated by the registry's
|
||||
//! input schema — *are* the negotiation; no second negotiation
|
||||
//! frame is written on the channel's data stream).
|
||||
//!
|
||||
//! The session handle exposes:
|
||||
//! - [`TtySession::send_stdin`] / [`TtySession::close_stdin`] — write
|
||||
@@ -31,10 +33,11 @@
|
||||
//! process exit code).
|
||||
//!
|
||||
//! The session does NOT re-serialize the negotiation request itself
|
||||
//! — the caller passes a `NegotiateRequest` (or a `serde_json::Value`
|
||||
//! for the channels path, since `ChannelClient::open_channel` takes a
|
||||
//! `Value`). The session writes the frame and switches to raw-chunk
|
||||
//! mode. See ADR-052 for the two-carriage model.
|
||||
//! — the caller passes a `NegotiateRequest` (direct path) or a
|
||||
//! `serde_json::Value` (channels path — the open op's `input` is the
|
||||
//! negotiation). The direct path writes the frame and switches to
|
||||
//! raw-chunk mode; the channels path starts in raw-chunk mode. See
|
||||
//! ADR-052 for the two-carriage model.
|
||||
//!
|
||||
//! # WASM
|
||||
//!
|
||||
@@ -180,22 +183,34 @@ impl TtySession {
|
||||
/// `open_via_channels(client, params)`, which invokes
|
||||
/// `channels/tty/sub` on channel 0, adopts the resulting channel,
|
||||
/// builds a `Connection` from the reassembled read half + mux
|
||||
/// write half, and runs the same negotiation + typed-methods flow
|
||||
/// on the channel's `BiStream`.
|
||||
/// write half, and runs the typed-methods flow directly in raw
|
||||
/// chunk mode (ADR-009: the open op's `params` — validated by the
|
||||
/// registry's input schema — *are* the negotiation; no second
|
||||
/// negotiation frame is written on the channel's data stream).
|
||||
///
|
||||
/// `params` is the `NegotiateRequest` as a `serde_json::Value` —
|
||||
/// the channels open op takes a `Value`, not a typed struct (the
|
||||
/// op's input schema is the `NegotiateRequest` shape). The
|
||||
/// session re-parses it as a `NegotiateRequest` after the channel
|
||||
/// is open so the typed methods can use the strongly-typed shape.
|
||||
/// the channels open op takes a `Value`, not a typed struct. The
|
||||
/// session parses it as a `NegotiateRequest` before opening (so a
|
||||
/// malformed request fails fast, before a channel is allocated)
|
||||
/// and the producer parses the same value from the open op.
|
||||
///
|
||||
/// Failures before the channel opens (ACL denial, unknown op,
|
||||
/// channel cap, invalid params) surface as
|
||||
/// [`TtySessionError::ChannelsOpen`]. Post-open failures (unknown
|
||||
/// backend, allocate failure, ownership denial) arrive as a
|
||||
/// negotiation error frame on the channel stream — the session
|
||||
/// surfaces those as [`TtySessionError::NegotiationRejected`] via
|
||||
/// the same `0x00` disambiguation read the direct path uses.
|
||||
pub async fn open_via_channels(
|
||||
client: &ChannelClient,
|
||||
params: serde_json::Value,
|
||||
) -> Result<Self, TtySessionError> {
|
||||
let _: NegotiateRequest = serde_json::from_value(params.clone())
|
||||
.map_err(TtySessionError::NegotiationSerialize)?;
|
||||
let (channel_id, send, recv) = client
|
||||
.open_channel(
|
||||
crate::channels::OP_TTY_OPEN,
|
||||
params.clone(),
|
||||
params,
|
||||
crate::channels::TTY_ALPN,
|
||||
)
|
||||
.await
|
||||
@@ -207,9 +222,7 @@ impl TtySession {
|
||||
let channel_conn =
|
||||
Connection::from_source(source, crate::channels::TTY_ALPN.as_bytes().to_vec());
|
||||
|
||||
let negotiate: NegotiateRequest =
|
||||
serde_json::from_value(params).map_err(TtySessionError::NegotiationSerialize)?;
|
||||
Self::from_bidi_stream_via(channel_conn, negotiate).await
|
||||
Self::from_bidi_stream_via(channel_conn).await
|
||||
}
|
||||
|
||||
/// Shared inner: take a `BiStream`, write the negotiation frame,
|
||||
@@ -225,15 +238,14 @@ impl TtySession {
|
||||
|
||||
/// Like `from_bidi_stream` but takes the channel's `Connection`
|
||||
/// directly (the channels path already has the `Connection` from
|
||||
/// `Connection::from_source`).
|
||||
async fn from_bidi_stream_via(
|
||||
channel_conn: Connection,
|
||||
negotiate: NegotiateRequest,
|
||||
) -> Result<Self, TtySessionError> {
|
||||
/// `Connection::from_source`). The negotiation already happened in
|
||||
/// the open op (ADR-009) — the stream starts in raw-chunk mode.
|
||||
async fn from_bidi_stream_via(channel_conn: Connection) -> Result<Self, TtySessionError> {
|
||||
let stream = channel_conn.accept_bi().await.map_err(|e| {
|
||||
std::io::Error::new(std::io::ErrorKind::ConnectionReset, format!("{e}"))
|
||||
})?;
|
||||
Self::from_bidi_stream(stream, negotiate).await
|
||||
let (read, write) = tokio::io::split(stream);
|
||||
Self::from_halves_raw(read, write).await
|
||||
}
|
||||
|
||||
/// Core inner: take a read half and a write half, write the
|
||||
@@ -274,6 +286,48 @@ impl TtySession {
|
||||
Err(e) => return Err(TtySessionError::Wire(e)),
|
||||
}
|
||||
|
||||
Self::start_pump(writer, reader, first_byte_peeked)
|
||||
}
|
||||
|
||||
/// Core inner for the channels path (ADR-009): the negotiation
|
||||
/// already happened in the open op — the stream is already in
|
||||
/// raw-chunk mode. The peek still applies: the producer sends a
|
||||
/// `0x00`-prefixed error frame on post-open failures (unknown
|
||||
/// backend, allocate failure, ownership denial), and a raw chunk
|
||||
/// (`stream_type` in `{1, 2, 4}`) on success.
|
||||
async fn from_halves_raw<R, W>(read: R, write: W) -> Result<Self, TtySessionError>
|
||||
where
|
||||
R: AsyncRead + Send + Unpin + 'static,
|
||||
W: AsyncWrite + Send + Unpin + 'static,
|
||||
{
|
||||
let writer = ChunkWriter::new(Box::new(write) as Box<dyn AsyncWrite + Send + Unpin>);
|
||||
let mut reader = ChunkReader::new(read);
|
||||
let mut first_byte_peeked = false;
|
||||
match reader.peek_stream_type().await {
|
||||
Ok(0x00) => {
|
||||
return Err(read_negotiation_error(reader.into_inner()).await);
|
||||
}
|
||||
Ok(_) => first_byte_peeked = true,
|
||||
Err(RawError::ConnectionClosed) => {
|
||||
// The server closed cleanly without a response. Fall
|
||||
// through to the read pump, which resolves `NoExitChunk`.
|
||||
}
|
||||
Err(e) => return Err(TtySessionError::Wire(e)),
|
||||
}
|
||||
|
||||
Self::start_pump(writer, reader, first_byte_peeked)
|
||||
}
|
||||
|
||||
/// Wire up the exit watch + stdout/stderr channels and spawn the
|
||||
/// read pump. Shared by `from_halves` and `from_halves_raw`.
|
||||
fn start_pump<R>(
|
||||
writer: ChunkWriter<Box<dyn AsyncWrite + Send + Unpin>>,
|
||||
reader: ChunkReader<R>,
|
||||
first_byte_peeked: bool,
|
||||
) -> Result<Self, TtySessionError>
|
||||
where
|
||||
R: AsyncRead + Send + Unpin + 'static,
|
||||
{
|
||||
let (stdout_tx, stdout_rx) = mpsc::channel::<Bytes>(64);
|
||||
let (stderr_tx, stderr_rx) = mpsc::channel::<Bytes>(64);
|
||||
let (exit_tx, exit_rx) = tokio::sync::watch::channel::<Option<ExitOutcome>>(None);
|
||||
@@ -934,4 +988,173 @@ mod tests {
|
||||
}
|
||||
let _ = server_handle.await;
|
||||
}
|
||||
|
||||
// --- channels consumer path (L3, over the shared `testing` harness) ----
|
||||
|
||||
use crate::testing::{tty_identity, wire_client_and_server};
|
||||
|
||||
/// The negotiate params as the open op's `input` (same JSON shape
|
||||
/// the direct path serializes from `NegotiateRequest`).
|
||||
fn test_open_params(backend: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"carriage": "raw",
|
||||
"backend": backend,
|
||||
"cmd": ["true"],
|
||||
})
|
||||
}
|
||||
|
||||
fn mock_backends(code: i32) -> Arc<HashMap<String, Arc<dyn TtyBackend>>> {
|
||||
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
|
||||
backends.insert(
|
||||
"mock".to_string(),
|
||||
Arc::new(MockBackend::with_exit_code(code)),
|
||||
);
|
||||
Arc::new(backends)
|
||||
}
|
||||
|
||||
/// End-to-end (L3): `TtySession::open_via_channels` opens the
|
||||
/// channel through the real `register_openable` producer path and
|
||||
/// resolves the session. The negotiation travels in the open op's
|
||||
/// `input` (ADR-009); the channel stream starts in raw-chunk mode.
|
||||
#[tokio::test]
|
||||
async fn open_via_channels_end_to_end_negotiates_and_waits() {
|
||||
let client =
|
||||
wire_client_and_server(mock_backends(0), None, Some(tty_identity("alice"))).await;
|
||||
|
||||
let session = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
TtySession::open_via_channels(&client, test_open_params("mock")),
|
||||
)
|
||||
.await
|
||||
.expect("open_via_channels timed out")
|
||||
.expect("session opens");
|
||||
|
||||
let code = tokio::time::timeout(std::time::Duration::from_secs(10), session.wait())
|
||||
.await
|
||||
.expect("wait didn't time out")
|
||||
.expect("wait returns exit code");
|
||||
assert_eq!(code, 0);
|
||||
}
|
||||
|
||||
/// The same end-to-end path with an unknown backend: the producer
|
||||
/// sends a `0x00`-prefixed error frame on the channel stream, the
|
||||
/// consumer's post-open peek disambiguates it, and the session
|
||||
/// surfaces `NegotiationRejected` (M1 on the channels path).
|
||||
#[tokio::test]
|
||||
async fn open_via_channels_surfaces_negotiation_rejected() {
|
||||
let client =
|
||||
wire_client_and_server(mock_backends(0), None, Some(tty_identity("alice"))).await;
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
TtySession::open_via_channels(&client, test_open_params("nope")),
|
||||
)
|
||||
.await
|
||||
.expect("open_via_channels timed out");
|
||||
match result {
|
||||
Err(TtySessionError::NegotiationRejected { error, fields }) => {
|
||||
assert_eq!(error, "unknown_backend");
|
||||
assert_eq!(fields.get("backend").map(String::as_str), Some("nope"));
|
||||
}
|
||||
Ok(_) => panic!("expected NegotiationRejected, got Ok(session)"),
|
||||
Err(other) => panic!("expected NegotiationRejected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The open op's `input` is schema-validated by the registry
|
||||
/// (alkcall 0.4): a params value missing the required `backend`
|
||||
/// field is rejected before any handler runs, so the failure is a
|
||||
/// `ChannelsOpen` error (the open op fails), not a session error.
|
||||
#[tokio::test]
|
||||
async fn open_via_channels_fails_fast_on_schema_invalid_params() {
|
||||
let client =
|
||||
wire_client_and_server(mock_backends(0), None, Some(tty_identity("alice"))).await;
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
TtySession::open_via_channels(
|
||||
&client,
|
||||
serde_json::json!({ "carriage": "raw", "cmd": ["true"] }),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("open_via_channels timed out");
|
||||
assert!(
|
||||
matches!(result, Err(TtySessionError::NegotiationSerialize(_))),
|
||||
"schema-invalid params fail at the local NegotiateRequest parse (fail-fast, pre-open)"
|
||||
);
|
||||
}
|
||||
|
||||
/// `open_via_channels` with params that fail the local
|
||||
/// `NegotiateRequest` parse (not just the schema): fails fast,
|
||||
/// before a channel is allocated.
|
||||
#[tokio::test]
|
||||
async fn open_via_channels_fails_fast_on_unparseable_params() {
|
||||
let client =
|
||||
wire_client_and_server(mock_backends(0), None, Some(tty_identity("alice"))).await;
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
TtySession::open_via_channels(
|
||||
&client,
|
||||
serde_json::json!({ "carriage": 42, "backend": "mock", "cmd": ["true"] }),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("open_via_channels timed out");
|
||||
assert!(
|
||||
matches!(result, Err(TtySessionError::NegotiationSerialize(_))),
|
||||
"unparseable params must fail before the open op"
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end with an emitting backend (L2's backend): stdout and
|
||||
/// stderr route through the channels data plane to the consumer's
|
||||
/// typed streams — the full producer+consumer channels path with
|
||||
/// real data.
|
||||
#[tokio::test]
|
||||
async fn open_via_channels_routes_backend_stdout_and_stderr() {
|
||||
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
|
||||
backends.insert(
|
||||
"mock".to_string(),
|
||||
Arc::new(EmittingBackend {
|
||||
stdout: vec![Bytes::from_static(b"ch-out")],
|
||||
stderr: vec![Bytes::from_static(b"ch-err")],
|
||||
exit_code: 3,
|
||||
}),
|
||||
);
|
||||
let client =
|
||||
wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await;
|
||||
|
||||
let session = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
TtySession::open_via_channels(&client, test_open_params("mock")),
|
||||
)
|
||||
.await
|
||||
.expect("open_via_channels timed out")
|
||||
.expect("session opens");
|
||||
|
||||
let stdout = session.recv_stdout().await;
|
||||
let collected: Vec<Bytes> = stdout.collect().await;
|
||||
let data: Vec<Bytes> = collected.into_iter().filter(|b| !b.is_empty()).collect();
|
||||
assert_eq!(
|
||||
data,
|
||||
vec![Bytes::from_static(b"ch-out")],
|
||||
"stdout should route through the channels data plane"
|
||||
);
|
||||
|
||||
let stderr = session.recv_stderr().await.expect("stderr present");
|
||||
let collected: Vec<Bytes> = stderr.collect().await;
|
||||
assert_eq!(
|
||||
collected,
|
||||
vec![Bytes::from_static(b"ch-err")],
|
||||
"stderr should route through the channels data plane"
|
||||
);
|
||||
|
||||
let code = tokio::time::timeout(std::time::Duration::from_secs(10), session.wait())
|
||||
.await
|
||||
.expect("wait didn't time out")
|
||||
.expect("wait returns exit code");
|
||||
assert_eq!(code, 3);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user