refactor(tty): split control channel into STREAM_CTRL_IN/STREAM_CTRL_OUT halves (Phase 7)

The single STREAM_CONTROL = 3 was documented as bidirectional but the
adapter had to ignore Exit from the client because the two directions
were indistinguishable on the same stream_type — half-duplex in
disguise. Phase 7 splits it into two halves so the bidirectionality is
literal on the wire.

Changes:
- wire.rs: STREAM_CTRL_IN = 3 (client→server), STREAM_CTRL_OUT = 4
  (server→client); InvalidStreamType bound > 3 → > 4; Chunk::control
  → Chunk::ctrl_in/ctrl_out; ChunkWriter::write_control_json →
  write_ctrl_in_json/write_ctrl_out_json; tests split accordingly.
- control.rs: ControlMessage doc updated with the stream_type column;
  JSON shape unchanged.
- adapter.rs: pump_client_to_backend dispatches on STREAM_CTRL_IN
  (Resize/Signal/Eof; Exit on ctrl_in is a protocol violation,
  ignored); send_exit_chunk emits on STREAM_CTRL_OUT; STREAM_CTRL_OUT
  from the client is a protocol violation, ignored. 3 new tests for
  the direction enforcement; existing tests updated to the new
  stream_types.
- negotiation.rs: framing-disambiguation doc updated (server-sent
  stream_type set is {1, 2, 4}).
- alknet-tty-local/tests: common/mod.rs, pty.rs, pipe.rs updated to
  the new constants.

Specs:
- ADR-052 amended (§4a 'Control channel split (Phase 7 amendment)').
- tty-wire.md + tty-adapter.md updated (last_updated 2026-07-18).

Verification:
- cargo test -p alknet-tty: 65 passed (was 61; +4 new tests).
- cargo test -p alknet-tty-local: 19 passed.
- cargo test --workspace --all-features: 1017 passed, 0 failed.
- cargo clippy --workspace --all-features: clean.
- cargo fmt --all: clean.
This commit is contained in:
glm-5.2 committed 2026-07-18 17:02:59 +00:00
1 parent 859ad35896
commit c2b7055a64
11 files changed
+571 -142

No files matched your search

