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_tty::adapter::drive_session;
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 tokio::io::duplex;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -70,10 +72,11 @@ impl ClientSide {
self.write.flush().await.unwrap();
}
/// Write a control chunk (stream_type 3) carrying a serialized
/// `ControlMessage` JSON payload.
/// Write a client→server control chunk (`STREAM_CTRL_IN`, stream_type
/// 3) carrying a serialized `ControlMessage` JSON payload (`Resize`,
/// `Signal`, or `Eof`).
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`
@@ -145,7 +148,7 @@ impl ClientSide {
stderr.extend_from_slice(&bytes);
}
}
STREAM_CONTROL => {
STREAM_CTRL_OUT => {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
if v["type"] == "exit" {
return Some((stdout, stderr, v["code"].as_i64().unwrap() as i32));
@@ -177,7 +180,7 @@ impl ClientSide {
stderr.extend_from_slice(&bytes);
}
}
STREAM_CONTROL => {
STREAM_CTRL_OUT => {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
if v["type"] == "exit" {
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() {
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();
if v["type"] == "exit" {
break;
+2 -2
View File
@@ -17,7 +17,7 @@ mod common;
use std::sync::Arc;
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 common::{negotiate_pty_json, spawn_session};
@@ -245,7 +245,7 @@ async fn pty_exit_chunk_is_last() {
if saw_exit {
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();
if v["type"] == "exit" {
assert_eq!(v["code"], 0);