feat(tty): implement TtyAdapter (ProtocolHandler) and three-pump session driver (ADR-052, 053, 055, 056)

Implement TtyAdapter (ProtocolHandler on alknet/tty) and the
drive_session three-pump bidirectional driver in adapter.rs.

TtyAdapter holds a HashMap<String, Arc<dyn TtyBackend>> keyed by the
negotiation frame's backend string and an optional OwnershipProvider.
handle() loops accept_bi and spawns drive_session per stream.

drive_session proceeds in three phases:
1. Negotiation — read length-prefixed JSON, parse NegotiateRequest,
   validate (carriage == raw, cmd non-empty), look up backend, scope-gate
   (tty:open), resource ownership check via backend.resource_id() +
   OwnershipProvider::owns(), construct TtyParams. Errors sent as JSON
   in negotiation framing, stream closed.
2. Allocation — backend.allocate(&params). On failure send
   allocate_failed error, close.
3. Raw carriage — three concurrent pumps:
   A. stdout/stderr → client (stream_type 1/2), zero-length sentinel on EOF
   B. client → backend: stdin chunks → TtyHandle.stdin, control chunks →
      Resize/Signal/Eof dispatch (Exit ignored, unknown types ignored)
   C. exit → exit chunk: await exit_code; on resolve enqueue exit chunk

Exit-chunk-is-last invariant (ADR-055): adapter waits for BOTH
stdout/stderr pumps to complete AND exit_code to resolve before
enqueueing the exit chunk (tokio::join!). On TtyError → code -1.

Cancel cleanup (ADR-056): on connection drop/stream reset, pump tasks
drop, TtyHandle drops, exit_code future drops without resolve →
backend kill-on-Drop fires. Client write-half close is NOT a cancel —
session runs to completion.

Access control (ADR-050): scope gate at negotiation (tty:open scope,
configurable constant), resource ownership via backend.resource_id() +
OwnershipProvider::owns() when wired.

18 integration tests with a TestBackend fixture over tokio::io::duplex:
happy path, exit-chunk-is-last, stdin EOF, resize/signal control,
unknown control ignored, exit control ignored, unknown_backend,
malformed_negotiation (bad JSON, carriage != raw, empty cmd),
allocate_failed, exit error (-1), cancel cleanup, scope gate forbidden,
ownership check deny/allow, stderr pump, eof control closes stdin.

Also fix two clippy unused_mut warnings in negotiation.rs tests.
This commit is contained in:
glm-5.2 committed 2026-07-07 15:34:09 +00:00
1 parent 42643be338
commit d5612f1a0e
2 files changed
+1391 -3

No files matched your search

File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -440,7 +440,7 @@ mod tests {
let read = reader.read_frame().await.expect("read"); let read = reader.read_frame().await.expect("read");
assert_eq!(read.as_ref(), body); assert_eq!(read.as_ref(), body);
let mut reclaimed = reader.into_inner(); let reclaimed = reader.into_inner();
let mut leftover = [0u8; 4]; let mut leftover = [0u8; 4];
a.write_all(b"tail").await.expect("write leftover"); a.write_all(b"tail").await.expect("write leftover");
a.flush().await.expect("flush"); a.flush().await.expect("flush");
@@ -456,7 +456,7 @@ mod tests {
let (mut a, mut b) = duplex(8 * 1024); let (mut a, mut b) = duplex(8 * 1024);
let mut writer = NegotiationWriter::new(&mut a); let mut writer = NegotiationWriter::new(&mut a);
writer.write_frame(b"x").await.expect("write"); writer.write_frame(b"x").await.expect("write");
let mut reclaimed = writer.into_inner(); let reclaimed = writer.into_inner();
reclaimed.write_all(b"raw").await.expect("write raw"); reclaimed.write_all(b"raw").await.expect("write raw");
reclaimed.flush().await.expect("flush"); reclaimed.flush().await.expect("flush");