fix: channels parse-failure path writes the negotiation error frame (R4)

Review #002 R4 — a NegotiateRequest parse failure of the open op's
schema-validated input died silently (log + return, channel teardown,
consumer observed NoExitChunk — indistinguishable from a crashed
producer), while the other post-open failure classes (unknown backend,
allocate_failed, ownership denial) wrote the 0x00-prefixed error frame.

- make_tty_open_handler now accepts the channel's BiStream and writes
  a malformed_negotiation frame via the shared
  crate::adapter::send_negotiation_error (now pub(crate)) before
  returning; the consumer's M1 peek surfaces NegotiationRejected
  unchanged
- the frame type and layout are unchanged (ADR-001 wire-stable
  contract); no new frame type, no wire change
- tests: make_tty_open_handler seam test with a hand-built
  schema-bypassing input (cwd: 42) + a real-registry end-to-end test
  via ChannelClient::open_channel (bypasses open_via_channels's local
  fail-fast parse — R5's path — so it exercises the producer handler)
- docs: ADR-009 amended (Parse-failure error frame section);
  tty-adapter.md malformed_negotiation row covers both paths;
  session.rs post-open failure lists updated; review #002 R4 resolved

Note: the review's "unreachable end-to-end" premise was refined —
open_via_channels parses params locally (fail-fast) so a TtySession
consumer never hits the producer-side parse failure, but direct
ChannelClient callers do; the schema is deliberately partial so a
schema-valid value (cwd typed as a number) reaches the handler.

Verification: cargo test 95 lib (default) / 138 (--all-features);
clippy -D warnings native + wasm clean; fmt clean; doc 0 warnings.
This commit is contained in:
2026-09-05 08:37:02 +00:00
parent 5b608117ff
commit 8ee9216a07
7 changed files with 263 additions and 25 deletions
@@ -6,6 +6,11 @@ Accepted (2026-09-05). Resolves review #001 L1. Prerequisites: alkcall
0.4.0 (call-time `input_schema` enforcement) and alkcall 0.4.1 0.4.0 (call-time `input_schema` enforcement) and alkcall 0.4.1
(early-arrival parking for un-adopted channels). (early-arrival parking for un-adopted channels).
Amended 2026-09-05 (review #002 R4): a `NegotiateRequest` parse failure
of the schema-validated `input` is now a client-visible
`malformed_negotiation` error frame, not a silent teardown — see
§"Parse-failure error frame (R4 amendment, 2026-09-05)".
## Context ## Context
Before this ADR, the channels path carried the negotiation twice. The Before this ADR, the channels path carried the negotiation twice. The
@@ -73,9 +78,10 @@ so the consumer's ADR-001 §5 disambiguation read applies unchanged.
A `NegotiateRequest` parse failure of the schema-validated `input` A `NegotiateRequest` parse failure of the schema-validated `input`
(the schema is deliberately partial — `carriage`/`backend`/`cmd` (the schema is deliberately partial — `carriage`/`backend`/`cmd`
required, `tty`/`cwd`/`env`/backend-params free-form so the opaque required, `tty`/`cwd`/`env`/backend-params free-form so the opaque
ADR-053 params pass through) is a handler-side failure; the handler ADR-053 params pass through) was originally a handler-side failure
logs and returns without writing an error frame (the channel is torn logged and returned without an error frame. The R4 amendment below
down by the wrapper's teardown task). makes it a client-visible error frame like every other post-open
failure.
### Consumer side ### Consumer side
@@ -102,6 +108,37 @@ prefix starting `0x00`) from a raw chunk (`stream_type` in `{1, 2, 4}`).
This ADR removes a frame from the channels data stream; it does not This ADR removes a frame from the channels data stream; it does not
change any frame that remains. change any frame that remains.
### Parse-failure error frame (R4 amendment, 2026-09-05)
All three post-open failure classes on the channels path are now
client-visible through the same `0x00` error-frame peek
(`from_halves_raw`):
1. **Semantic validation failures** (`carriage != "raw"`, empty `cmd`,
unknown backend, `allocate_failed`, ownership denial) — error
frames from `validate_and_allocate` (unchanged).
2. **`NegotiateRequest` parse failure of schema-valid `input`** — the
open handler accepts the channel's `BiStream` and writes a
`malformed_negotiation` frame
(`{"error":"malformed_negotiation","message":"..."}`) via the shared
`crate::adapter::send_negotiation_error`, then returns. Reachable
despite the registry's schema check because the schema is
deliberately partial: e.g. `cwd` typed as a number passes the schema
(unknown-key/type fields pass through for the opaque ADR-053
params) but fails the typed parse.
3. **Schema-invalid `input`** — rejected at dispatch by the registry
(alkcall 0.4) before any handler runs: a `CallError` on the open
op, no channel allocated, no error frame (unchanged).
This replaces the original behavior (log + return, channel teardown,
consumer observes `NoExitChunk` — indistinguishable from a crashed
producer). The error-frame layout is unchanged (ADR-001's wire-stable
contract); no new frame type, no new stream type. Tests: the
`make_tty_open_handler` seam test (hand-built `input` bypassing the
schema, `channels.rs::tests`) and the real-registry end-to-end test
(`testing.rs`); both assert the consumer-side `0x00` peek observes
`malformed_negotiation`.
## Consequences ## Consequences
**Positive:** **Positive:**
+1 -1
View File
@@ -175,7 +175,7 @@ entering raw mode. The error response shape:
| Error | When | Shape | | Error | When | Shape |
|-------|------|------| |-------|------|------|
| `unknown_backend` | the `backend` string is not in the adapter's backend map | `{"error":"unknown_backend","backend":"..."}` | | `unknown_backend` | the `backend` string is not in the adapter's backend map | `{"error":"unknown_backend","backend":"..."}` |
| `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation | `{"error":"malformed_negotiation","message":"..."}` | | `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation — on the direct path the wire frame, on the channels path the open op's `input` (schema-valid values can still fail the typed parse, e.g. `cwd` typed as a number, because the schema is deliberately partial) | `{"error":"malformed_negotiation","message":"..."}` |
| `allocate_failed` | `backend.allocate()` returned a `TtyError` | `{"error":"allocate_failed","message":"..."}` | | `allocate_failed` | `backend.allocate()` returned a `TtyError` | `{"error":"allocate_failed","message":"..."}` |
After sending the error response, the adapter closes the write half of After sending the error response, the adapter closes the write half of
+22 -5
View File
@@ -175,6 +175,22 @@ Option 1 is preferred if a test can be written for it (feed a handler
a schema-bypassing value directly — the unit-testable seam is a schema-bypassing value directly — the unit-testable seam is
`make_tty_open_handler` with a hand-built `input`). `make_tty_open_handler` with a hand-built `input`).
**Resolution (2026-09-05)**: option 1 implemented. `make_tty_open_handler`
accepts the channel's `BiStream` and writes a `malformed_negotiation`
error frame via the shared `crate::adapter::send_negotiation_error`
(now `pub(crate)`) before returning — all three post-open failure
classes are client-visible through the unchanged M1 peek. The
review's reachability analysis is refined by the R5 fail-fast
discovery: `TtySession::open_via_channels` parses `params` locally
before opening, so the consumer never sends a value it can't parse
itself — but the producer-side handler is still the reachable seam for
direct `ChannelClient` callers (the schema is deliberately partial; a
schema-valid `cwd: 42` fails the typed parse). Tests: the
`make_tty_open_handler` seam test (channels.rs) and a real-registry
end-to-end test via `ChannelClient::open_channel` (testing.rs). ADR-009
amended (§"Parse-failure error frame"); `tty-adapter.md` error table
updated.
--- ---
### R5. `open_via_channels` fail-fast surfaces as `NegotiationSerialize` ### R5. `open_via_channels` fail-fast surfaces as `NegotiationSerialize`
@@ -257,13 +273,14 @@ version-skew note in ADR-009); option 1 at that point.
| R1 | ADR-009 never written | write the ADR | small | none | ✅ resolved (`37ae07a`) | | R1 | ADR-009 never written | write the ADR | small | none | ✅ resolved (`37ae07a`) |
| R2 | stale docs from the L1 redesign | align with ADR-009 | trivial | none | ✅ resolved (`37ae07a`) | | R2 | stale docs from the L1 redesign | align with ADR-009 | trivial | none | ✅ resolved (`37ae07a`) |
| R3 | install-time identity snapshot | accepted design (hub-proxy rationale) | none | none | ✅ closed as intended | | R3 | install-time identity snapshot | accepted design (hub-proxy rationale) | none | none | ✅ closed as intended |
| R4 | silent death on parse-failure path | error frame or documented asymmetry | small | low | ⬜ open (deferred) | | R4 | silent death on parse-failure path | error frame or documented asymmetry | small | low | ✅ resolved (option 1) |
| R5 | `NegotiationSerialize` mislabel on fail-fast | additive variant (with `#[non_exhaustive]` decision) | small | medium (semver) | ⬜ open (deferred) | | R5 | `NegotiationSerialize` mislabel on fail-fast | additive variant (with `#[non_exhaustive]` decision) | small | medium (semver) | ⬜ open (deferred) |
R4/R5 are deferred deliberately: both touch the consumer-facing error R5 is deferred deliberately: it touches the consumer-facing error
surface, both are cheap, and neither is reachable-by-design today. surface, it is cheap, and the producer-side parse-failure arm (R4's
Batch them with the first post-1.0 API decision rather than churning concern) is now client-visible regardless. Batch it with the first
the error enum before a consumer exists. post-1.0 API decision rather than churning the error enum before a
consumer exists.
## Notes ## Notes
+8 -1
View File
@@ -168,7 +168,14 @@ fn has_scope(identity: &Option<Identity>, scope: &str) -> bool {
/// Send a negotiation error frame and close the write half. Consumes the /// Send a negotiation error frame and close the write half. Consumes the
/// writer so the underlying transport's shutdown runs after the frame is /// writer so the underlying transport's shutdown runs after the frame is
/// flushed. /// flushed.
async fn send_negotiation_error<W: AsyncWrite + Unpin>( ///
/// Shared by the direct path's failure arms and the channels open
/// handler (`make_tty_open_handler` writes the same `malformed_negotiation`
/// frame on a `NegotiateRequest` parse failure of the open op's `input` —
/// the pre-negotiated driver has no writer to consume, so the handler
/// builds its own over the channel's write half). `pub(crate)` so the
/// channels handler reuses it; not public API.
pub(crate) async fn send_negotiation_error<W: AsyncWrite + Unpin>(
mut writer: NegotiationWriter<W>, mut writer: NegotiationWriter<W>,
error: &str, error: &str,
fields: &[(&str, &str)], fields: &[(&str, &str)],
+121 -7
View File
@@ -43,7 +43,7 @@ use tracing::debug;
use crate::adapter::{drive_session_pre_negotiated, TTY_OPEN_SCOPE}; use crate::adapter::{drive_session_pre_negotiated, TTY_OPEN_SCOPE};
use crate::backend::TtyBackend; use crate::backend::TtyBackend;
use crate::negotiation::NegotiateRequest; use crate::negotiation::{NegotiateRequest, NegotiationWriter};
/// The per-ALPN open operation name (`channels/<alpn>/sub` convention, /// The per-ALPN open operation name (`channels/<alpn>/sub` convention,
/// ADR-047). TTY is consumer-opens (the client requests a shell), so /// ADR-047). TTY is consumer-opens (the client requests a shell), so
@@ -184,12 +184,27 @@ pub fn tty_open_spec() -> OperationSpec {
/// three-pump session driver as the direct-ALPN path, minus the /// three-pump session driver as the direct-ALPN path, minus the
/// wire-frame negotiation phase. /// wire-frame negotiation phase.
/// ///
/// Parse or validation failures (unknown backend, `allocate_failed`, /// All failure classes write a negotiation error frame on the channel
/// ADR-050 ownership denial) are reported to the client as a /// stream (the same `0x00`-prefixed framing the direct path uses), so a
/// negotiation error frame on the channel stream (the same /// consumer's negotiation-error disambiguation read applies unchanged:
/// `0x00`-prefixed framing the direct path uses), so a consumer's ///
/// negotiation-error disambiguation read applies unchanged. The /// - The registry rejects schema-invalid `input` at dispatch (alkcall
/// `tty:open` scope gate is enforced by the registry's `AccessControl` /// 0.4) — the open op fails, no channel is ever allocated, and the
/// failure is a `CallError` on the open op, not a channel-stream
/// error frame. But the schema is deliberately partial (the opaque
/// ADR-053 backend params pass through), so a schema-valid value can
/// still fail the full `NegotiateRequest` parse — e.g. `cwd` typed
/// as a number — and that failure is post-open: the handler writes a
/// `malformed_negotiation` error frame (the same
/// [`crate::adapter::send_negotiation_error`] the other failure
/// classes use) and the consumer's peek surfaces
/// `NegotiationRejected`.
/// - Semantic validation failures (unknown backend, `carriage != "raw"`,
/// empty `cmd`, `allocate_failed`, ADR-050 ownership denial) are
/// post-open error frames written by `validate_and_allocate` inside
/// [`crate::adapter::drive_session_pre_negotiated`].
///
/// The `tty:open` scope gate is enforced by the registry's `AccessControl`
/// before this handler runs — the handler does not re-check it. /// before this handler runs — the handler does not re-check it.
/// ///
/// The handler's `JoinHandle` is recorded by the channels wrapper for /// The handler's `JoinHandle` is recorded by the channels wrapper for
@@ -214,6 +229,20 @@ fn make_tty_open_handler(
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
debug!("tty: channels open: invalid negotiate params: {e}"); debug!("tty: channels open: invalid negotiate params: {e}");
let stream = match channel_conn.accept_bi().await {
Ok(s) => s,
Err(e) => {
debug!("tty: channels open: accept_bi failed: {e}");
return;
}
};
let (_, mut client_write) = tokio::io::split(stream);
crate::adapter::send_negotiation_error(
NegotiationWriter::new(&mut client_write),
"malformed_negotiation",
&[("message", &e.to_string())],
)
.await;
return; return;
} }
}; };
@@ -250,6 +279,91 @@ mod tests {
use std::collections::HashMap as StdHashMap; use std::collections::HashMap as StdHashMap;
use tokio::io::duplex; use tokio::io::duplex;
/// The R4 unit-testable seam: feed `make_tty_open_handler` a
/// hand-built `input` the (partial) schema would accept but the full
/// `NegotiateRequest` parse rejects (`cwd` typed as a number). The
/// handler must write a `0x00`-prefixed `malformed_negotiation` error
/// frame on the channel stream — the same failure class as the
/// post-open semantic failures, not a silent teardown. The read side
/// is the consumer's exact post-open sequence (`from_halves_raw`):
/// peek the first byte, see `0x00`, read the length-prefixed error
/// frame.
#[tokio::test]
async fn open_handler_writes_error_frame_on_schema_bypassing_input() {
use alkcall::core::types::{BiStream, BidiStreamSource, StreamError};
use tokio::io::AsyncReadExt;
struct YieldOnce {
stream: tokio::sync::Mutex<Option<BiStream>>,
}
#[async_trait::async_trait]
impl BidiStreamSource for YieldOnce {
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
self.stream
.lock()
.await
.take()
.ok_or(StreamError::ConnectionClosed)
}
async fn open_bi(&self) -> Result<BiStream, StreamError> {
Err(StreamError::StreamClosed)
}
fn remote_addr(&self) -> Option<std::net::SocketAddr> {
None
}
fn close(&self, _code: u32, _reason: &str) {
// The take-on-drop of the wrapped stream is not needed
// for this test; the connection is never closed.
}
}
let (client_end, server_end) = duplex(64 * 1024);
let (server_read, server_write) = tokio::io::split(server_end);
let bidi = BiStream::from_joined(server_read, server_write);
let channel_conn = Connection::from_source(
YieldOnce {
stream: tokio::sync::Mutex::new(Some(bidi)),
},
TTY_ALPN.as_bytes().to_vec(),
);
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0)));
let handler = make_tty_open_handler(Arc::new(backends), None, None);
let task = handler(
serde_json::json!({
"carriage": "raw",
"backend": "mock",
"cmd": ["true"],
"cwd": 42
}),
channel_conn,
AuthContext::anonymous(b"test"),
);
task.await.expect("handler task");
let mut read = client_end;
let mut first = [0u8; 1];
tokio::time::timeout(
std::time::Duration::from_secs(5),
read.read_exact(&mut first),
)
.await
.expect("no byte from handler")
.expect("read first byte");
assert_eq!(first[0], 0x00, "error frame length prefix starts with 0x00");
let mut len_rest = [0u8; 3];
read.read_exact(&mut len_rest).await.expect("read len rest");
let len = u32::from_be_bytes([first[0], len_rest[0], len_rest[1], len_rest[2]]) as usize;
let mut body = vec![0u8; len];
read.read_exact(&mut body).await.expect("read error body");
let v: serde_json::Value = serde_json::from_slice(&body).expect("parse error frame");
assert_eq!(v["error"], "malformed_negotiation");
assert!(v["message"].as_str().is_some_and(|m| !m.is_empty()));
}
#[test] #[test]
fn op_tty_open_is_channels_tty_sub() { fn op_tty_open_is_channels_tty_sub() {
assert_eq!(OP_TTY_OPEN, "channels/tty/sub"); assert_eq!(OP_TTY_OPEN, "channels/tty/sub");
+11 -8
View File
@@ -196,11 +196,13 @@ impl TtySession {
/// ///
/// Failures before the channel opens (ACL denial, unknown op, /// Failures before the channel opens (ACL denial, unknown op,
/// channel cap, invalid params) surface as /// channel cap, invalid params) surface as
/// [`TtySessionError::ChannelsOpen`]. Post-open failures (unknown /// [`TtySessionError::ChannelsOpen`]. Post-open failures (a
/// backend, allocate failure, ownership denial) arrive as a /// `NegotiateRequest` parse failure of a schema-valid-but-unparseable
/// negotiation error frame on the channel stream — the session /// params value, unknown backend, allocate failure, ownership
/// surfaces those as [`TtySessionError::NegotiationRejected`] via /// denial) arrive as a negotiation error frame on the channel
/// the same `0x00` disambiguation read the direct path uses. /// stream — the session surfaces those as
/// [`TtySessionError::NegotiationRejected`] via the same `0x00`
/// disambiguation read the direct path uses.
pub async fn open_via_channels( pub async fn open_via_channels(
client: &ChannelClient, client: &ChannelClient,
params: serde_json::Value, params: serde_json::Value,
@@ -292,9 +294,10 @@ impl TtySession {
/// Core inner for the channels path (ADR-009): the negotiation /// Core inner for the channels path (ADR-009): the negotiation
/// already happened in the open op — the stream is already in /// already happened in the open op — the stream is already in
/// raw-chunk mode. The peek still applies: the producer sends a /// raw-chunk mode. The peek still applies: the producer sends a
/// `0x00`-prefixed error frame on post-open failures (unknown /// `0x00`-prefixed error frame on any post-open failure (a
/// backend, allocate failure, ownership denial), and a raw chunk /// `NegotiateRequest` parse failure of the open op's `input`,
/// (`stream_type` in `{1, 2, 4}`) on success. /// 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> async fn from_halves_raw<R, W>(read: R, write: W) -> Result<Self, TtySessionError>
where where
R: AsyncRead + Send + Unpin + 'static, R: AsyncRead + Send + Unpin + 'static,
+60
View File
@@ -206,4 +206,64 @@ mod tests {
assert_eq!(first[0], crate::wire::STREAM_STDOUT); assert_eq!(first[0], crate::wire::STREAM_STDOUT);
drop(send); drop(send);
} }
/// End-to-end (R4): `input` that passes the registry's (partial)
/// schema but fails the handler's full `NegotiateRequest` parse —
/// `cwd` typed as a number. The open op succeeds (the schema is
/// deliberately partial), the producer-side handler writes a
/// `0x00`-prefixed `malformed_negotiation` error frame on the
/// channel stream, and the consumer-side post-open read sequence
/// (`from_halves_raw`'s peek + error-frame parse) observes it — the
/// same failure class as the post-open semantic failures, not a
/// silent teardown. Uses `ChannelClient::open_channel` directly
/// (the consumer's local fail-fast parse in `open_via_channels`
/// would reject these params before the open op runs — R5's
/// fail-fast path).
#[tokio::test]
async fn schema_valid_but_unparseable_input_gets_malformed_negotiation_frame() {
use tokio::io::AsyncReadExt;
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0)));
let client =
wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await;
let (_channel_id, _send, mut recv) = tokio::time::timeout(
std::time::Duration::from_secs(5),
client.open_channel(
OP_TTY_OPEN,
serde_json::json!({
"carriage": "raw",
"backend": "mock",
"cmd": ["true"],
"cwd": 42
}),
crate::channels::TTY_ALPN,
),
)
.await
.expect("open_channel timed out")
.expect("open_channel — the partial schema accepts cwd: 42");
// The consumer-side post-open sequence (`from_halves_raw`):
// peek the first byte; `0x00` = negotiation error frame.
let mut first = [0u8; 1];
tokio::time::timeout(
std::time::Duration::from_secs(5),
recv.read_exact(&mut first),
)
.await
.expect("no response byte from producer")
.expect("read first byte");
assert_eq!(first[0], 0x00, "error frame length prefix starts with 0x00");
let mut len_rest = [0u8; 3];
recv.read_exact(&mut len_rest).await.expect("read len rest");
let len = u32::from_be_bytes([first[0], len_rest[0], len_rest[1], len_rest[2]]) as usize;
let mut body = vec![0u8; len];
recv.read_exact(&mut body).await.expect("read error body");
let v: serde_json::Value = serde_json::from_slice(&body).expect("parse error frame");
assert_eq!(v["error"], "malformed_negotiation");
assert!(v["message"].as_str().is_some_and(|m| !m.is_empty()));
}
} }