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:
2026-09-05 07:08:17 +00:00
parent 9327a73496
commit 96692d3b6a
8 changed files with 781 additions and 243 deletions
+245 -22
View File
@@ -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);
}
}