+9 -6
View File
@@ -21,7 +21,9 @@ use std::sync::Arc;
use alknet_core::auth::Identity; use alknet_core::auth::Identity;
use alknet_tty::adapter::drive_session; use alknet_tty::adapter::drive_session;
use alknet_tty::backend::TtyBackend; use alknet_tty::backend::TtyBackend;
use alknet_tty::wire::{ChunkReader, STREAM_CONTROL, STREAM_STDERR, STREAM_STDOUT}; use alknet_tty::wire::{
ChunkReader, STREAM_CTRL_IN, STREAM_CTRL_OUT, STREAM_STDERR, STREAM_STDOUT,
};
use bytes::Bytes; use bytes::Bytes;
use tokio::io::duplex; use tokio::io::duplex;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -70,10 +72,11 @@ impl ClientSide {
self.write.flush().await.unwrap(); self.write.flush().await.unwrap();
} }
/// Write a control chunk (stream_type 3) carrying a serialized /// Write a client→server control chunk (`STREAM_CTRL_IN`, stream_type
/// `ControlMessage` JSON payload. /// 3) carrying a serialized `ControlMessage` JSON payload (`Resize`,
/// `Signal`, or `Eof`).
pub async fn write_control(&mut self, json: &[u8]) { pub async fn write_control(&mut self, json: &[u8]) {
self.write_chunk(STREAM_CONTROL, json).await; self.write_chunk(STREAM_CTRL_IN, json).await;
} }
/// Read one raw chunk from the server. Returns the `stream_type` /// Read one raw chunk from the server. Returns the `stream_type`
@@ -145,7 +148,7 @@ impl ClientSide {
stderr.extend_from_slice(&bytes); stderr.extend_from_slice(&bytes);
} }
} }
STREAM_CONTROL => { STREAM_CTRL_OUT => {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
if v["type"] == "exit" { if v["type"] == "exit" {
return Some((stdout, stderr, v["code"].as_i64().unwrap() as i32)); return Some((stdout, stderr, v["code"].as_i64().unwrap() as i32));
@@ -177,7 +180,7 @@ impl ClientSide {
stderr.extend_from_slice(&bytes); stderr.extend_from_slice(&bytes);
} }
} }
STREAM_CONTROL => { STREAM_CTRL_OUT => {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
if v["type"] == "exit" { if v["type"] == "exit" {
return Some((stdout, stderr, v["code"].as_i64().unwrap() as i32)); return Some((stdout, stderr, v["code"].as_i64().unwrap() as i32));
+1 -1
View File
@@ -199,7 +199,7 @@ async fn pipe_echo_emits_stdout_chunk_then_sentinel() {
if st == STREAM_STDOUT && !bytes.is_empty() { if st == STREAM_STDOUT && !bytes.is_empty() {
saw_nonempty_stdout = true; saw_nonempty_stdout = true;
} }
if st == alknet_tty::wire::STREAM_CONTROL { if st == alknet_tty::wire::STREAM_CTRL_OUT {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
if v["type"] == "exit" { if v["type"] == "exit" {
break; break;
+2 -2
View File
@@ -17,7 +17,7 @@ mod common;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use alknet_tty::wire::{STREAM_CONTROL, STREAM_STDIN}; use alknet_tty::wire::{STREAM_CTRL_OUT, STREAM_STDIN};
use alknet_tty_local::LocalTtyBackend; use alknet_tty_local::LocalTtyBackend;
use common::{negotiate_pty_json, spawn_session}; use common::{negotiate_pty_json, spawn_session};
@@ -245,7 +245,7 @@ async fn pty_exit_chunk_is_last() {
if saw_exit { if saw_exit {
panic!("chunk arrived after exit: stream_type={st}, bytes={bytes:?} (ADR-055)"); panic!("chunk arrived after exit: stream_type={st}, bytes={bytes:?} (ADR-055)");
} }
if st == STREAM_CONTROL { if st == STREAM_CTRL_OUT {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
if v["type"] == "exit" { if v["type"] == "exit" {
assert_eq!(v["code"], 0); assert_eq!(v["code"], 0);
+147 -23
View File
@@ -22,19 +22,34 @@
//! (stream_type 2) when `TtyHandle.stderr` is `Some`. On backend stdout //! (stream_type 2) when `TtyHandle.stderr` is `Some`. On backend stdout
//! EOF, emit a zero-length stdout sentinel. //! EOF, emit a zero-length stdout sentinel.
//! - **B. client → backend**: stdin chunks (stream_type 0) → //! - **B. client → backend**: stdin chunks (stream_type 0) →
//! `TtyHandle.stdin`; control chunks (stream_type 3) → //! `TtyHandle.stdin`; client→server control chunks (stream_type 3,
//! `ControlMessage` dispatch (`Resize`, `Signal`, `Eof`; `Exit` is //! `STREAM_CTRL_IN`) → `ControlMessage` dispatch (`Resize`, `Signal`,
//! server→client only and ignored). Zero-length stdin chunk or //! `Eof`). `STREAM_CTRL_OUT` (stream_type 4) from the client is a
//! read-half close → EOF to backend stdin. //! protocol violation (it's the server→client half) and is ignored;
//! `Exit` arriving on `STREAM_CTRL_IN` is likewise a protocol
//! violation and ignored. Zero-length stdin chunk or read-half close
//! → EOF to backend stdin.
//! - **C. exit → exit chunk**: await `TtyHandle.exit_code`; on resolve, //! - **C. exit → exit chunk**: await `TtyHandle.exit_code`; on resolve,
//! enqueue `{"type":"exit","code":N}` as a control chunk (stream_type //! enqueue `{"type":"exit","code":N}` as a server→client control
//! 3). On `TtyError` → `{"type":"exit","code":-1}`. //! chunk (stream_type 4, `STREAM_CTRL_OUT`). On `TtyError` →
//! `{"type":"exit","code":-1}`.
//! //!
//! The adapter enforces the **exit-chunk-is-last** invariant (ADR-055): //! The adapter enforces the **exit-chunk-is-last** invariant (ADR-055):
//! it waits for BOTH the stdout/stderr pumps to complete AND `exit_code` //! it waits for BOTH the stdout/stderr pumps to complete AND `exit_code`
//! to resolve before enqueueing the exit chunk. A drainer task writes //! to resolve before enqueueing the exit chunk. A drainer task writes
//! chunks to the client in arrival order; the exit chunk is last. //! chunks to the client in arrival order; the exit chunk is last.
//! //!
//! # Bidirectional control channel (Phase 7)
//!
//! The control channel is split into two halves so it is genuinely
//! bidirectional on the wire: `STREAM_CTRL_IN = 3` carries client→server
//! control (`Resize`, `Signal`, `Eof`); `STREAM_CTRL_OUT = 4` carries
//! server→client control (`Exit`). The previous single `STREAM_CONTROL =
//! 3` was documented as "bidirectional" but the adapter ignored `Exit`
//! from the client because the two directions were indistinguishable on
//! the same stream_type. The split makes the bidirectionality explicit
//! — see `docs/research/alknet-crate-extraction/findings.md` Phase 7.
//!
//! # Cancel cleanup (ADR-056) //! # Cancel cleanup (ADR-056)
//! //!
//! On connection drop or stream reset, the pump tasks are dropped, which //! On connection drop or stream reset, the pump tasks are dropped, which
@@ -63,7 +78,7 @@ use crate::control::ControlMessage;
use crate::negotiation::{ use crate::negotiation::{
error_response_bytes, NegotiateRequest, NegotiationError, NegotiationReader, NegotiationWriter, error_response_bytes, NegotiateRequest, NegotiationError, NegotiationReader, NegotiationWriter,
}; };
use crate::wire::{Chunk, ChunkReader, ChunkWriter, RawError, STREAM_CONTROL, STREAM_STDIN}; use crate::wire::{Chunk, ChunkReader, ChunkWriter, RawError, STREAM_CTRL_IN, STREAM_STDIN};
/// The scope required to open a `alknet/tty` session (ADR-050). A two-way-door /// The scope required to open a `alknet/tty` session (ADR-050). A two-way-door
/// choice (reversible: a deployment-configured scope, not a wire-format /// choice (reversible: a deployment-configured scope, not a wire-format
@@ -378,7 +393,7 @@ async fn send_exit_chunk(writer_tx: &mpsc::Sender<Chunk>, code: i32) {
let exit_msg = ControlMessage::Exit { code }; let exit_msg = ControlMessage::Exit { code };
match exit_msg.to_json() { match exit_msg.to_json() {
Ok(json) => { Ok(json) => {
let chunk = Chunk::control(json); let chunk = Chunk::ctrl_out(json);
if writer_tx.send(chunk).await.is_err() { if writer_tx.send(chunk).await.is_err() {
debug!("tty: writer channel closed before exit chunk"); debug!("tty: writer channel closed before exit chunk");
} }
@@ -423,9 +438,25 @@ async fn pump_stderr(
debug!("tty: stderr pump done"); debug!("tty: stderr pump done");
} }
/// Pump client chunks → backend: stdin chunks → `TtyHandle.stdin`, control /// Pump client chunks → backend: stdin chunks → `TtyHandle.stdin`,
/// chunks → `ControlMessage` dispatch. On client read-half close or a /// client→server control chunks (`STREAM_CTRL_IN`, stream_type 3) →
/// zero-length stdin chunk, signal EOF to the backend's stdin. /// `ControlMessage` dispatch. On client read-half close or a zero-length
/// stdin chunk, signal EOF to the backend's stdin.
///
/// # Direction enforcement (Phase 7)
///
/// The control channel is split into two halves. This pump reads from
/// the client, so it dispatches only `STREAM_CTRL_IN` (client→server):
///
/// - `Resize` / `Signal` / `Eof` → forward to the backend's control
/// handle (`TtyControlHandle::resize` / `signal` / `stdin.shutdown`).
/// - `Exit` arriving on `STREAM_CTRL_IN` is a protocol violation
/// (`Exit` is server→client only, belongs on `STREAM_CTRL_OUT`); the
/// adapter ignores it. (The previous single `STREAM_CONTROL = 3`
/// couldn't distinguish the two directions, so `Exit` from the client
/// was always ignored — the split makes the rejection explicit.)
/// - `STREAM_CTRL_OUT` (stream_type 4) from the client is a protocol
/// violation (it's the server→client half); the adapter ignores it.
async fn pump_client_to_backend<R>( async fn pump_client_to_backend<R>(
client_read: R, client_read: R,
mut stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>, mut stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
@@ -446,7 +477,7 @@ async fn pump_client_to_backend<R>(
break; break;
} }
} }
STREAM_CONTROL => match ControlMessage::from_slice(&chunk.bytes) { STREAM_CTRL_IN => match ControlMessage::from_slice(&chunk.bytes) {
Ok(ControlMessage::Resize { Ok(ControlMessage::Resize {
cols, cols,
rows, rows,
@@ -467,12 +498,21 @@ async fn pump_client_to_backend<R>(
debug!("tty: client stdin EOF (eof control)"); debug!("tty: client stdin EOF (eof control)");
} }
Ok(ControlMessage::Exit { .. }) => { Ok(ControlMessage::Exit { .. }) => {
debug!("tty: ignoring Exit control from client (server→client only)"); debug!(
"tty: ignoring Exit control on STREAM_CTRL_IN \
(server→client only; belongs on STREAM_CTRL_OUT)"
);
} }
Err(e) => { Err(e) => {
debug!("tty: ignoring unknown control type: {e}"); debug!("tty: ignoring unknown control type: {e}");
} }
}, },
crate::wire::STREAM_CTRL_OUT => {
debug!(
"tty: ignoring STREAM_CTRL_OUT (stream_type 4) from client \
(server→client half; client should not write on it)"
);
}
other => { other => {
debug!("tty: ignoring stream_type {other} from client"); debug!("tty: ignoring stream_type {other} from client");
} }
@@ -868,7 +908,7 @@ mod tests {
assert!(bytes.is_empty()); assert!(bytes.is_empty());
let (st, bytes) = client.read_chunk().await; let (st, bytes) = client.read_chunk().await;
assert_eq!(st, crate::wire::STREAM_CONTROL); assert_eq!(st, crate::wire::STREAM_CTRL_OUT);
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["type"], "exit"); assert_eq!(v["type"], "exit");
assert_eq!(v["code"], 0); assert_eq!(v["code"], 0);
@@ -899,7 +939,7 @@ mod tests {
loop { loop {
let (st, bytes) = client.read_chunk().await; let (st, bytes) = client.read_chunk().await;
if st == crate::wire::STREAM_CONTROL { if st == crate::wire::STREAM_CTRL_OUT {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
if v["type"] == "exit" { if v["type"] == "exit" {
assert_eq!(v["code"], 7); assert_eq!(v["code"], 7);
@@ -959,13 +999,13 @@ mod tests {
client client
.write_chunk( .write_chunk(
crate::wire::STREAM_CONTROL, crate::wire::STREAM_CTRL_IN,
br#"{"type":"resize","cols":100,"rows":50}"#, br#"{"type":"resize","cols":100,"rows":50}"#,
) )
.await; .await;
client client
.write_chunk( .write_chunk(
crate::wire::STREAM_CONTROL, crate::wire::STREAM_CTRL_IN,
br#"{"type":"signal","name":"INT"}"#, br#"{"type":"signal","name":"INT"}"#,
) )
.await; .await;
@@ -1003,7 +1043,7 @@ mod tests {
client.write_negotiation(TEST_NEG).await; client.write_negotiation(TEST_NEG).await;
client client
.write_chunk(crate::wire::STREAM_CONTROL, br#"{"type":"unknown"}"#) .write_chunk(crate::wire::STREAM_CTRL_IN, br#"{"type":"unknown"}"#)
.await; .await;
let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx"); let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx");
@@ -1025,6 +1065,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn exit_control_from_client_ignored() { async fn exit_control_from_client_ignored() {
// `Exit` is server→client only (belongs on `STREAM_CTRL_OUT`).
// Sending it on `STREAM_CTRL_IN` (client→server) is a protocol
// violation; the adapter ignores it and keeps pumping stdout.
let (backend, _control, _cancel) = TestBackend::builder().build(); let (backend, _control, _cancel) = TestBackend::builder().build();
let backends = make_backends(backend.clone()); let backends = make_backends(backend.clone());
let (mut client, server) = make_client_and_server(); let (mut client, server) = make_client_and_server();
@@ -1037,7 +1080,7 @@ mod tests {
client.write_negotiation(TEST_NEG).await; client.write_negotiation(TEST_NEG).await;
client client
.write_chunk(crate::wire::STREAM_CONTROL, br#"{"type":"exit","code":99}"#) .write_chunk(crate::wire::STREAM_CTRL_IN, br#"{"type":"exit","code":99}"#)
.await; .await;
let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx"); let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx");
@@ -1057,6 +1100,87 @@ mod tests {
let _ = session.await; let _ = session.await;
} }
#[tokio::test]
async fn ctrl_out_from_client_ignored() {
// `STREAM_CTRL_OUT` (stream_type 4) is the server→client half.
// The client writing on it is a protocol violation; the adapter
// ignores the chunk and keeps pumping stdout (Phase 7).
let (backend, _control, _cancel) = TestBackend::builder().build();
let backends = make_backends(backend.clone());
let (mut client, server) = make_client_and_server();
let identity = identity_with_scope(TTY_OPEN_SCOPE);
let session = tokio::spawn(async move {
drive_session_server(server, backends, None, identity).await;
});
client.write_negotiation(TEST_NEG).await;
// Bogus: a client writing on the server→client control half.
client
.write_chunk(
crate::wire::STREAM_CTRL_OUT,
br#"{"type":"exit","code":99}"#,
)
.await;
let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx");
let _ = backend.take_stderr_tx().await;
stdout_tx
.send(Bytes::from_static(b"after-bogus-ctrl-out"))
.await
.unwrap();
drop(stdout_tx);
let exit_tx = backend.take_exit_tx().await.expect("exit tx");
exit_tx.send(Ok(0)).unwrap();
let (st, bytes) = client.read_chunk().await;
assert_eq!(st, STREAM_STDOUT);
assert_eq!(bytes.as_ref(), b"after-bogus-ctrl-out");
let _ = session.await;
}
#[tokio::test]
async fn exit_chunk_arrives_on_ctrl_out_not_ctrl_in() {
// Verifies the adapter emits `Exit` on `STREAM_CTRL_OUT` (4), not
// `STREAM_CTRL_IN` (3) — the Phase 7 bidirectionality fix. A client
// distinguishing the two halves can route exit vs. control
// without parsing the JSON tag first.
let (backend, _control, _cancel) = TestBackend::builder().build();
let backends = make_backends(backend.clone());
let (mut client, server) = make_client_and_server();
let identity = identity_with_scope(TTY_OPEN_SCOPE);
let session = tokio::spawn(async move {
drive_session_server(server, backends, None, identity).await;
});
client.write_negotiation(TEST_NEG).await;
let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx");
let _ = backend.take_stderr_tx().await;
drop(stdout_tx);
let exit_tx = backend.take_exit_tx().await.expect("exit tx");
exit_tx.send(Ok(42)).unwrap();
let (st, bytes) = client.read_chunk().await;
assert_eq!(st, STREAM_STDOUT);
assert!(bytes.is_empty());
let (st, bytes) = client.read_chunk().await;
assert_eq!(
st,
crate::wire::STREAM_CTRL_OUT,
"exit chunk must arrive on STREAM_CTRL_OUT (4), not STREAM_CTRL_IN (3)"
);
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["type"], "exit");
assert_eq!(v["code"], 42);
let _ = session.await;
}
#[tokio::test] #[tokio::test]
async fn unknown_backend_error() { async fn unknown_backend_error() {
let (backend, _control, _cancel) = TestBackend::builder().build(); let (backend, _control, _cancel) = TestBackend::builder().build();
@@ -1184,7 +1308,7 @@ mod tests {
loop { loop {
let (st, bytes) = client.read_chunk().await; let (st, bytes) = client.read_chunk().await;
if st == crate::wire::STREAM_CONTROL { if st == crate::wire::STREAM_CTRL_OUT {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["type"], "exit"); assert_eq!(v["type"], "exit");
assert_eq!(v["code"], -1); assert_eq!(v["code"], -1);
@@ -1294,7 +1418,7 @@ mod tests {
loop { loop {
let (st, bytes) = client.read_chunk().await; let (st, bytes) = client.read_chunk().await;
if st == crate::wire::STREAM_CONTROL { if st == crate::wire::STREAM_CTRL_OUT {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["type"], "exit"); assert_eq!(v["type"], "exit");
assert_eq!(v["code"], 0); assert_eq!(v["code"], 0);
@@ -1346,7 +1470,7 @@ mod tests {
assert_eq!(bytes.as_ref(), b"err"); assert_eq!(bytes.as_ref(), b"err");
saw_stderr = true; saw_stderr = true;
} }
crate::wire::STREAM_CONTROL => { crate::wire::STREAM_CTRL_OUT => {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["type"], "exit"); assert_eq!(v["type"], "exit");
saw_exit = true; saw_exit = true;
@@ -1379,7 +1503,7 @@ mod tests {
client.write_chunk(STREAM_STDIN, b"first").await; client.write_chunk(STREAM_STDIN, b"first").await;
client client
.write_chunk(crate::wire::STREAM_CONTROL, br#"{"type":"eof"}"#) .write_chunk(crate::wire::STREAM_CTRL_IN, br#"{"type":"eof"}"#)
.await; .await;
let mut received = Vec::new(); let mut received = Vec::new();
+41 -20
View File
@@ -1,4 +1,14 @@
//! Control messages carried in `stream_type 3` chunks (ADR-052). //! Control messages carried in `stream_type 3` (`ctrl_in`) and
//! `stream_type 4` (`ctrl_out`) chunks (ADR-052, amended Phase 7).
//!
//! The control channel is split into two halves so it is genuinely
//! bidirectional on the wire: `STREAM_CTRL_IN = 3` carries client→server
//! control (`Resize`, `Signal`, `Eof`); `STREAM_CTRL_OUT = 4` carries
//! server→client control (`Exit`). The previous single
//! `STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter
//! ignored `Exit` from the client because it had no way to distinguish
//! the two directions on the same stream_type — see
//! `docs/research/alknet-crate-extraction/findings.md` Phase 7.
//! //!
//! Control chunks carry a JSON payload tagged by `type`. The schema is the //! Control chunks carry a JSON payload tagged by `type`. The schema is the
//! POC's `ControlMessage` (`/workspace/alknet-tty-poc/src/control.rs`): //! POC's `ControlMessage` (`/workspace/alknet-tty-poc/src/control.rs`):
@@ -23,20 +33,30 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// A control message riding on `stream_type 3`. /// A control message riding on `STREAM_CTRL_IN` (stream_type 3,
/// client→server) or `STREAM_CTRL_OUT` (stream_type 4, server→client).
/// ///
/// Direction and mapping (per `tty-wire.md` §"Control Channel"): /// Direction and mapping (per `tty-wire.md` §"Control Channel"):
/// ///
/// | direction | variant | maps to | /// | direction | stream_type | variant | maps to |
/// |----------------|----------|----------------------------------------------------| /// |----------------|-----------------|----------|----------------------------------------------------|
/// | client→server | `Resize` | SSH `window-change`, docker exec resize, `ioctl` | /// | client→server | `STREAM_CTRL_IN` (3) | `Resize` | SSH `window-change`, docker exec resize, `ioctl` |
/// | client→server | `Signal` | SSH `signal`, docker exec signal, `kill(-pgid, n)` | /// | client→server | `STREAM_CTRL_IN` (3) | `Signal` | SSH `signal`, docker exec signal, `kill(-pgid, n)` |
/// | client→server | `Eof` | SSH channel EOF, docker stdin close, `ChildStdin` | /// | client→server | `STREAM_CTRL_IN` (3) | `Eof` | SSH channel EOF, docker stdin close, `ChildStdin` |
/// | server→client | `Exit` | the completion signal (ADR-055) | /// | server→client | `STREAM_CTRL_OUT` (4) | `Exit` | the completion signal (ADR-055) |
///
/// The direction is enforced by the adapter, not by this enum: a `Resize`
/// arriving on `STREAM_CTRL_OUT` is a protocol violation (the adapter
/// ignores it), and an `Exit` arriving on `STREAM_CTRL_IN` is likewise a
/// protocol violation (the adapter ignores it). The split is what makes
/// the control channel genuinely bidirectional — the previous single
/// `STREAM_CONTROL = 3` was documented as "bidirectional" but the
/// adapter had to ignore `Exit` from the client because the two
/// directions were indistinguishable on the same stream_type.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ControlMessage { pub enum ControlMessage {
/// Terminal window resize (client→server). /// Terminal window resize (client→server, `STREAM_CTRL_IN`).
/// ///
/// `pixel_width`/`pixel_height` default to 0 (most terminals don't /// `pixel_width`/`pixel_height` default to 0 (most terminals don't
/// report pixel dimensions; SSH's `pty_request` carries them for /// report pixel dimensions; SSH's `pty_request` carries them for
@@ -49,23 +69,24 @@ pub enum ControlMessage {
#[serde(default)] #[serde(default)]
pixel_height: u16, pixel_height: u16,
}, },
/// Forward a signal to the child process group (client→server). /// Forward a signal to the child process group (client→server,
/// `STREAM_CTRL_IN`).
/// ///
/// `name` is an uppercase string from the supported set (see /// `name` is an uppercase string from the supported set (see
/// [`signal_from_name`]). Unknown names fall back to the backend's /// [`signal_from_name`]). Unknown names fall back to the backend's
/// default kill in the adapter (tty-local.md REQ-TTY-02). /// default kill in the adapter (tty-local.md REQ-TTY-02).
Signal { name: String }, Signal { name: String },
/// Client stdin is done (client→server). The server closes the /// Client stdin is done (client→server, `STREAM_CTRL_IN`). The
/// backend's stdin (`ChildStdin::drop` / PTY writer close) but keeps /// server closes the backend's stdin (`ChildStdin::drop` / PTY writer
/// pumping stdout + the exit chunk. See `tty-wire.md` §"Stdin /// close) but keeps pumping stdout + the exit chunk. See
/// Closure". /// `tty-wire.md` §"Stdin Closure".
Eof, Eof,
/// Process exit code (server→client). The exit chunk is the last /// Process exit code (server→client, `STREAM_CTRL_OUT`). The exit
/// control chunk before stream close (ADR-055). `code` is `i32` /// chunk is the last control chunk before stream close (ADR-055).
/// matching `std::process::ExitStatus::code()`; negative values are /// `code` is `i32` matching `std::process::ExitStatus::code()`;
/// signal-terminated (e.g., `-9` for SIGKILL on Unix). `-1` is the /// negative values are signal-terminated (e.g., `-9` for SIGKILL on
/// adapter's best-effort "backend could not determine the exit code" /// Unix). `-1` is the adapter's best-effort "backend could not
/// sentinel (ADR-055 §4). /// determine the exit code" sentinel (ADR-055 §4).
Exit { code: i32 }, Exit { code: i32 },
} }
+4 -2
View File
@@ -21,8 +21,10 @@
//! - An error frame's 4-byte big-endian length prefix starts with `0x00` //! - An error frame's 4-byte big-endian length prefix starts with `0x00`
//! because error frames MUST be under 16 MiB ([`MAX_CHUNK_LEN`]) so the //! because error frames MUST be under 16 MiB ([`MAX_CHUNK_LEN`]) so the
//! high byte is zero (a wire-format invariant, not an assumption). //! high byte is zero (a wire-format invariant, not an assumption).
//! - A raw chunk's first byte is a `stream_type` in `{1, 2, 3}` — //! - A raw chunk's first byte is a `stream_type`. The server never sends
//! `0` (stdin from server) is invalid, so `0x00` is unambiguous. //! `0` (stdin — client→server only) or `3` (`STREAM_CTRL_IN` —
//! client→server only), so the server-sent set is `{1, 2, 4}`
//! (stdout, stderr, `STREAM_CTRL_OUT`); `0x00` is unambiguous.
//! //!
//! See ADR-052 §5 and `tty-wire.md` §"Constraints". //! See ADR-052 §5 and `tty-wire.md` §"Constraints".
+84 -27
View File
@@ -7,10 +7,20 @@
//! ``` //! ```
//! //!
//! `stream_type`: //! `stream_type`:
//! - 0 = stdin (client→server, raw bytes) //! - 0 = stdin (client→server, raw bytes)
//! - 1 = stdout (server→client, raw bytes) //! - 1 = stdout (server→client, raw bytes)
//! - 2 = stderr (server→client, raw bytes) //! - 2 = stderr (server→client, raw bytes)
//! - 3 = control (bidirectional, JSON control message — see [`crate::control`]) //! - 3 = ctrl_in (client→server, JSON control message — see [`crate::control`])
//! - 4 = ctrl_out (server→client, JSON control message — see [`crate::control`])
//!
//! The control channel is split into two halves so it is genuinely
//! bidirectional on the wire: `STREAM_CTRL_IN = 3` carries client→server
//! control (resize, signal, eof); `STREAM_CTRL_OUT = 4` carries
//! server→client control (exit). The previous single `STREAM_CONTROL = 3`
//! was documented as "bidirectional" but the adapter ignored `Exit` from
//! the client because it had no way to distinguish the two directions on
//! the same stream_type — see `docs/research/alknet-crate-extraction/
//! findings.md` Phase 7.
//! //!
//! Zero-length data chunks are sentinels: a zero-length stdin chunk is EOF //! Zero-length data chunks are sentinels: a zero-length stdin chunk is EOF
//! from the client; a zero-length stdout chunk is "drained" from the //! from the client; a zero-length stdout chunk is "drained" from the
@@ -29,8 +39,11 @@ pub const STREAM_STDIN: u8 = 0;
pub const STREAM_STDOUT: u8 = 1; pub const STREAM_STDOUT: u8 = 1;
/// stderr channel (server→client, raw bytes). /// stderr channel (server→client, raw bytes).
pub const STREAM_STDERR: u8 = 2; pub const STREAM_STDERR: u8 = 2;
/// control channel (bidirectional, JSON control message). /// Control channel, client→server half (JSON control message —
pub const STREAM_CONTROL: u8 = 3; /// `Resize`, `Signal`, `Eof`).
pub const STREAM_CTRL_IN: u8 = 3;
/// Control channel, server→client half (JSON control message — `Exit`).
pub const STREAM_CTRL_OUT: u8 = 4;
/// Chunk header length in bytes: 1 byte `stream_type` + 4 bytes `length`. /// Chunk header length in bytes: 1 byte `stream_type` + 4 bytes `length`.
pub const CHUNK_HEADER_LEN: usize = 5; pub const CHUNK_HEADER_LEN: usize = 5;
@@ -54,7 +67,7 @@ pub enum RawError {
/// The peer closed the stream cleanly (unexpected EOF on header or payload). /// The peer closed the stream cleanly (unexpected EOF on header or payload).
#[error("connection closed")] #[error("connection closed")]
ConnectionClosed, ConnectionClosed,
/// The chunk header's `stream_type` byte was > 3. /// The chunk header's `stream_type` byte was > 4.
#[error("invalid chunk header: stream type {0}")] #[error("invalid chunk header: stream type {0}")]
InvalidStreamType(u8), InvalidStreamType(u8),
/// The chunk payload length exceeded `MAX_CHUNK_LEN`. /// The chunk payload length exceeded `MAX_CHUNK_LEN`.
@@ -66,11 +79,12 @@ pub enum RawError {
/// payload bytes. /// payload bytes.
/// ///
/// Construct with [`Chunk::stdin`], [`Chunk::stdout`], [`Chunk::stderr`], /// Construct with [`Chunk::stdin`], [`Chunk::stdout`], [`Chunk::stderr`],
/// or [`Chunk::control`] for the four fixed channels. /// [`Chunk::ctrl_in`], or [`Chunk::ctrl_out`] for the five fixed
/// channels.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Chunk { pub struct Chunk {
/// The channel: one of [`STREAM_STDIN`], [`STREAM_STDOUT`], /// The channel: one of [`STREAM_STDIN`], [`STREAM_STDOUT`],
/// [`STREAM_STDERR`], [`STREAM_CONTROL`]. /// [`STREAM_STDERR`], [`STREAM_CTRL_IN`], [`STREAM_CTRL_OUT`].
pub stream_type: u8, pub stream_type: u8,
/// The payload bytes (raw for data channels, UTF-8 JSON for control). /// The payload bytes (raw for data channels, UTF-8 JSON for control).
pub bytes: bytes::Bytes, pub bytes: bytes::Bytes,
@@ -101,10 +115,19 @@ impl Chunk {
} }
} }
/// A control chunk (stream_type 3). /// A client→server control chunk (stream_type 3) — `Resize`, `Signal`,
pub fn control(bytes: bytes::Bytes) -> Self { /// or `Eof`.
pub fn ctrl_in(bytes: bytes::Bytes) -> Self {
Self { Self {
stream_type: STREAM_CONTROL, stream_type: STREAM_CTRL_IN,
bytes,
}
}
/// A server→client control chunk (stream_type 4) — `Exit`.
pub fn ctrl_out(bytes: bytes::Bytes) -> Self {
Self {
stream_type: STREAM_CTRL_OUT,
bytes, bytes,
} }
} }
@@ -113,7 +136,7 @@ impl Chunk {
/// Reads raw chunks from an [`AsyncRead`] transport. /// Reads raw chunks from an [`AsyncRead`] transport.
/// ///
/// [`ChunkReader::read_chunk`] reads the 5-byte header, validates the /// [`ChunkReader::read_chunk`] reads the 5-byte header, validates the
/// `stream_type` (≤ 3, else [`RawError::InvalidStreamType`]) and the /// `stream_type` (≤ 4, else [`RawError::InvalidStreamType`]) and the
/// payload length (≤ [`MAX_CHUNK_LEN`], else [`RawError::ChunkTooLarge`]), /// payload length (≤ [`MAX_CHUNK_LEN`], else [`RawError::ChunkTooLarge`]),
/// then reads the payload. On a clean `UnexpectedEof` reading either the /// then reads the payload. On a clean `UnexpectedEof` reading either the
/// header or the payload, it returns [`RawError::ConnectionClosed`] — the /// header or the payload, it returns [`RawError::ConnectionClosed`] — the
@@ -148,7 +171,7 @@ impl<R: AsyncRead + Unpin> ChunkReader<R> {
} }
let stream_type = self.header[0]; let stream_type = self.header[0];
if stream_type > 3 { if stream_type > 4 {
return Err(RawError::InvalidStreamType(stream_type)); return Err(RawError::InvalidStreamType(stream_type));
} }
@@ -183,9 +206,10 @@ impl<R: AsyncRead + Unpin> ChunkReader<R> {
/// Writes raw chunks to an [`AsyncWrite`] transport. /// Writes raw chunks to an [`AsyncWrite`] transport.
/// ///
/// [`ChunkWriter::write_chunk`] writes the 5-byte header then the payload /// [`ChunkWriter::write_chunk`] writes the 5-byte header then the payload
/// (if non-empty), then flushes. [`ChunkWriter::write_stdin`] and /// (if non-empty), then flushes. [`ChunkWriter::write_stdin`],
/// [`ChunkWriter::write_control_json`] are convenience helpers for the /// [`ChunkWriter::write_ctrl_in_json`], and
/// two most common write paths. /// [`ChunkWriter::write_ctrl_out_json`] are convenience helpers for the
/// most common write paths.
pub struct ChunkWriter<W: AsyncWrite + Unpin> { pub struct ChunkWriter<W: AsyncWrite + Unpin> {
writer: W, writer: W,
} }
@@ -229,10 +253,24 @@ impl<W: AsyncWrite + Unpin> ChunkWriter<W> {
Ok(()) Ok(())
} }
/// Write a control chunk (stream_type 3) carrying a JSON payload. /// Write a client→server control chunk (stream_type 3) carrying a JSON
pub async fn write_control_json(&mut self, json: &[u8]) -> Result<(), RawError> { /// payload (`Resize`, `Signal`, or `Eof`).
pub async fn write_ctrl_in_json(&mut self, json: &[u8]) -> Result<(), RawError> {
let mut header = [0u8; CHUNK_HEADER_LEN]; let mut header = [0u8; CHUNK_HEADER_LEN];
header[0] = STREAM_CONTROL; header[0] = STREAM_CTRL_IN;
let len = json.len() as u32;
header[1..].copy_from_slice(&len.to_be_bytes());
self.writer.write_all(&header).await?;
self.writer.write_all(json).await?;
self.writer.flush().await?;
Ok(())
}
/// Write a server→client control chunk (stream_type 4) carrying a JSON
/// payload (`Exit`).
pub async fn write_ctrl_out_json(&mut self, json: &[u8]) -> Result<(), RawError> {
let mut header = [0u8; CHUNK_HEADER_LEN];
header[0] = STREAM_CTRL_OUT;
let len = json.len() as u32; let len = json.len() as u32;
header[1..].copy_from_slice(&len.to_be_bytes()); header[1..].copy_from_slice(&len.to_be_bytes());
self.writer.write_all(&header).await?; self.writer.write_all(&header).await?;
@@ -281,8 +319,13 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn round_trip_control() { async fn round_trip_ctrl_in() {
round_trip(STREAM_CONTROL, br#"{"type":"eof"}"#).await; round_trip(STREAM_CTRL_IN, br#"{"type":"eof"}"#).await;
}
#[tokio::test]
async fn round_trip_ctrl_out() {
round_trip(STREAM_CTRL_OUT, br#"{"type":"exit","code":0}"#).await;
} }
#[tokio::test] #[tokio::test]
@@ -303,27 +346,41 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn round_trip_write_control_json_helper() { async fn round_trip_write_ctrl_in_json_helper() {
let (mut a, mut b) = duplex(8 * 1024); let (mut a, mut b) = duplex(8 * 1024);
let mut writer = ChunkWriter::new(&mut a); let mut writer = ChunkWriter::new(&mut a);
let mut reader = ChunkReader::new(&mut b); let mut reader = ChunkReader::new(&mut b);
let json = br#"{"type":"resize","cols":80,"rows":24}"#; let json = br#"{"type":"resize","cols":80,"rows":24}"#;
writer.write_control_json(json).await.unwrap(); writer.write_ctrl_in_json(json).await.unwrap();
let read = reader.read_chunk().await.unwrap(); let read = reader.read_chunk().await.unwrap();
assert_eq!(read.stream_type, STREAM_CONTROL); assert_eq!(read.stream_type, STREAM_CTRL_IN);
assert_eq!(read.bytes.as_ref(), json);
}
#[tokio::test]
async fn round_trip_write_ctrl_out_json_helper() {
let (mut a, mut b) = duplex(8 * 1024);
let mut writer = ChunkWriter::new(&mut a);
let mut reader = ChunkReader::new(&mut b);
let json = br#"{"type":"exit","code":0}"#;
writer.write_ctrl_out_json(json).await.unwrap();
let read = reader.read_chunk().await.unwrap();
assert_eq!(read.stream_type, STREAM_CTRL_OUT);
assert_eq!(read.bytes.as_ref(), json); assert_eq!(read.bytes.as_ref(), json);
} }
#[tokio::test] #[tokio::test]
async fn invalid_stream_type() { async fn invalid_stream_type() {
let (mut a, mut b) = duplex(8 * 1024); let (mut a, mut b) = duplex(8 * 1024);
a.write_all(&[4u8, 0, 0, 0, 0]).await.unwrap(); // 5 is one past the highest valid stream_type (4 = STREAM_CTRL_OUT).
a.write_all(&[5u8, 0, 0, 0, 0]).await.unwrap();
a.flush().await.unwrap(); a.flush().await.unwrap();
let mut reader = ChunkReader::new(&mut b); let mut reader = ChunkReader::new(&mut b);
let err = reader.read_chunk().await.unwrap_err(); let err = reader.read_chunk().await.unwrap_err();
assert!(matches!(err, RawError::InvalidStreamType(4))); assert!(matches!(err, RawError::InvalidStreamType(5)));
} }
#[tokio::test] #[tokio::test]
+52 -17
View File
@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-07-07 last_updated: 2026-07-18
--- ---
# alknet-tty — TtyAdapter and Session Lifecycle # alknet-tty — TtyAdapter and Session Lifecycle
@@ -100,14 +100,18 @@ A `alknet/tty` session on one bidi stream proceeds in three phases:
`Some`, a concurrent stderr pump emits stderr chunks (stream_type 2). `Some`, a concurrent stderr pump emits stderr chunks (stream_type 2).
On backend stdout EOF, emit a zero-length stdout sentinel. On backend stdout EOF, emit a zero-length stdout sentinel.
- **B. client → backend**: client chunks → backend. stdin chunks - **B. client → backend**: client chunks → backend. stdin chunks
(stream_type 0) → `TtyHandle.stdin` (via `AsyncWrite`). Control (stream_type 0) → `TtyHandle.stdin` (via `AsyncWrite`).
chunks (stream_type 3) → `ControlMessage` dispatch: `Resize` → Client→server control chunks (`STREAM_CTRL_IN`, stream_type 3) →
`TtyControl::resize`, `Signal` → `TtyControl::signal`, `Eof` → `ControlMessage` dispatch: `Resize` → `TtyControl::resize`, `Signal`
close stdin. `Exit` from the client is ignored (server→client only). → `TtyControl::signal`, `Eof` → close stdin. `Exit` on
`STREAM_CTRL_IN` is a protocol violation (it's server→client only)
and is ignored. `STREAM_CTRL_OUT` (stream_type 4) from the client is
a protocol violation (it's the server→client half) and is ignored.
On client read-half close or a zero-length stdin chunk, signal EOF On client read-half close or a zero-length stdin chunk, signal EOF
to the backend's stdin. to the backend's stdin.
- **C. exit → exit chunk**: await `TtyHandle.exit_code`; on resolve, - **C. exit → exit chunk**: await `TtyHandle.exit_code`; on resolve,
enqueue `{"type":"exit","code":N}` as a control chunk (stream_type 3). enqueue `{"type":"exit","code":N}` as a server→client control
chunk (`STREAM_CTRL_OUT`, stream_type 4).
A drainer task writes chunks to the client in arrival order. After the A drainer task writes chunks to the client in arrival order. After the
exit chunk is written (task C resolves and the exit chunk drains), exit chunk is written (task C resolves and the exit chunk drains),
@@ -118,6 +122,36 @@ hardcoded the local PTY backend; the adapter dispatches to any
`TtyBackend`. See `/workspace/alknet-tty-poc/src/session.rs` for the `TtyBackend`. See `/workspace/alknet-tty-poc/src/session.rs` for the
reference implementation of the three-pump driver. reference implementation of the three-pump driver.
### Bidirectional Control Channel (Phase 7)
The control channel is split into two halves so it is genuinely
bidirectional on the wire:
- **`STREAM_CTRL_IN = 3`** — client→server control (`Resize`, `Signal`,
`Eof`).
- **`STREAM_CTRL_OUT = 4`** — server→client control (`Exit`).
The adapter enforces the direction:
- An `Exit` arriving on `STREAM_CTRL_IN` is a protocol violation
(server→client message on the client→server half) — the adapter
ignores it (the previous single `STREAM_CONTROL = 3` could not
distinguish the two directions, so `Exit` from the client was always
ignored; the split makes the rejection explicit).
- A `Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is a protocol
violation (client→server message on the server→client half) — the
adapter ignores it (the server never dispatches control messages it
receives on the server→client half).
- `STREAM_CTRL_OUT` (stream_type 4) chunks written by the client are a
protocol violation (the client should not write on the server→client
half) — the adapter ignores them.
The exit chunk (`Exit`) is emitted on `STREAM_CTRL_OUT` (stream_type
4), not on the previous `STREAM_CONTROL = 3`. A client distinguishing
the two halves can route exit vs. control without parsing the JSON
`type` tag first. See `docs/research/alknet-crate-extraction/findings.md`
Phase 7 and `tty-wire.md` §"Control Channel".
### Negotiation Errors ### Negotiation Errors
If the server cannot allocate the session, it sends a JSON error response If the server cannot allocate the session, it sends a JSON error response
@@ -146,17 +180,18 @@ The disambiguation is by the first byte: a JSON error frame's 4-byte
big-endian length prefix always starts with `0x00` (error frames MUST big-endian length prefix always starts with `0x00` (error frames MUST
be under 16 MiB — `MAX_CHUNK_LEN` — so the high byte is zero; this is be under 16 MiB — `MAX_CHUNK_LEN` — so the high byte is zero; this is
a wire-format invariant, not an assumption), while a raw chunk's first a wire-format invariant, not an assumption), while a raw chunk's first
byte is a `stream_type` in `{0, 1, 2, 3}`. A stream_type of `0` (stdin byte is a `stream_type`. The server never sends `0` (stdin —
from server) is invalid — the server never sends stdin chunks — so the client→server only) or `3` (`STREAM_CTRL_IN` — client→server only), so
client distinguishes: read the first byte; if it is `0x00`, interpret the server-sent set is `{1, 2, 4}` (stdout, stderr, `STREAM_CTRL_OUT`);
the next 4 bytes as a big-endian length prefix and read that many bytes `0x00` is unambiguous. The client distinguishes: read the first byte; if
as a JSON error frame; otherwise interpret it as a `stream_type` byte it is `0x00`, interpret the next 4 bytes as a big-endian length prefix
and continue reading the raw chunk header. This is a one-way-door and read that many bytes as a JSON error frame; otherwise interpret it
wire-format invariant (ADR-052): error frames use the negotiation as a `stream_type` byte and continue reading the raw chunk header. This
framing (length prefix) and MUST be under 16 MiB; success uses the raw is a one-way-door wire-format invariant (ADR-052): error frames use the
chunk framing (stream_type byte first); the `0x00`-as-length-prefix vs negotiation framing (length prefix) and MUST be under 16 MiB; success
`0x00`-as-invalid-stream_type disambiguation is what makes the two uses the raw chunk framing (stream_type byte first); the
distinguishable on the wire. `0x00`-as-length-prefix vs `0x00`-as-invalid-stream_type disambiguation
is what makes the two distinguishable on the wire.
### Exit-Chunk Ordering (ADR-055) ### Exit-Chunk Ordering (ADR-055)
+78 -35
View File
@@ -1,13 +1,14 @@
--- ---
status: draft status: draft
last_updated: 2026-07-07 last_updated: 2026-07-18
--- ---
# alknet-tty — Wire Format # alknet-tty — Wire Format
The wire protocol for `alknet/tty`: the negotiation frame (JSON The wire protocol for `alknet/tty`: the negotiation frame (JSON
carriage), the raw chunk codec, the control channel, and the sentinels. carriage), the raw chunk codec, the control channel (split into
The two-carriage model is decided in `STREAM_CTRL_IN` / `STREAM_CTRL_OUT` halves — Phase 7), and the
sentinels. The two-carriage model is decided in
[ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md); [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md);
this document specifies what an implementer builds. this document specifies what an implementer builds.
@@ -164,19 +165,34 @@ no `call.responded`/`call.completed` — this is not the call protocol.
- **`stream_type`** (1 byte) — the channel: - **`stream_type`** (1 byte) — the channel:
| stream_type | channel | direction | payload | | stream_type | channel | direction | payload |
|---|---|---|---| |-------------|-------------|----------------|---------------------|
| 0 | data-in (stdin) | client→server | raw bytes | | 0 | data-in (stdin) | client→server | raw bytes |
| 1 | data-out (stdout) | server→client | raw bytes | | 1 | data-out (stdout) | server→client | raw bytes |
| 2 | data-err (stderr) | server→client | raw bytes | | 2 | data-err (stderr) | server→client | raw bytes |
| 3 | control | bidirectional | JSON control message | | 3 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof`) |
| 4 | ctrl-out | server→client | JSON control message (`Exit`) |
`stream_type > 3` is a protocol error (`InvalidStreamType`). There is `stream_type > 4` is a protocol error (`InvalidStreamType`). There is
no extension escape hatch in the byte — a 5th channel is a wire-format no extension escape hatch in the byte — a 6th channel is a wire-format
change requiring a new ALPN (`alknet/tty/v2` per ADR-006), not a change requiring a new ALPN (`alknet/tty/v2` per ADR-006), not a
negotiated addition to this format. See ADR-052 §"Fixed channel set, negotiated addition to this format. See ADR-052 §"Fixed channel set,
not extensible." not extensible."
**Bidirectional control channel (Phase 7).** The control channel is
split into two halves so it is genuinely bidirectional on the wire:
`STREAM_CTRL_IN = 3` carries client→server control (`Resize`,
`Signal`, `Eof`); `STREAM_CTRL_OUT = 4` carries server→client control
(`Exit`). The previous single `STREAM_CONTROL = 3` was documented as
"bidirectional" but the adapter ignored `Exit` from the client
because the two directions were indistinguishable on the same
stream_type — see `docs/research/alknet-crate-extraction/findings.md`
Phase 7. The split makes the bidirectionality explicit: each
direction has its own stream_type, and the adapter enforces the
direction (an `Exit` arriving on `STREAM_CTRL_IN` is a protocol
violation and is ignored; a `Resize` arriving on `STREAM_CTRL_OUT` is
likewise a protocol violation and is ignored).
- **`length`** (4 bytes, big-endian) — payload length in bytes. Max - **`length`** (4 bytes, big-endian) — payload length in bytes. Max
16 MiB (`MAX_CHUNK_LEN = 16 * 1024 * 1024`). A chunk larger than 16 MiB 16 MiB (`MAX_CHUNK_LEN = 16 * 1024 * 1024`). A chunk larger than 16 MiB
is a protocol error (`ChunkTooLarge`). is a protocol error (`ChunkTooLarge`).
@@ -208,9 +224,14 @@ Zero-length data chunks are sentinels:
Control chunks are never zero-length (the JSON payload is at least Control chunks are never zero-length (the JSON payload is at least
`{}`). `{}`).
### Control Channel (stream_type 3) ### Control Channel
Control chunks carry a JSON payload tagged by `type`. The schema is the The control channel is split into two halves (Phase 7):
- **`STREAM_CTRL_IN` (stream_type 3)** — client→server control.
- **`STREAM_CTRL_OUT` (stream_type 4)** — server→client control.
Each half carries JSON payloads tagged by `type`. The schema is the
POC's `ControlMessage` (`/workspace/alknet-tty-poc/src/control.rs`): POC's `ControlMessage` (`/workspace/alknet-tty-poc/src/control.rs`):
```rust ```rust
@@ -231,12 +252,22 @@ pub enum ControlMessage {
} }
``` ```
| Direction | Message | Shape | Maps to | | stream_type | direction | Message | Shape | Maps to |
|---|---|---|---| |-------------|----------------|---------|-------|---------|
| client→server | resize | `{"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}` | SSH `window-change`, docker exec resize, `ioctl(TIOCSWINSZ)` | | 3 (ctrl_in) | client→server | resize | `{"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}` | SSH `window-change`, docker exec resize, `ioctl(TIOCSWINSZ)` |
| client→server | signal | `{"type":"signal","name":"INT"}` | SSH `signal`, docker exec signal, `kill(-pgid, sig)` (REQ-TTY-02) | | 3 (ctrl_in) | client→server | signal | `{"type":"signal","name":"INT"}` | SSH `signal`, docker exec signal, `kill(-pgid, sig)` (REQ-TTY-02) |
| client→server | eof | `{"type":"eof"}` | SSH channel EOF, docker stdin close, `ChildStdin::drop` | | 3 (ctrl_in) | client→server | eof | `{"type":"eof"}` | SSH channel EOF, docker stdin close, `ChildStdin::drop` |
| server→client | exit | `{"type":"exit","code":0}` | the terminal/completion signal (ADR-055) | | 4 (ctrl_out) | server→client | exit | `{"type":"exit","code":0}` | the terminal/completion signal (ADR-055) |
The adapter enforces the direction: an `Exit` arriving on
`STREAM_CTRL_IN` is a protocol violation (the adapter ignores it); a
`Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is likewise a
protocol violation (the adapter ignores it). The split makes the
control channel genuinely bidirectional on the wire — the previous
single `STREAM_CONTROL = 3` was documented as "bidirectional" but the
adapter had to ignore `Exit` from the client because the two directions
were indistinguishable on the same stream_type. See
`docs/research/alknet-crate-extraction/findings.md` Phase 7.
**Signal names.** `name` is an uppercase string. The supported set (per **Signal names.** `name` is an uppercase string. The supported set (per
the POC's `signal_from_name`): `HUP`, `INT`, `QUIT`, `TERM`, `KILL`, the POC's `signal_from_name`): `HUP`, `INT`, `QUIT`, `TERM`, `KILL`,
@@ -261,12 +292,12 @@ type is not.
Two signals both close the client's stdin: Two signals both close the client's stdin:
1. **`{"type":"eof"}` control chunk** (stream_type 3) — explicit, 1. **`{"type":"eof"}` control chunk** (stream_type 3, `STREAM_CTRL_IN`)
recommended. Tells the server to close the backend's stdin — explicit, recommended. Tells the server to close the backend's
(`ChildStdin::drop` / PTY writer close). The client may still want to stdin (`ChildStdin::drop` / PTY writer close). The client may still
receive remaining stdout + the exit code, so the server does not tear want to receive remaining stdout + the exit code, so the server does
down the session on eof — it just closes stdin and keeps pumping not tear down the session on eof — it just closes stdin and keeps
output. pumping output.
2. **Zero-length stdin chunk** (stream_type 0, length 0) — the docker 2. **Zero-length stdin chunk** (stream_type 0, length 0) — the docker
POC's sentinel. Accepted for compatibility with that pattern. POC's sentinel. Accepted for compatibility with that pattern.
@@ -288,17 +319,26 @@ a session — see [tty-adapter.md](tty-adapter.md).
## Constraints ## Constraints
- **The wire format is one-way (ADR-052).** The 5-byte header, the fixed - **The wire format is one-way (ADR-052).** The 5-byte header, the fixed
stream_type set (0-3), and the two-carriage sequence are bytes clients stream_type set (0-4), and the two-carriage sequence are bytes clients
and servers parse. A 5th channel type requires a new ALPN and servers parse. A 6th channel type requires a new ALPN
(`alknet/tty/v2` per ADR-006), not a negotiated addition. (`alknet/tty/v2` per ADR-006), not a negotiated addition.
- **The control channel is split into two halves (Phase 7).**
`STREAM_CTRL_IN = 3` is client→server (`Resize`, `Signal`, `Eof`);
`STREAM_CTRL_OUT = 4` is server→client (`Exit`). The adapter enforces
the direction: an `Exit` on `STREAM_CTRL_IN` is ignored; a `Resize` on
`STREAM_CTRL_OUT` is ignored. The split is what makes the control
channel genuinely bidirectional on the wire — the previous single
`STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter
had to ignore `Exit` from the client because the two directions were
indistinguishable on the same stream_type.
- **No windowing.** The chunk format has no flow-control window; QUIC's - **No windowing.** The chunk format has no flow-control window; QUIC's
per-stream flow control is the backpressure mechanism (OQ-45 resolved: per-stream flow control is the backpressure mechanism (OQ-45 resolved:
the backpressure chain is complete by construction — QUIC flow control the backpressure chain is complete by construction — QUIC flow control
→ bounded drainer channel → bounded stdout channel → OS pipe/PTY → bounded drainer channel → bounded stdout channel → OS pipe/PTY
buffer → process `write()` blocks; no unbounded buffer breaks the buffer → process `write()` blocks; no unbounded buffer breaks the
chain). The reversal path, if ever needed, is an additive chain). The reversal path, if ever needed, is an additive
`ControlMessage` variant on stream_type 3, not a wire-format header `ControlMessage` variant on `STREAM_CTRL_IN`/`STREAM_CTRL_OUT`, not a
change. wire-format header change.
- **No negotiation round-trip.** The client writes the negotiation frame - **No negotiation round-trip.** The client writes the negotiation frame
and starts sending chunks; the server reads the frame and starts and starts sending chunks; the server reads the frame and starts
pumping. There is no "the server acknowledges the negotiation before pumping. There is no "the server acknowledges the negotiation before
@@ -312,18 +352,21 @@ a session — see [tty-adapter.md](tty-adapter.md).
without entering raw mode. The error response MUST be under 16 MiB without entering raw mode. The error response MUST be under 16 MiB
(`MAX_CHUNK_LEN`) so the 4-byte big-endian length prefix's high byte (`MAX_CHUNK_LEN`) so the 4-byte big-endian length prefix's high byte
is `0x00` — this is what makes the framing-disambiguation trick is `0x00` — this is what makes the framing-disambiguation trick
(first byte `0x00` = error frame, first byte `1`/`2`/`3` = raw chunk) (first byte `0x00` = error frame, first byte `1`/`2`/`4` = raw chunk;
sound; it is a wire-format invariant, not an empirical observation. the server never sends `0` (stdin, client→server) or `3`
See [tty-adapter.md](tty-adapter.md) §"Negotiation errors". (`STREAM_CTRL_IN`, client→server), so `0x00` is unambiguous) sound; it
is a wire-format invariant, not an empirical observation. See
[tty-adapter.md](tty-adapter.md) §"Negotiation errors".
## Design Decisions ## Design Decisions
| Decision | ADR | Summary | | Decision | ADR | Summary |
|----------|-----|---------| |----------|-----|---------|
| Wire format and two-carriage model | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | `alknet/tty` ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-3; control as JSON | | Wire format and two-carriage model | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | `alknet/tty` ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-4; control as JSON |
| Bidirectional control channel split | Phase 7 (this doc, amended) | `STREAM_CTRL_IN = 3` (client→server) and `STREAM_CTRL_OUT = 4` (server→client) replace the single `STREAM_CONTROL = 3`; the adapter enforces the direction |
| No alknet-call dependency (self-contained framing) | [ADR-057](../../decisions/057-alknet-tty-no-alknet-call-dep.md) | alknet-tty implements its own length-prefixed framing; format coincides with alknet-call's by convention, not by code reuse | | No alknet-call dependency (self-contained framing) | [ADR-057](../../decisions/057-alknet-tty-no-alknet-call-dep.md) | alknet-tty implements its own length-prefixed framing; format coincides with alknet-call's by convention, not by code reuse |
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | `{"type":"exit","code":N}` on stream_type 3; "exit chunk is last" invariant | | Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | `{"type":"exit","code":N}` on `STREAM_CTRL_OUT` (stream_type 4); "exit chunk is last" invariant |
| Stdin closure canonical signal | OQ-47 | Either `eof` control chunk or zero-length stdin chunk; `eof` recommended | | Stdin closure canonical signal | OQ-47 | Either `eof` control chunk (`STREAM_CTRL_IN`) or zero-length stdin chunk; `eof` recommended |
## Open Questions ## Open Questions
@@ -2,7 +2,9 @@
## Status ## Status
Accepted Accepted (amended 2026-07-18 — Phase 7: the control channel is split
into `STREAM_CTRL_IN = 3` (client→server) and `STREAM_CTRL_OUT = 4`
(server→client) halves; see §"Control channel split" below)
## Context ## Context
@@ -123,12 +125,15 @@ The bidi stream has two phases:
| 0 | data-in (stdin) | client→server | raw bytes | | 0 | data-in (stdin) | client→server | raw bytes |
| 1 | data-out (stdout) | server→client | raw bytes | | 1 | data-out (stdout) | server→client | raw bytes |
| 2 | data-err (stderr) | server→client | raw bytes | | 2 | data-err (stderr) | server→client | raw bytes |
| 3 | control | bidirectional | JSON control message | | 3 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof` — see §4a) |
| 4 | ctrl-out | server→client | JSON control message (`Exit` — see §4a) |
`stream_type > 3` is a protocol error (`InvalidStreamType`). There is `stream_type > 4` is a protocol error (`InvalidStreamType`). (Phase 7
no extension escape hatch in the byte — a 5th channel is a wire-format amendment, §4a: the original `3 = control (bidirectional)` is split
change requiring a new ALPN (`alknet/tty/v2` per ADR-006), not a into `3 = ctrl-in` and `4 = ctrl-out`; the original bound was
negotiated addition to this format. `> 3`.) There is no extension escape hatch in the byte — a 6th
channel is a wire-format change requiring a new ALPN (`alknet/tty/v2`
per ADR-006), not a negotiated addition to this format.
- `length` — payload length in bytes, u32 big-endian, max 16 MiB. A - `length` — payload length in bytes, u32 big-endian, max 16 MiB. A
chunk larger than 16 MiB is a protocol error (`ChunkTooLarge`). The chunk larger than 16 MiB is a protocol error (`ChunkTooLarge`). The
@@ -176,6 +181,53 @@ tearing down the session. This is a two-way-door extension point within
the one-way-door wire format — adding a control message type is the one-way-door wire format — adding a control message type is
additive; changing the chunk header is not. additive; changing the chunk header is not.
### 4a. Control channel split (Phase 7 amendment, 2026-07-18)
The single `stream_type 3 = control (bidirectional)` in §3 and §4 above
is **split into two halves** so the control channel is genuinely
bidirectional on the wire:
| stream_type | channel | direction | payload |
|-------------|-------------|----------------|------------------------------------------------------|
| 3 | `STREAM_CTRL_IN` | client→server | JSON control message (`Resize`, `Signal`, `Eof`) |
| 4 | `STREAM_CTRL_OUT` | server→client | JSON control message (`Exit`) |
The `stream_type > 3` protocol-error bound becomes `stream_type > 4`.
The chunk header is otherwise unchanged (5 bytes: 1 type + 4 length).
**Why the split.** The original §4 documented stream_type 3 as
"bidirectional" and listed the four control messages with their
directions. But the adapter had no way to distinguish the two
directions on the same stream_type — `Exit` from the client was always
ignored (the adapter's `pump_client_to_backend` matched `Exit` and
logged "ignoring Exit control from client (server→client only)"). The
spec said "bidirectional"; the code was half-duplex. The split makes
the bidirectionality literal: each direction has its own stream_type,
the adapter enforces the direction (an `Exit` arriving on
`STREAM_CTRL_IN` is a protocol violation; a `Resize` arriving on
`STREAM_CTRL_OUT` is a protocol violation), and a client can route
exit vs. control without parsing the JSON `type` tag first.
**Door type.** One-way, same as the original §3 / §4. The stream_type
set is bytes clients and servers parse. A client written against the
old single-`STREAM_CONTROL` shape will misread `STREAM_CTRL_OUT = 4` as
`InvalidStreamType (> 3)` and tear down the session — the split is a
wire-format change, not an additive extension. The reversal path is
the same as the original: a new ALPN (`alknet/tty/v2`), which coexists
rather than replaces. The trade is one new stream_type byte now vs.
the half-duplex-in-disguise flaw forever.
**What changes in the spec.** §3's stream_type table gains a 5th row
(`4 = ctrl_out, server→client`); the bound becomes `> 4`. §4's
direction table is unchanged in content (the four messages keep their
directions and shapes) but the direction is now encoded in the
stream_type, not just in the adapter's behavior. ADR-055's "exit chunk
is last" invariant is unchanged — the exit chunk still rides the
control channel, just on `STREAM_CTRL_OUT` (stream_type 4) instead of
the old single `STREAM_CONTROL` (stream_type 3). See
`docs/research/alknet-crate-extraction/findings.md` Phase 7 for the
full migration notes.
### 5. Negotiation errors use the JSON framing, not the raw chunk format ### 5. Negotiation errors use the JSON framing, not the raw chunk format
If the server cannot allocate the session (unknown backend, PTY If the server cannot allocate the session (unknown backend, PTY
@@ -480,8 +480,22 @@ only type returned by `accept_bi()`, `from_stream` is removed,
### Phase 7: TTY control-channel bidirectionality fix ### Phase 7: TTY control-channel bidirectionality fix
**What:** Fix the "control isn't actually bidirectional" flaw in **Status:** **Done** (this commit, 2026-07-18). All "Done when"
`alknet-tty`. The current `STREAM_CONTROL = 3` is documented as criteria met: `STREAM_CONTROL` is replaced with `STREAM_CTRL_IN = 3` /
`STREAM_CTRL_OUT = 4`; `InvalidStreamType` bound is `> 4`; the adapter
dispatches `Resize`/`Signal`/`Eof` on `STREAM_CTRL_IN` and emits `Exit`
on `STREAM_CTRL_OUT`; protocol violations in both directions (`Exit` on
ctrl_in, anything on ctrl_out from the client) are ignored;
`cargo test -p alknet-tty` passes (65 tests, was 61 — 4 new tests for
the split); `cargo test -p alknet-tty-local` passes (19 tests); the
full workspace `cargo test --workspace --all-features` is fully green
(1017 tests, 0 failures). ADR-052 is amended (§4a "Control channel
split"); `tty-wire.md` and `tty-adapter.md` are updated; the
`negotiation.rs` framing-disambiguation doc is updated (server-sent
stream_type set is now `{1, 2, 4}`).
**What was planned:** Fix the "control isn't actually bidirectional" flaw
in `alknet-tty`. The current `STREAM_CONTROL = 3` is documented as
"bidirectional" but the adapter ignores `Exit` from the client "bidirectional" but the adapter ignores `Exit` from the client
(`adapter.rs:462-463`). The fix: (`adapter.rs:462-463`). The fix:
@@ -496,6 +510,84 @@ only type returned by `accept_bi()`, `from_stream` is removed,
This is a TTY-layer fix — the channels layer has no `stream_type` This is a TTY-layer fix — the channels layer has no `stream_type`
concept and is unaffected. concept and is unaffected.
**What was done (`crates/alknet-tty`, +~110/-~60 lines net +~50):**
- **`wire.rs`**: split `pub const STREAM_CONTROL: u8 = 3` into
`STREAM_CTRL_IN: u8 = 3` (client→server) and `STREAM_CTRL_OUT: u8 =
4` (server→client); updated the module doc to describe the split;
updated `RawError::InvalidStreamType` doc comment (`> 4`); updated
`ChunkReader::read_chunk` bound check (`> 3` → `> 4`); replaced
`Chunk::control` with `Chunk::ctrl_in` + `Chunk::ctrl_out`
constructors; replaced `ChunkWriter::write_control_json` with
`write_ctrl_in_json` + `write_ctrl_out_json` helpers; split the
`round_trip_control` test into `round_trip_ctrl_in` +
`round_trip_ctrl_out`; split `round_trip_write_control_json_helper`
into `round_trip_write_ctrl_in_json_helper` +
`round_trip_write_ctrl_out_json_helper`; updated the
`invalid_stream_type` test (the boundary byte is now 5, not 4).
- **`control.rs`**: updated the module doc and the `ControlMessage`
enum doc (direction table now includes the `stream_type` column —
`STREAM_CTRL_IN` for `Resize`/`Signal`/`Eof`, `STREAM_CTRL_OUT` for
`Exit`; notes the adapter enforces the direction; explains the
half-duplex-in-disguise flaw the split fixes). The JSON shape is
unchanged — the `ControlMessage` enum, the `to_json`/`from_slice`
methods, and the `signal_from_name` helper are byte-for-byte
unchanged. The 9 unit tests in `control.rs` are unchanged.
- **`adapter.rs`**: updated the module doc (session lifecycle +
"Bidirectional control channel (Phase 7)" section); updated the
`STREAM_CONTROL` import to `STREAM_CTRL_IN`; `send_exit_chunk` now
emits `Chunk::ctrl_out(json)` (was `Chunk::control(json)`); the
`pump_client_to_backend` doc explains the direction enforcement;
the `STREAM_CTRL_IN` match arm dispatches `Resize`/`Signal`/`Eof`
and ignores `Exit` (with a debug log explaining the protocol
violation); a new `STREAM_CTRL_OUT` match arm logs and ignores the
protocol violation (the client writing on the server→client half);
updated all 10 test references to use the correct half —
`STREAM_CTRL_OUT` for reading the exit chunk from the server (4
sites), `STREAM_CTRL_IN` for writing control from the client (6
sites). Added 3 new tests: `ctrl_out_from_client_ignored`,
`exit_chunk_arrives_on_ctrl_out_not_ctrl_in`, and updated
`exit_control_from_client_ignored` (which now documents the protocol
violation explicitly).
- **`negotiation.rs`**: updated the framing-disambiguation doc
comment (the server-sent stream_type set is now `{1, 2, 4}` — the
server never sends `0` (stdin, client→server) or `3`
(`STREAM_CTRL_IN`, client→server); `0x00` is unambiguous as the
error-frame length-prefix high byte).
- **`crates/alknet-tty-local/tests/`**: updated `common/mod.rs`
(replaced `STREAM_CONTROL` import with `STREAM_CTRL_IN` +
`STREAM_CTRL_OUT`; `write_control` writes on `STREAM_CTRL_IN`; the
`read_until_exit` + `read_until_exit_timeout` match arms for the
exit chunk use `STREAM_CTRL_OUT`); updated `pty.rs` and `pipe.rs`
to use `STREAM_CTRL_OUT` for the exit-chunk reads. No production
code changes in `alknet-tty-local` (the backend doesn't write to
the wire — it produces handles; the adapter pumps).
**Spec updates:**
- **ADR-052** (`docs/architecture/decisions/052-...md`): Status changed
to "Accepted (amended 2026-07-18 — Phase 7: the control channel is
split into `STREAM_CTRL_IN = 3` (client→server) and
`STREAM_CTRL_OUT = 4` (server→client) halves; see §"Control channel
split" below)". §3's stream_type table gains a 5th row (`4 = ctrl_out,
server→client`); the bound becomes `> 4`. New §4a "Control channel
split (Phase 7 amendment)" documents the why, the door-type, and
the spec deltas.
- **`docs/architecture/crates/tty/tty-wire.md`**: updated `last_updated`
to 2026-07-18; the Phase 2 Raw Chunk Format stream_type table gains
the `ctrl_in`/`ctrl_out` rows; the "Bidirectional control channel
(Phase 7)" paragraph explains the split; the Control Channel
section now uses the two halves; the Constraints section reflects
the new bound (`> 4`) and the new framing-disambiguation
(`{1, 2, 4}` server-sent); the Design Decisions table gains a
"Bidirectional control channel split" row.
- **`docs/architecture/crates/tty/tty-adapter.md`**: updated
`last_updated` to 2026-07-18; the Session Lifecycle bullet B
references `STREAM_CTRL_IN`; bullet C references `STREAM_CTRL_OUT`;
new "Bidirectional Control Channel (Phase 7)" section documents the
direction enforcement; the Framing Disambiguation paragraph
references the new server-sent set `{1, 2, 4}`.
**Compilable state:** `cargo test -p alknet-tty` passes. The control **Compilable state:** `cargo test -p alknet-tty` passes. The control
channel is properly bidirectional. channel is properly bidirectional.
@@ -606,7 +698,7 @@ Phase 9 is fully closed.
| 4 (core prune) | core is lightweight; `endpoint.rs` gone; `ConnectionCredentials` in core | | 4 (core prune) | core is lightweight; `endpoint.rs` gone; `ConnectionCredentials` in core |
| 5 (call prune) | call is pure protocol; `connect` + TLS helpers + `CallCredentials` + `from_call` dead path gone; Category B tests already moved; 2 `CallCredentials` tests moved to core | | 5 (call prune) | call is pure protocol; `connect` + TLS helpers + `CallCredentials` + `from_call` dead path gone; Category B tests already moved; 2 `CallCredentials` tests moved to core |
| 6 (stream unification) | **Done** (`b60a584`). `BiStream` is the handler leaf; `accept_bi` returns `BiStream`; `from_stream` removed; `from_bidi` is the only public constructor; `SendStream`/`RecvStream` are thin internal newtypes. Subsumed Phase 9's `QuicStream`/`QuicStreamDuplex` removal. | | 6 (stream unification) | **Done** (`b60a584`). `BiStream` is the handler leaf; `accept_bi` returns `BiStream`; `from_stream` removed; `from_bidi` is the only public constructor; `SendStream`/`RecvStream` are thin internal newtypes. Subsumed Phase 9's `QuicStream`/`QuicStreamDuplex` removal. |
| 7 (TTY control fix) | TTY control channel is properly bidirectional (`STREAM_CTRL_IN = 3`, `STREAM_CTRL_OUT = 4`); `InvalidStreamType` bound updated. **Unchanged by Phase 6** — Phase 7's work is in `wire.rs` and `control.rs`, neither of which Phase 6 touched. | | 7 (TTY control fix) | **Done** (this commit). TTY control channel is properly bidirectional (`STREAM_CTRL_IN = 3`, `STREAM_CTRL_OUT = 4`); `InvalidStreamType` bound is `> 4`; the adapter dispatches on `STREAM_CTRL_IN` and emits on `STREAM_CTRL_OUT`; protocol violations in both directions are ignored. ADR-052 amended (§4a); `tty-wire.md` + `tty-adapter.md` + `negotiation.rs` framing-disambiguation doc updated. 4 new tests in `adapter.rs`; 1017 tests pass workspace-wide. |
| 8 (channels spec) | Channels spec updated to 8-byte wire format; no `stream_type` concept; `into_sub_streams` removed; ADRs 071/074/077 amended. **Unchanged by Phase 6** — docs-only, no code. | | 8 (channels spec) | Channels spec updated to 8-byte wire format; no `stream_type` concept; `into_sub_streams` removed; ADRs 071/074/077 amended. **Unchanged by Phase 6** — docs-only, no code. |
| 9 (http fix) | **Done — subsumed by Phase 6** (`b60a584`) + test-helper fix (`<this commit>`). `QuicStream` wrapper removed from `alknet-http`; `BiStream` used directly; `QuicStreamDuplex` test helper removed. Pre-existing `to_mcp` test-helper bug (`full_registry_with_ops` used `HandlerKind::Once` for `Subscription` ops, rejected by the registry's kind validation since ADR-049) fixed — `cargo test --workspace --all-features` is fully green (1008 tests, 0 failures). | | 9 (http fix) | **Done — subsumed by Phase 6** (`b60a584`) + test-helper fix (`<this commit>`). `QuicStream` wrapper removed from `alknet-http`; `BiStream` used directly; `QuicStreamDuplex` test helper removed. Pre-existing `to_mcp` test-helper bug (`full_registry_with_ops` used `HandlerKind::Once` for `Subscription` ops, rejected by the registry's kind validation since ADR-049) fixed — `cargo test --workspace --all-features` is fully green (1008 tests, 0 failures). |