alktty 0.5.0

Terminal session protocol: wire format, TtyBackend trait, TtyAdapter, and typed consumer client. Producer/consumer protocol crate on top of alkcall channels.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
//! Consumer half — `TtySession`, the typed client wrapper around the
//! `alk/tty` wire protocol (per alkcall's protocol-crate pattern).
//!
//! Two constructors:
//!
//! - [`TtySession::connect_direct`] — for a direct `alk/tty` ALPN
//!   connection. The consumer dials the transport (TLS, QUIC,
//!   WebSocket), negotiates the `alk/tty` ALPN, and hands the
//!   `Connection` to `connect_direct`. The session takes ownership of
//!   the connection's single `BiStream` (yield-once per connection,
//!   ADR-065), writes the negotiation frame, and exposes the typed
//!   methods.
//!
//! - [`TtySession::open_via_channels`] — for the `alk/channels`
//!   multiplexed path. The consumer holds a
//!   [`alkcall::channels::client::ChannelClient`], calls
//!   `open_via_channels(client, params)`, which invokes
//!   `channels/tty/sub` on channel 0, adopts the resulting channel,
//!   builds a `Connection` from the reassembled read half + mux write
//!   half, and runs the typed-methods flow directly in raw chunk mode
//!   (ADR-009: the open op's `params` — validated by the registry's
//!   input schema — *are* the negotiation; no second negotiation
//!   frame is written on the channel's data stream). Semantic
//!   establishment failures — including allocation failure since
//!   alkcall 0.6 — are rejected by the producer's establisher and
//!   surface as `channel:open_failed` call errors (alkcall ADR-049 /
//!   alktty ADR-010, as amended for the 0.6 plan payload; see
//!   `open_via_channels`' docs).
//!
//! The session handle exposes:
//! - [`TtySession::send_stdin`] / [`TtySession::close_stdin`] — write
//!   stdin chunks, close stdin (zero-length sentinel).
//! - [`TtySession::recv_stdout`] / [`TtySession::recv_stderr`] —
//!   streams of stdout/stderr chunks (`Stream<Item = Bytes>`).
//! - [`TtySession::resize`] — send a `Resize` control message.
//! - [`TtySession::signal`] — send a `Signal` control message.
//! - [`TtySession::wait`] — await the `Exit` control chunk (the
//!   process exit code).
//!
//! The session does NOT re-serialize the negotiation request itself
//! — the caller passes a `NegotiateRequest` (direct path) or a
//! `serde_json::Value` (channels path — the open op's `input` is the
//! negotiation). The direct path writes the frame and switches to
//! raw-chunk mode; the channels path starts in raw-chunk mode. See
//! ADR-052 for the two-carriage model.
//!
//! # WASM
//!
//! `TtySession` is wasm-clean (no `tokio::process`, no `std::thread`,
//! no `libc`). The consumer half is exactly the part a browser-side
//! or Python-wasm adapter would use — it runs the wire protocol
//! against a `Connection` the consumer dials (a WebSocket binary
//! stream, a WebTransport bidi stream, etc.). The producer half
//! (`TtyAdapter` + `register_openable`) runs on a real OS with a
//! backend that can spawn processes.

use std::collections::HashMap;
use std::pin::Pin;

use bytes::Bytes;
use futures::Stream;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::sync::{mpsc, Mutex};
use tokio::task::JoinHandle;
use tracing::{debug, warn};

use alkcall::channels::client::ChannelClient;
use alkcall::core::Connection;

use crate::control::ControlMessage;
use crate::negotiation::{NegotiateRequest, NegotiationError, NegotiationWriter};
use crate::wire::{Chunk, ChunkReader, ChunkWriter, RawError, STREAM_CTRL_IN, STREAM_CTRL_OUT};

/// Errors from the typed consumer client.
///
/// `#[non_exhaustive]` so new variants are additive (the same
/// two-way-door pattern as [`crate::backend::TtyError`], and the same
/// justification alkcall gives its consumer-facing `AdapterError`):
/// session drivers accrue failure modes (the channels-path fail-fast
/// variant was added pre-1.0), and an exhaustive match on this enum in
/// a downstream consumer would turn every addition into a breaking
/// change.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TtySessionError {
    /// The underlying transport I/O failed (not a clean EOF).
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    /// The raw-chunk codec errored (invalid stream type, chunk too
    /// large, transport I/O).
    #[error("wire: {0}")]
    Wire(#[from] RawError),
    /// The negotiation frame failed to serialize.
    #[error("negotiation serialize: {0}")]
    NegotiationSerialize(#[from] serde_json::Error),
    /// The negotiation frame failed to write (framing I/O error).
    #[error("negotiation write: {0}")]
    NegotiationWrite(#[from] NegotiationError),
    /// The channels open op failed. Carries alkcall's typed
    /// `ChannelOpenError` verbatim (alkcall 0.5.0, ADR-049 §4): a
    /// `CallFailed` variant wraps the wire `CallError`, so a
    /// semantic-establishment failure (unknown backend, malformed
    /// negotiation, ownership denial — rejected by the producer's
    /// establisher) surfaces as `channel:open_failed` with
    /// `details.reason` branchable via
    /// `ChannelOpenError::establishment_reason()`; ACL denial and
    /// unknown-op failures carry their own codes (`FORBIDDEN`,
    /// `UNKNOWN_OPERATION`). `MissingChannelId` / `AdoptFailed` are
    /// the local-only failure shapes.
    #[error("channels open: {0}")]
    ChannelsOpen(#[from] alkcall::channels::client::ChannelOpenError),
    /// The channels open op's `params` failed the consumer's local
    /// `NegotiateRequest` parse — the fail-fast check in
    /// [`TtySession::open_via_channels`] before a channel is allocated.
    /// (R5: the parse previously surfaced as `NegotiationSerialize`,
    /// a variant whose name and doc describe serializing the
    /// negotiation frame, not parsing open-op params.)
    #[error("invalid open params: {0}")]
    InvalidParams(String),
    /// The server sent a negotiation error frame (the first frame on
    /// the stream is a length-prefixed JSON `{"error":"..."}` rather
    /// than a raw chunk). On the channels path this is
    /// defense-in-depth only — a registered producer rejects every
    /// failure class (including allocation, since alkcall 0.6) in the
    /// establisher as `channel:open_failed` before the reply — but
    /// the variant stays reachable (no-establisher registrations, the
    /// direct-ALPN path's full in-band vocabulary).
    #[error("negotiation rejected: {error}")]
    NegotiationRejected {
        error: String,
        fields: HashMap<String, String>,
    },
    /// The session ended (server closed the stream) before an `Exit`
    /// control chunk arrived. `wait()` returns this when the
    /// stdout/stderr pumps drain and no exit chunk was observed.
    #[error("session ended without exit chunk")]
    NoExitChunk,
    /// The `Exit` control chunk's JSON payload failed to parse.
    #[error("malformed exit chunk: {0}")]
    MalformedExitChunk(String),
    /// The transport refused the session open (the `Connection`'s
    /// `accept_bi` failed on either constructor path). Carries the
    /// upstream error type rather than flattening it into an
    /// `io::Error` (review #003 P15 — additive pre-consumers).
    #[error("session open failed: {0}")]
    Open(#[from] alkcall::core::StreamError),
}

/// A live `alk/tty` session — the typed consumer-side handle.
///
/// Constructed via [`TtySession::connect_direct`] (direct `alk/tty`
/// ALPN) or [`TtySession::open_via_channels`] (multiplexed over
/// `alk/channels`). The session owns the negotiation frame exchange
/// and exposes typed methods for stdin/stdout/stderr/control/exit.
///
/// The session drives a single read pump task (chunks →
/// stdout/stderr/exit channels) and holds the write half for stdin +
/// control messages. Dropping the session cancels the read pump and
/// closes the write half.
pub struct TtySession {
    /// The write half of the bidi stream, wrapped in a `ChunkWriter`.
    /// `send_stdin`, `resize`, `signal`, and `close_stdin` write
    /// through this. Behind a `Mutex` so the methods can take `&self`
    /// and the caller doesn't need `&mut self` to drive the session.
    writer: Mutex<ChunkWriter<Box<dyn AsyncWrite + Send + Unpin>>>,
    /// stdout chunks from the read pump. The caller drains this via
    /// `recv_stdout()`. `Option` so `recv_stdout()` can take it
    /// (calling twice returns an empty stream the second time).
    stdout_rx: Mutex<Option<mpsc::Receiver<Bytes>>>,
    /// stderr chunks from the read pump. `None` for PTY-mode backends
    /// (stdout/stderr merged into stdout by the kernel PTY) or after
    /// `recv_stderr()` has taken it.
    stderr_rx: Mutex<Option<mpsc::Receiver<Bytes>>>,
    /// The exit outcome, resolved by the read pump when it observes the
    /// `Exit` control chunk. `wait()` awaits this. `Option<ExitOutcome>`
    /// starts as `None`; the pump sends `Some(Exited(code))` on exit
    /// chunk, `Some(MalformedExit(msg))` on a malformed exit chunk, or
    /// `Some(NoExitChunk)` on stream close.
    exit_code: tokio::sync::watch::Receiver<Option<ExitOutcome>>,
    /// The read pump task handle. Dropping the session aborts it.
    _read_pump: JoinHandle<()>,
}

/// The cloneable outcome the read pump resolves into the exit watch
/// channel. `wait()` maps this to a [`TtySessionError`] (or the exit
/// code). Kept separate from `TtySessionError` because the watch channel
/// requires `Clone`, and `TtySessionError` carries non-`Clone` payloads
/// (`std::io::Error`, `serde_json::Error`).
#[derive(Debug, Clone)]
enum ExitOutcome {
    /// The `Exit` control chunk was observed with this code.
    Exited(i32),
    /// A `STREAM_CTRL_OUT` chunk failed to parse as a `ControlMessage`.
    MalformedExit(String),
    /// The stream closed before an `Exit` chunk was observed.
    NoExitChunk,
}

impl TtySession {
    /// Connect directly over a `alk/tty` ALPN connection.
    ///
    /// The consumer dials the transport (TLS, QUIC, WebSocket),
    /// negotiates `alk/tty`, and hands the `Connection` here. The
    /// session takes the connection's single `BiStream` (yield-once
    /// per single-stream connection, ADR-065), writes the negotiation
    /// frame, and starts the read pump.
    ///
    /// `negotiate` is the [`NegotiateRequest`] the session writes as
    /// the first frame. The caller builds it (the typed shape is
    /// easier to construct than a raw JSON `Value`); the session
    /// serializes it.
    pub async fn connect_direct(
        connection: Connection,
        negotiate: NegotiateRequest,
    ) -> Result<Self, TtySessionError> {
        let stream = connection.accept_bi().await?;
        Self::from_bidi_stream(stream, negotiate).await
    }

    /// Open a TTY session via `alk/channels` — the multiplexed path.
    ///
    /// The consumer holds a [`ChannelClient`], calls
    /// `open_via_channels(client, params)`, which invokes
    /// `channels/tty/sub` on channel 0, adopts the resulting channel,
    /// builds a `Connection` from the reassembled read half + mux
    /// write half, and runs the typed-methods flow directly in raw
    /// chunk mode (ADR-009: the open op's `params` — validated by the
    /// registry's input schema — *are* the negotiation; no second
    /// negotiation frame is written on the channel's data stream).
    ///
    /// `params` is the `NegotiateRequest` as a `serde_json::Value` —
    /// the channels open op takes a `Value`, not a typed struct. The
    /// session parses it as a `NegotiateRequest` before opening (so a
    /// malformed request fails fast, before a channel is allocated)
    /// and the producer parses the same value from the open op.
    ///
    /// Failures before the channel opens (ACL denial, unknown op,
    /// channel cap) surface as [`TtySessionError::ChannelsOpen`]; a
    /// params value that fails the local `NegotiateRequest` parse
    /// surfaces as [`TtySessionError::InvalidParams`]. Semantic
    /// establishment failures (a `NegotiateRequest` parse failure of a
    /// schema-valid-but-unparseable params value, `carriage != "raw"`,
    /// empty `cmd`, unknown backend, ownership denial) are rejected by
    /// the producer's establisher before the reply — they surface as
    /// [`TtySessionError::ChannelsOpen`] wrapping a `CallFailed` whose
    /// `CallError` is `channel:open_failed` with
    /// `details.reason` (alkcall 0.5.0 ADR-049; the SSH contract: no
    /// `channel_id` is ever returned). Since alkcall 0.6 (ADR-010 as
    /// amended — allocation moved into the establisher, the allocated
    /// handle crossing via the `Establishment` plan payload) no
    /// failure class arrives in-band on the channels path: allocation
    /// failure surfaces as `channel:open_failed` with
    /// `details.reason == "dial_failed"`, and the `0x00`
    /// disambiguation read is a keep-the-pump formality (a registered
    /// producer never writes an error frame first).
    pub async fn open_via_channels(
        client: &ChannelClient,
        params: serde_json::Value,
    ) -> Result<Self, TtySessionError> {
        // Borrowing deserialize (`&Value` implements `Deserializer`):
        // fails fast before a channel is allocated, and the owned
        // `params` still goes to `open_channel` (review #003 P15 — no
        // clone).
        use serde::Deserialize as _;
        NegotiateRequest::deserialize(&params)
            .map_err(|e| TtySessionError::InvalidParams(e.to_string()))?;
        let (channel_id, send, recv) = client
            .open_channel(
                crate::channels::OP_TTY_OPEN,
                params,
                crate::channels::TTY_ALPN,
            )
            .await
            .map_err(TtySessionError::from)?;
        debug!("tty: opened channel {channel_id} via channels");

        let remote_addr = client.manager().remote_addr();
        let source = alkcall::channels::source::channel_source(recv, send, remote_addr);
        let channel_conn =
            Connection::from_source(source, crate::channels::TTY_ALPN.as_bytes().to_vec());

        Self::from_bidi_stream_via(channel_conn).await
    }

    /// Shared inner: take a `BiStream`, write the negotiation frame,
    /// start the read pump. Used by both `connect_direct` and (after
    /// the channels open) `open_via_channels`.
    async fn from_bidi_stream(
        stream: alkcall::core::BiStream,
        negotiate: NegotiateRequest,
    ) -> Result<Self, TtySessionError> {
        let (read, write) = tokio::io::split(stream);
        Self::from_halves(read, write, negotiate).await
    }

    /// Like `from_bidi_stream` but takes the channel's `Connection`
    /// directly (the channels path already has the `Connection` from
    /// `Connection::from_source`). The negotiation already happened in
    /// the open op (ADR-009) — the stream starts in raw-chunk mode.
    async fn from_bidi_stream_via(channel_conn: Connection) -> Result<Self, TtySessionError> {
        let stream = channel_conn.accept_bi().await?;
        let (read, write) = tokio::io::split(stream);
        Self::from_halves_raw(read, write).await
    }

    /// Core inner: take a read half and a write half, write the
    /// negotiation frame, spawn the read pump, return the session.
    async fn from_halves<R, W>(
        read: R,
        write: W,
        negotiate: NegotiateRequest,
    ) -> Result<Self, TtySessionError>
    where
        R: AsyncRead + Send + Unpin + 'static,
        W: AsyncWrite + Send + Unpin + 'static,
    {
        let boxed_write: Box<dyn AsyncWrite + Send + Unpin> = Box::new(write);
        let mut neg_writer = NegotiationWriter::new(boxed_write);
        let body = serde_json::to_vec(&negotiate)?;
        neg_writer.write_frame(&body).await?;
        let writer = ChunkWriter::new(neg_writer.into_inner());

        let mut reader = ChunkReader::new(read);
        // Disambiguate the first response frame (ADR-052 §5): a
        // negotiation error frame's 4-byte length prefix starts with
        // `0x00`, while a raw chunk's first byte is a `stream_type` in
        // `{1, 2, 4}` (the server never sends `0` or `3`). If the server
        // rejected the negotiation, read the error frame and return
        // `NegotiationRejected`; otherwise hand the peeked reader to the
        // read pump — `ChunkReader` tracks the peeked byte, so the pump's
        // `read_chunk()` completes that first chunk.
        match reader.peek_stream_type().await {
            Ok(0x00) => {
                return Err(read_negotiation_error(reader.into_inner()).await);
            }
            Ok(_) => {}
            Err(RawError::ConnectionClosed) => {
                // The server closed cleanly without a response. Fall
                // through to the read pump, which resolves `NoExitChunk`.
            }
            Err(e) => return Err(TtySessionError::Wire(e)),
        }

        Self::start_pump(writer, reader)
    }

    /// Core inner for the channels path (ADR-009): the negotiation
    /// already happened in the open op — the stream is already in
    /// raw-chunk mode. The peek still applies as defense-in-depth: a
    /// `0x00`-prefixed error frame means the producer rejected the
    /// session post-open (only reachable from a no-establisher
    /// registration — the establisher rejects every semantic class
    /// before the reply, and allocation runs there too since alkcall
    /// 0.6 / ADR-010 as amended), while a raw chunk (`stream_type` in
    /// `{1, 2, 4}`) is the normal first data.
    async fn from_halves_raw<R, W>(read: R, write: W) -> Result<Self, TtySessionError>
    where
        R: AsyncRead + Send + Unpin + 'static,
        W: AsyncWrite + Send + Unpin + 'static,
    {
        let writer = ChunkWriter::new(Box::new(write) as Box<dyn AsyncWrite + Send + Unpin>);
        let mut reader = ChunkReader::new(read);
        match reader.peek_stream_type().await {
            Ok(0x00) => {
                return Err(read_negotiation_error(reader.into_inner()).await);
            }
            Ok(_) => {}
            Err(RawError::ConnectionClosed) => {
                // The server closed cleanly without a response. Fall
                // through to the read pump, which resolves `NoExitChunk`.
            }
            Err(e) => return Err(TtySessionError::Wire(e)),
        }

        Self::start_pump(writer, reader)
    }

    /// Wire up the exit watch + stdout/stderr channels and spawn the
    /// read pump. Shared by `from_halves` and `from_halves_raw`. The
    /// reader may arrive with a peeked first byte (the negotiation
    /// disambiguation peek); `ChunkReader` tracks that state, so the
    /// pump reads every chunk — including the first — via `read_chunk`.
    fn start_pump<R>(
        writer: ChunkWriter<Box<dyn AsyncWrite + Send + Unpin>>,
        reader: ChunkReader<R>,
    ) -> Result<Self, TtySessionError>
    where
        R: AsyncRead + Send + Unpin + 'static,
    {
        let (stdout_tx, stdout_rx) = mpsc::channel::<Bytes>(64);
        let (stderr_tx, stderr_rx) = mpsc::channel::<Bytes>(64);
        let (exit_tx, exit_rx) = tokio::sync::watch::channel::<Option<ExitOutcome>>(None);

        let read_pump = tokio::spawn(read_pump(reader, stdout_tx, stderr_tx, exit_tx));

        Ok(Self {
            writer: Mutex::new(writer),
            stdout_rx: Mutex::new(Some(stdout_rx)),
            stderr_rx: Mutex::new(Some(stderr_rx)),
            exit_code: exit_rx,
            _read_pump: read_pump,
        })
    }

    /// Send stdin bytes. Writes a stdin chunk (stream_type 0) with the
    /// given payload. An empty `bytes` writes a zero-length sentinel
    /// (client stdin EOF — see `tty-wire.md` §"Sentinels"); callers
    /// that want to signal EOF should use [`Self::close_stdin`] instead,
    /// which is explicit.
    pub async fn send_stdin(&self, bytes: Bytes) -> Result<(), TtySessionError> {
        let mut writer = self.writer.lock().await;
        let chunk = Chunk::stdin(bytes);
        writer.write_chunk(&chunk).await?;
        Ok(())
    }

    /// Close stdin — send a zero-length stdin chunk (the EOF sentinel)
    /// and flush. The server closes the backend's stdin but keeps
    /// pumping stdout + the exit chunk (see `tty-wire.md` §"Stdin
    /// Closure").
    pub async fn close_stdin(&self) -> Result<(), TtySessionError> {
        let mut writer = self.writer.lock().await;
        let chunk = Chunk::stdin(Bytes::new());
        writer.write_chunk(&chunk).await?;
        Ok(())
    }

    /// Send a `Resize` control message (client→server, `STREAM_CTRL_IN`).
    /// `pixel_width`/`pixel_height` default to 0 (most terminals don't
    /// report pixel dimensions).
    pub async fn resize(
        &self,
        cols: u16,
        rows: u16,
        pixel_width: u16,
        pixel_height: u16,
    ) -> Result<(), TtySessionError> {
        let mut writer = self.writer.lock().await;
        let msg = ControlMessage::Resize {
            cols,
            rows,
            pixel_width,
            pixel_height,
        };
        let json = msg.to_json()?;
        let chunk = Chunk::ctrl_in(json);
        writer.write_chunk(&chunk).await?;
        Ok(())
    }

    /// Send a `Signal` control message (client→server,
    /// `STREAM_CTRL_IN`). `name` is an uppercase string from the
    /// supported set (`HUP`, `INT`, `QUIT`, `TERM`, `KILL`, `USR1`,
    /// `USR2`, `TSTP`, `CONT` — see [`crate::control::signal_from_name`]).
    /// Unknown names are forwarded as-is; the backend decides whether
    /// to ignore or fall back to its default kill.
    pub async fn signal(&self, name: &str) -> Result<(), TtySessionError> {
        let mut writer = self.writer.lock().await;
        let msg = ControlMessage::Signal {
            name: name.to_string(),
        };
        let json = msg.to_json()?;
        let chunk = Chunk::ctrl_in(json);
        writer.write_chunk(&chunk).await?;
        Ok(())
    }

    /// Get the stdout stream. Returns a `Stream<Item = Bytes>` that
    /// yields stdout chunks as they arrive. The stream ends when the
    /// server's stdout reaches EOF — the zero-length stdout sentinel
    /// chunk (`tty-wire.md` §"Sentinels") terminates the stream and is
    /// NOT yielded as an item.
    ///
    /// This consumes the stdout receiver — calling it twice returns
    /// an empty stream the second time (the receiver is behind a
    /// `Mutex<Option<...>>` and is taken).
    pub async fn recv_stdout(&self) -> Pin<Box<dyn Stream<Item = Bytes> + Send>> {
        let mut guard = self.stdout_rx.lock().await;
        if let Some(rx) = guard.take() {
            return Box::pin(futures::stream::unfold(rx, |mut rx| async move {
                // The zero-length stdout chunk is the server's
                // "drained" sentinel; end the stream on it rather than
                // delivering it as an item (review #003 P11).
                match rx.recv().await {
                    Some(bytes) if bytes.is_empty() => None,
                    Some(bytes) => Some((bytes, rx)),
                    None => None,
                }
            }));
        }
        // Already taken — return an empty stream (documented on
        // `recv_stdout`).
        Box::pin(futures::stream::empty())
    }

    /// Get the stderr stream. `None` for PTY-mode backends
    /// (stdout/stderr merged into stdout by the kernel PTY), or if
    /// already taken. The stream ends when the server's stderr reaches
    /// EOF. Stderr has no sentinel on the wire (the adapter's stderr
    /// pump emits none — only stdout carries the drained sentinel), so
    /// this stream ends when the read pump terminates.
    pub async fn recv_stderr(&self) -> Option<Pin<Box<dyn Stream<Item = Bytes> + Send>>> {
        let mut guard = self.stderr_rx.lock().await;
        let rx = guard.take()?;
        Some(Box::pin(futures::stream::unfold(rx, |mut rx| async move {
            rx.recv().await.map(|bytes| (bytes, rx))
        })))
    }

    /// Await the `Exit` control chunk and return the process exit
    /// code. The exit chunk is the last control chunk before stream
    /// close (ADR-055). `code` is `i32` matching
    /// `std::process::ExitStatus::code()`; negative values are
    /// signal-terminated, `-1` is the adapter's best-effort "backend
    /// could not determine the exit code" sentinel.
    ///
    /// Returns [`TtySessionError::NoExitChunk`] if the session ends
    /// (server closed the stream) before an exit chunk is observed.
    pub async fn wait(&self) -> Result<i32, TtySessionError> {
        let mut rx = self.exit_code.clone();
        // If the pump already resolved before we started waiting, the
        // watch's current value is `Some(_)` — return it.
        {
            let borrow = rx.borrow();
            if let Some(outcome) = borrow.as_ref() {
                return outcome_to_result(outcome);
            }
        }
        // Wait for the read pump to send a value.
        rx.changed()
            .await
            .map_err(|_| TtySessionError::NoExitChunk)?;
        let borrow = rx.borrow();
        match borrow.as_ref() {
            Some(outcome) => outcome_to_result(outcome),
            None => Err(TtySessionError::NoExitChunk),
        }
    }
}

impl Drop for TtySession {
    fn drop(&mut self) {
        self._read_pump.abort();
    }
}

/// Map a resolved [`ExitOutcome`] to the `wait()` result.
fn outcome_to_result(outcome: &ExitOutcome) -> Result<i32, TtySessionError> {
    match outcome {
        ExitOutcome::Exited(code) => Ok(*code),
        ExitOutcome::MalformedExit(msg) => Err(TtySessionError::MalformedExitChunk(msg.clone())),
        ExitOutcome::NoExitChunk => Err(TtySessionError::NoExitChunk),
    }
}

/// Read a negotiation error frame (ADR-052 §5) from the raw transport
/// and map it to [`TtySessionError::NegotiationRejected`]. The caller
/// has already peeked the first byte (`0x00`); this reads the remaining
/// 3 length bytes, the body, and parses the `{"error": "...", ...}`
/// JSON. Any non-`error` fields are collected into the `fields` map.
async fn read_negotiation_error<R>(mut read: R) -> TtySessionError
where
    R: AsyncRead + Unpin,
{
    use tokio::io::AsyncReadExt;

    let mut len_rest = [0u8; 3];
    if let Err(e) = read.read_exact(&mut len_rest).await {
        return TtySessionError::Wire(RawError::Io(e));
    }
    let length = u32::from_be_bytes([0x00, len_rest[0], len_rest[1], len_rest[2]]) as usize;
    let mut body = vec![0u8; length];
    if let Err(e) = read.read_exact(&mut body).await {
        return TtySessionError::Wire(RawError::Io(e));
    }

    let value: serde_json::Value = match serde_json::from_slice(&body) {
        Ok(v) => v,
        Err(_) => {
            return TtySessionError::NegotiationRejected {
                error: String::from_utf8_lossy(&body).into_owned(),
                fields: HashMap::new(),
            };
        }
    };

    let mut fields = HashMap::new();
    let mut error = String::new();
    if let Some(obj) = value.as_object() {
        for (k, v) in obj {
            if k == "error" {
                if let Some(s) = v.as_str() {
                    error = s.to_string();
                }
            } else if let Some(s) = v.as_str() {
                fields.insert(k.clone(), s.to_string());
            }
        }
    }
    TtySessionError::NegotiationRejected { error, fields }
}

/// The read pump: reads chunks off the bidi stream's read half and
/// routes them to the stdout/stderr/exit channels. The pump owns the
/// `ChunkReader`. When the stream closes (clean EOF or transport
/// error), the pump drains the stdout/stderr channels (drops the
/// senders) and resolves the exit watch with `NoExitChunk` if no
/// `Exit` chunk was observed, or with the parsed exit code if one was.
///
/// The pump distinguishes the four stream types:
/// - `STREAM_STDOUT` (1) → stdout channel
/// - `STREAM_STDERR` (2) → stderr channel
/// - `STREAM_CTRL_OUT` (4) → control message; parses as
///   `ControlMessage` and, if it's `Exit`, resolves the exit watch
/// - `STREAM_STDIN` (0) / `STREAM_CTRL_IN` (3) — client→server only;
///   the server shouldn't send these, the pump ignores them (with a
///   debug log)
async fn read_pump<R>(
    mut reader: ChunkReader<R>,
    stdout_tx: mpsc::Sender<Bytes>,
    stderr_tx: mpsc::Sender<Bytes>,
    exit_tx: tokio::sync::watch::Sender<Option<ExitOutcome>>,
) where
    R: AsyncRead + Send + Unpin + 'static,
{
    let mut exit_resolved = false;
    loop {
        let read = reader.read_chunk().await;
        match read {
            Ok(chunk) => match chunk.stream_type {
                crate::wire::STREAM_STDOUT => {
                    if stdout_tx.send(chunk.bytes).await.is_err() {
                        debug!("tty: stdout receiver dropped, ending read pump");
                        break;
                    }
                }
                crate::wire::STREAM_STDERR => {
                    if stderr_tx.send(chunk.bytes).await.is_err() {
                        debug!("tty: stderr receiver dropped, ending read pump");
                        break;
                    }
                }
                STREAM_CTRL_OUT => match ControlMessage::from_slice(&chunk.bytes) {
                    Ok(ControlMessage::Exit { code }) => {
                        let _ = exit_tx.send(Some(ExitOutcome::Exited(code)));
                        exit_resolved = true;
                        debug!("tty: exit chunk received, code={code}");
                        break;
                    }
                    Ok(other) => {
                        debug!("tty: ignoring non-exit control on STREAM_CTRL_OUT: {other:?}");
                    }
                    Err(e) => {
                        let _ = exit_tx.send(Some(ExitOutcome::MalformedExit(e.to_string())));
                        exit_resolved = true;
                        break;
                    }
                },
                STREAM_CTRL_IN | crate::wire::STREAM_STDIN => {
                    debug!(
                        "tty: ignoring client→server stream_type {} from server",
                        chunk.stream_type
                    );
                }
                other => {
                    debug!("tty: ignoring unknown stream_type {other}");
                }
            },
            Err(RawError::ConnectionClosed) => {
                debug!("tty: read pump: stream closed");
                break;
            }
            Err(e) => {
                warn!("tty: read pump: chunk read error: {e}");
                break;
            }
        }
    }
    // Drain the channels (drop the senders so the receivers observe
    // EOF). If no exit chunk was observed, resolve the watch with
    // `NoExitChunk`.
    drop(stdout_tx);
    drop(stderr_tx);
    if !exit_resolved {
        let _ = exit_tx.send(Some(ExitOutcome::NoExitChunk));
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::backend::{MockBackend, TtyBackend};
    use crate::negotiation::NegotiateRequest;
    use alkcall::core::auth::Identity;
    use alkcall::core::types::Connection;
    use futures::stream::StreamExt;
    use std::collections::HashMap;
    use std::sync::Arc;
    use tokio::io::duplex;

    /// Build a `NegotiateRequest` for tests — minimal valid shape.
    fn test_negotiate(backend: &str) -> NegotiateRequest {
        NegotiateRequest {
            carriage: "raw".to_string(),
            backend: backend.to_string(),
            tty: None,
            cmd: vec!["true".to_string()],
            cwd: None,
            env: HashMap::new(),
            backend_params: serde_json::Map::new(),
        }
    }

    /// Build a `TtySession` wired to a `drive_session` over a duplex
    /// pair, with a `MockBackend` registered as `"mock"`. The session
    /// is the client side; the server side runs `drive_session` on
    /// the other half of the duplex. Returns the session and the
    /// server-side task handle.
    async fn wire_session_and_server(
        backend: Arc<dyn TtyBackend>,
        identity: Option<Identity>,
    ) -> (TtySession, tokio::task::JoinHandle<()>) {
        let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
        backends.insert("mock".to_string(), backend);
        let backends = Arc::new(backends);

        let (client, server) = duplex(64 * 1024);
        let (server_read, server_write) = tokio::io::split(server);
        let server_task = tokio::spawn(async move {
            crate::adapter::drive_session(server_write, server_read, backends, None, identity)
                .await;
        });

        let client_conn = Connection::from_bidi(client, b"alk/tty".to_vec(), None);
        let session = TtySession::connect_direct(client_conn, test_negotiate("mock"))
            .await
            .expect("connect_direct");
        (session, server_task)
    }

    #[tokio::test]
    async fn connect_direct_writes_negotiation_frame() {
        // The session writes the negotiation frame on construction.
        // If the server side reads it and dispatches to the backend,
        // the session is wired. A `MockBackend` resolves to exit 0
        // immediately; the session's `wait()` should observe it.
        let backend = Arc::new(MockBackend::with_exit_code(0));
        let identity = Some(Identity {
            id: "alice".to_string(),
            scopes: vec![crate::adapter::TTY_OPEN_SCOPE.to_string()],
            resources: HashMap::new(),
        });
        let (session, _server) = wire_session_and_server(backend, identity).await;
        let code = tokio::time::timeout(std::time::Duration::from_secs(5), session.wait())
            .await
            .expect("wait didn't time out")
            .expect("wait returns exit code");
        assert_eq!(code, 0);
    }

    /// A backend whose exit is held back until the test releases it, so
    /// typed-method writes happen while the session data plane is
    /// definitively open (the exit-resolves-immediately `MockBackend`
    /// races them against session teardown — review #003 P15's
    /// `input_pump.abort()` made that race fail loudly).
    struct GatedBackend {
        release: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Receiver<()>>>>,
    }

    #[async_trait::async_trait]
    impl TtyBackend for GatedBackend {
        async fn allocate(
            &self,
            _params: &crate::backend::TtyParams,
        ) -> Result<crate::backend::TtyHandle, crate::backend::TtyError> {
            use crate::backend::{TtyControlHandle, TtyHandle};

            let (_stdout_tx, stdout_rx) = mpsc::channel::<Bytes>(8);
            let release = self.release.lock().await.take();

            let stdout: Pin<Box<dyn Stream<Item = Bytes> + Send>> =
                Box::pin(tokio_stream::wrappers::ReceiverStream::new(stdout_rx));
            let stdin: Box<dyn AsyncWrite + Send + Unpin> = Box::new(tokio::io::sink());
            let control = Some(TtyControlHandle::new(Arc::new(
                crate::backend::MockControl::default(),
            )));
            let exit_code: crate::backend::BoxFuture<Result<i32, crate::backend::TtyError>> =
                Box::pin(async move {
                    // Hold the exit until released (dropped = cancel path).
                    if let Some(rx) = release {
                        let _ = rx.await;
                    }
                    Ok(0)
                });

            Ok(TtyHandle {
                stdin,
                stdout,
                stderr: None,
                exit_code,
                control,
            })
        }
    }

    /// Wire a `GatedBackend` session; returns the release sender.
    async fn wire_gated_session() -> (
        TtySession,
        tokio::sync::oneshot::Sender<()>,
        tokio::task::JoinHandle<()>,
    ) {
        let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
        let backend = Arc::new(GatedBackend {
            release: Arc::new(tokio::sync::Mutex::new(Some(release_rx))),
        });
        let identity = Some(Identity {
            id: "alice".to_string(),
            scopes: vec![crate::adapter::TTY_OPEN_SCOPE.to_string()],
            resources: HashMap::new(),
        });
        let (session, server) = wire_session_and_server(backend, identity).await;
        (session, release_tx, server)
    }

    #[tokio::test]
    async fn send_stdin_round_trips_to_backend() {
        // `send_stdin`/`close_stdin` serialize and write while the
        // session is open (the adapter tests cover the stdin-to-backend
        // pump itself). The backend's exit is held until the writes are
        // done so the data plane cannot race them.
        let (session, release, _server) = wire_gated_session().await;
        session
            .send_stdin(Bytes::from_static(b"hello"))
            .await
            .expect("send_stdin");
        session.close_stdin().await.expect("close_stdin");
        release.send(()).unwrap();
        let code = tokio::time::timeout(std::time::Duration::from_secs(5), session.wait())
            .await
            .expect("wait didn't time out")
            .expect("wait returns exit code");
        assert_eq!(code, 0);
    }

    #[tokio::test]
    async fn resize_and_signal_dont_error() {
        // The session writes control chunks; whether the backend
        // receives them is the adapter's concern (covered by the
        // adapter tests). This test verifies the typed methods
        // serialize and write without error, with the exit held until
        // after the writes.
        let (session, release, _server) = wire_gated_session().await;
        session.resize(80, 24, 0, 0).await.expect("resize");
        session.signal("INT").await.expect("signal");
        release.send(()).unwrap();
        let code = tokio::time::timeout(std::time::Duration::from_secs(5), session.wait())
            .await
            .expect("wait didn't time out")
            .expect("wait returns exit code");
        assert_eq!(code, 0);
    }

    #[tokio::test]
    async fn recv_stdout_yields_backend_stdout() {
        // `MockBackend` doesn't pump stdout (it resolves exit
        // immediately), so the stdout stream should be empty. This
        // test verifies the stream API works and ends cleanly.
        let backend = Arc::new(MockBackend::with_exit_code(0));
        let identity = Some(Identity {
            id: "alice".to_string(),
            scopes: vec![crate::adapter::TTY_OPEN_SCOPE.to_string()],
            resources: HashMap::new(),
        });
        let (session, _server) = wire_session_and_server(backend, identity).await;
        let stdout = session.recv_stdout().await;
        let collected: Vec<Bytes> = stdout.collect().await;
        // The backend's stdout stream ends immediately (MockBackend
        // drops its stdout sender on exit), so the stream should be
        // empty or near-empty.
        assert!(
            collected.is_empty() || collected.iter().all(|b| b.is_empty()),
            "mock backend produces no stdout, got {collected:?}"
        );
    }

    #[tokio::test]
    async fn wait_returns_no_exit_chunk_when_server_drops_without_exit() {
        // If the server side drops the stream before sending an exit
        // chunk, `wait()` should return `NoExitChunk`. We simulate
        // this by wiring the session to a duplex where the "server"
        // reads the negotiation frame (so the client's write succeeds)
        // then drops without sending anything back.
        let (client, mut server) = duplex(64);
        let server_handle = tokio::spawn(async move {
            use tokio::io::AsyncReadExt;
            // Read the 4-byte length prefix + body so the client's
            // negotiation write succeeds (duplex buffers are small;
            // a partial write would block and the test would hang).
            let mut len_buf = [0u8; 4];
            let _ = server.read_exact(&mut len_buf).await;
            let len = u32::from_be_bytes(len_buf) as usize;
            let mut body = vec![0u8; len];
            let _ = server.read_exact(&mut body).await;
            // Drop `server` — the client's read pump hits EOF.
        });
        let client_conn = Connection::from_bidi(client, b"alk/tty".to_vec(), None);
        let session = TtySession::connect_direct(client_conn, test_negotiate("mock"))
            .await
            .expect("connect_direct");
        let result = tokio::time::timeout(std::time::Duration::from_secs(5), session.wait())
            .await
            .expect("wait didn't time out");
        assert!(matches!(result, Err(TtySessionError::NoExitChunk)));
        let _ = server_handle.await;
    }

    /// `connect_direct` with a `Connection` whose stream is broken
    /// (server half dropped) should return an error from the
    /// negotiation frame write (`BrokenPipe`).
    #[tokio::test]
    async fn connect_direct_errors_when_stream_is_broken() {
        let (client, server) = duplex(64);
        drop(server);
        let client_conn = Connection::from_bidi(client, b"alk/tty".to_vec(), None);
        let result = TtySession::connect_direct(client_conn, test_negotiate("mock")).await;
        assert!(
            result.is_err(),
            "construction should fail when the negotiation write hits a broken pipe"
        );
    }

    /// A backend that emits fixed stdout/stderr chunks before resolving
    /// exit, so the consumer's read-pump routing can be tested with
    /// real data (the `MockBackend` emits nothing).
    struct EmittingBackend {
        stdout: Vec<Bytes>,
        stderr: Vec<Bytes>,
        exit_code: i32,
    }

    #[async_trait::async_trait]
    impl TtyBackend for EmittingBackend {
        async fn allocate(
            &self,
            _params: &crate::backend::TtyParams,
        ) -> Result<crate::backend::TtyHandle, crate::backend::TtyError> {
            use crate::backend::{TtyControlHandle, TtyHandle};
            use tokio_stream::wrappers::ReceiverStream;

            let (stdout_tx, stdout_rx) = mpsc::channel::<Bytes>(8);
            let (stderr_tx, stderr_rx) = mpsc::channel::<Bytes>(8);
            let (_stdin_tx, _stdin_rx) = mpsc::channel::<Bytes>(8);
            let (exit_tx, exit_rx) =
                tokio::sync::oneshot::channel::<Result<i32, crate::backend::TtyError>>();

            let stdout = self.stdout.clone();
            let stderr = self.stderr.clone();
            let code = self.exit_code;
            tokio::spawn(async move {
                for b in stdout {
                    let _ = stdout_tx.send(b).await;
                }
                for b in stderr {
                    let _ = stderr_tx.send(b).await;
                }
                let _ = exit_tx.send(Ok(code));
            });

            let stdout: Pin<Box<dyn Stream<Item = Bytes> + Send>> =
                Box::pin(ReceiverStream::new(stdout_rx));
            let stderr: Option<Pin<Box<dyn Stream<Item = Bytes> + Send>>> =
                Some(Box::pin(ReceiverStream::new(stderr_rx)));
            let stdin: Box<dyn AsyncWrite + Send + Unpin> = Box::new(tokio::io::sink());
            let control = Some(TtyControlHandle::new(Arc::new(
                crate::backend::MockControl::default(),
            )));
            let exit_code: crate::backend::BoxFuture<Result<i32, crate::backend::TtyError>> =
                Box::pin(async move {
                    exit_rx
                        .await
                        .map_err(|_| crate::backend::TtyError::WaitFailed {
                            message: "exit sender dropped".to_string(),
                        })
                        .and_then(|r| r)
                });

            Ok(TtyHandle {
                stdin,
                stdout,
                stderr,
                exit_code,
                control,
            })
        }
    }

    /// The consumer's read pump routes stdout and stderr chunks to the
    /// correct channels (L2). `MockBackend` emits nothing, so this uses
    /// an `EmittingBackend` that produces real stdout/stderr data.
    #[tokio::test]
    async fn recv_stdout_and_stderr_route_backend_data() {
        let backend = Arc::new(EmittingBackend {
            stdout: vec![Bytes::from_static(b"out1"), Bytes::from_static(b"out2")],
            stderr: vec![Bytes::from_static(b"err1")],
            exit_code: 0,
        });
        let identity = Some(Identity {
            id: "alice".to_string(),
            scopes: vec![crate::adapter::TTY_OPEN_SCOPE.to_string()],
            resources: HashMap::new(),
        });
        let (session, _server) = wire_session_and_server(backend, identity).await;

        let stdout = session.recv_stdout().await;
        let collected: Vec<Bytes> = stdout.collect().await;
        // The adapter emits a zero-length stdout sentinel after the
        // backend stream ends; the stream terminates ON the sentinel
        // (P11), so no filtering is needed to see the data chunks.
        assert_eq!(
            collected,
            vec![Bytes::from_static(b"out1"), Bytes::from_static(b"out2")],
            "stdout chunks should route to the stdout stream"
        );

        let stderr = session.recv_stderr().await.expect("stderr present");
        let collected: Vec<Bytes> = stderr.collect().await;
        assert_eq!(
            collected,
            vec![Bytes::from_static(b"err1")],
            "stderr chunks should route to the stderr stream"
        );

        let code = tokio::time::timeout(std::time::Duration::from_secs(5), session.wait())
            .await
            .expect("wait didn't time out")
            .expect("wait returns exit code");
        assert_eq!(code, 0);
    }

    /// `wait()` surfaces a malformed exit chunk as
    /// `MalformedExitChunk`, not `NoExitChunk` (M2). The server sends a
    /// `STREAM_CTRL_OUT` chunk whose JSON fails to parse as a
    /// `ControlMessage`.
    #[tokio::test]
    async fn wait_returns_malformed_exit_chunk() {
        let (client, mut server) = duplex(64 * 1024);
        let server_handle = tokio::spawn(async move {
            use tokio::io::{AsyncReadExt, AsyncWriteExt};
            let mut len_buf = [0u8; 4];
            let _ = server.read_exact(&mut len_buf).await;
            let len = u32::from_be_bytes(len_buf) as usize;
            let mut body = vec![0u8; len];
            let _ = server.read_exact(&mut body).await;

            // Write a ctrl_out chunk with a malformed exit payload.
            let payload = br#"{"type":"exit","code":"not-a-number"}"#;
            let mut header = [0u8; 5];
            header[0] = crate::wire::STREAM_CTRL_OUT;
            header[1..].copy_from_slice(&(payload.len() as u32).to_be_bytes());
            let _ = server.write_all(&header).await;
            let _ = server.write_all(payload).await;
            let _ = server.flush().await;
        });
        let client_conn = Connection::from_bidi(client, b"alk/tty".to_vec(), None);
        let session = TtySession::connect_direct(client_conn, test_negotiate("mock"))
            .await
            .expect("connect_direct");
        let result = tokio::time::timeout(std::time::Duration::from_secs(5), session.wait())
            .await
            .expect("wait didn't time out");
        assert!(
            matches!(result, Err(TtySessionError::MalformedExitChunk(_))),
            "expected MalformedExitChunk, got {result:?}"
        );
        let _ = server_handle.await;
    }

    /// Client→server stream types (0 and 3) arriving from the server are
    /// protocol violations the read pump ignores — they must be skipped
    /// without ending the pump, desynchronizing framing, or disturbing
    /// the stdout routing. The server interleaves them with real stdout
    /// chunks, then sends a well-formed exit; the session should deliver
    /// exactly the stdout chunks and the exit code. (The first chunk
    /// must be a legitimate server→client type: a leading `0x00` byte
    /// is the negotiation-error disambiguation marker — ADR-052 §5 —
    /// and would surface `NegotiationRejected` before the pump starts.)
    #[tokio::test]
    async fn read_pump_ignores_client_to_server_stream_types_from_server() {
        let (client, mut server) = duplex(64 * 1024);
        let server_handle = tokio::spawn(async move {
            use tokio::io::{AsyncReadExt, AsyncWriteExt};
            let mut len_buf = [0u8; 4];
            let _ = server.read_exact(&mut len_buf).await;
            let len = u32::from_be_bytes(len_buf) as usize;
            let mut body = vec![0u8; len];
            let _ = server.read_exact(&mut body).await;

            async fn write_chunk(
                server: &mut tokio::io::DuplexStream,
                stream_type: u8,
                payload: &[u8],
            ) {
                let mut header = [0u8; 5];
                header[0] = stream_type;
                header[1..].copy_from_slice(&(payload.len() as u32).to_be_bytes());
                server.write_all(&header).await.unwrap();
                if !payload.is_empty() {
                    server.write_all(payload).await.unwrap();
                }
            }

            write_chunk(&mut server, crate::wire::STREAM_STDOUT, b"out1").await;
            write_chunk(
                &mut server,
                crate::wire::STREAM_STDIN,
                b"server-must-not-send-stdin",
            )
            .await;
            write_chunk(
                &mut server,
                crate::wire::STREAM_CTRL_IN,
                br#"{"type":"resize"}"#,
            )
            .await;
            write_chunk(&mut server, crate::wire::STREAM_STDOUT, b"out2").await;
            let exit = br#"{"type":"exit","code":3}"#;
            write_chunk(&mut server, crate::wire::STREAM_CTRL_OUT, exit).await;
            let _ = server.flush().await;
        });
        let client_conn = Connection::from_bidi(client, b"alk/tty".to_vec(), None);
        let session = TtySession::connect_direct(client_conn, test_negotiate("mock"))
            .await
            .expect("connect_direct");

        let stdout = session.recv_stdout().await;
        let collected: Vec<Bytes> = stdout.collect().await;
        // The stream ends on the drained sentinel (P11): the client→server
        // stream types (0 and 3) from the server are discarded, and the
        // empty stdout item is consumed as the terminator, not yielded.
        assert_eq!(
            collected,
            vec![Bytes::from_static(b"out1"), Bytes::from_static(b"out2")],
            "stdout routing must be unaffected by the ignored chunks"
        );

        let code = tokio::time::timeout(std::time::Duration::from_secs(5), session.wait())
            .await
            .expect("wait didn't time out")
            .expect("wait returns exit code");
        assert_eq!(code, 3);
        let _ = server_handle.await;
    }

    /// `connect_direct` returns `NegotiationRejected` when the server
    /// rejects the negotiation with an error frame (M1). The server
    /// reads the negotiation frame and writes back a length-prefixed
    /// `{"error":"unknown_backend","backend":"nope"}` frame.
    #[tokio::test]
    async fn connect_direct_returns_negotiation_rejected() {
        let (client, mut server) = duplex(64 * 1024);
        let server_handle = tokio::spawn(async move {
            use tokio::io::{AsyncReadExt, AsyncWriteExt};
            let mut len_buf = [0u8; 4];
            let _ = server.read_exact(&mut len_buf).await;
            let len = u32::from_be_bytes(len_buf) as usize;
            let mut body = vec![0u8; len];
            let _ = server.read_exact(&mut body).await;

            let err_body = br#"{"error":"unknown_backend","backend":"nope"}"#;
            let _ = server
                .write_all(&(err_body.len() as u32).to_be_bytes())
                .await;
            let _ = server.write_all(err_body).await;
            let _ = server.flush().await;
        });
        let client_conn = Connection::from_bidi(client, b"alk/tty".to_vec(), None);
        let result = TtySession::connect_direct(client_conn, test_negotiate("mock")).await;
        match result {
            Err(TtySessionError::NegotiationRejected { error, fields }) => {
                assert_eq!(error, "unknown_backend");
                assert_eq!(fields.get("backend").map(String::as_str), Some("nope"));
            }
            Ok(_) => panic!("expected NegotiationRejected, got Ok(session)"),
            Err(other) => panic!("expected NegotiationRejected, got {other:?}"),
        }
        let _ = server_handle.await;
    }

    // --- channels consumer path (L3, over the shared `testing` harness) ----

    use crate::testing::{tty_identity, wire_client_and_server};

    /// The negotiate params as the open op's `input` (same JSON shape
    /// the direct path serializes from `NegotiateRequest`).
    fn test_open_params(backend: &str) -> serde_json::Value {
        serde_json::json!({
            "carriage": "raw",
            "backend": backend,
            "cmd": ["true"],
        })
    }

    fn mock_backends(code: i32) -> Arc<HashMap<String, Arc<dyn TtyBackend>>> {
        let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
        backends.insert(
            "mock".to_string(),
            Arc::new(MockBackend::with_exit_code(code)),
        );
        Arc::new(backends)
    }

    /// End-to-end (L3): `TtySession::open_via_channels` opens the
    /// channel through the real `register_openable` producer path and
    /// resolves the session. The negotiation travels in the open op's
    /// `input` (ADR-009); the channel stream starts in raw-chunk mode.
    #[tokio::test]
    async fn open_via_channels_end_to_end_negotiates_and_waits() {
        let client =
            wire_client_and_server(mock_backends(0), None, Some(tty_identity("alice"))).await;

        let session = tokio::time::timeout(
            std::time::Duration::from_secs(10),
            TtySession::open_via_channels(&client, test_open_params("mock")),
        )
        .await
        .expect("open_via_channels timed out")
        .expect("session opens");

        let code = tokio::time::timeout(std::time::Duration::from_secs(10), session.wait())
            .await
            .expect("wait didn't time out")
            .expect("wait returns exit code");
        assert_eq!(code, 0);
    }

    /// The ADR-049 §5 migration gate: an unknown backend is rejected by
    /// the producer's establisher before the reply — the open op fails
    /// with `channel:open_failed` + `details.reason == "unknown_resource"`
    /// (the SSH "channel never exists opener-side" contract: no
    /// `channel_id`, no session), instead of the pre-0.2.0 post-open
    /// in-band `unknown_backend` negotiation error frame
    /// (`NegotiationRejected`). The direct-ALPN path keeps the in-band
    /// frame (two transports, two contracts — see
    /// `connect_direct_returns_negotiation_rejected`).
    #[tokio::test]
    async fn open_via_channels_surfaces_negotiation_rejected() {
        let client =
            wire_client_and_server(mock_backends(0), None, Some(tty_identity("alice"))).await;

        let result = tokio::time::timeout(
            std::time::Duration::from_secs(10),
            TtySession::open_via_channels(&client, test_open_params("nope")),
        )
        .await
        .expect("open_via_channels timed out");
        match result {
            Err(TtySessionError::ChannelsOpen(
                alkcall::channels::client::ChannelOpenError::CallFailed { error },
            )) => {
                assert_eq!(error.code, "channel:open_failed");
                let details = error.details.expect("details carry the reason");
                assert_eq!(details["reason"], "unknown_resource");
                assert_eq!(details["message"], "unknown backend: nope");
            }
            Ok(_) => panic!("expected channel:open_failed, got Ok(session)"),
            Err(other) => panic!("expected ChannelsOpen(channel:open_failed), got {other:?}"),
        }
        // The SSH contract, consumer-visible: no channel was adopted
        // locally (a failed open never returns a channel_id).
        assert!(
            client.manager().channel_ids().iter().all(|&id| id == 0),
            "no data channel survives a failed establishment (channel 0 is the call channel)"
        );
    }

    /// The open op's `input` is schema-validated by the registry
    /// (alkcall 0.4): a params value missing the required `backend`
    /// field is rejected before any handler runs, so the failure is a
    /// `ChannelsOpen` error (the open op fails), not a session error.
    #[tokio::test]
    async fn open_via_channels_fails_fast_on_schema_invalid_params() {
        let client =
            wire_client_and_server(mock_backends(0), None, Some(tty_identity("alice"))).await;

        let result = tokio::time::timeout(
            std::time::Duration::from_secs(10),
            TtySession::open_via_channels(
                &client,
                serde_json::json!({ "carriage": "raw", "cmd": ["true"] }),
            ),
        )
        .await
        .expect("open_via_channels timed out");
        assert!(
            matches!(result, Err(TtySessionError::InvalidParams(_))),
            "schema-invalid params fail at the local NegotiateRequest parse (fail-fast, pre-open)"
        );
    }

    /// `open_via_channels` with params that fail the local
    /// `NegotiateRequest` parse (not just the schema): fails fast,
    /// before a channel is allocated.
    #[tokio::test]
    async fn open_via_channels_fails_fast_on_unparseable_params() {
        let client =
            wire_client_and_server(mock_backends(0), None, Some(tty_identity("alice"))).await;

        let result = tokio::time::timeout(
            std::time::Duration::from_secs(10),
            TtySession::open_via_channels(
                &client,
                serde_json::json!({ "carriage": 42, "backend": "mock", "cmd": ["true"] }),
            ),
        )
        .await
        .expect("open_via_channels timed out");
        assert!(
            matches!(result, Err(TtySessionError::InvalidParams(_))),
            "unparseable params must fail before the open op"
        );
    }

    /// End-to-end with an emitting backend (L2's backend): stdout and
    /// stderr route through the channels data plane to the consumer's
    /// typed streams — the full producer+consumer channels path with
    /// real data.
    #[tokio::test]
    async fn open_via_channels_routes_backend_stdout_and_stderr() {
        let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
        backends.insert(
            "mock".to_string(),
            Arc::new(EmittingBackend {
                stdout: vec![Bytes::from_static(b"ch-out")],
                stderr: vec![Bytes::from_static(b"ch-err")],
                exit_code: 3,
            }),
        );
        let client =
            wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await;

        let session = tokio::time::timeout(
            std::time::Duration::from_secs(10),
            TtySession::open_via_channels(&client, test_open_params("mock")),
        )
        .await
        .expect("open_via_channels timed out")
        .expect("session opens");

        let stdout = session.recv_stdout().await;
        let collected: Vec<Bytes> = stdout.collect().await;
        // The stream ends on the drained sentinel (P11).
        assert_eq!(
            collected,
            vec![Bytes::from_static(b"ch-out")],
            "stdout should route through the channels data plane"
        );

        let stderr = session.recv_stderr().await.expect("stderr present");
        let collected: Vec<Bytes> = stderr.collect().await;
        assert_eq!(
            collected,
            vec![Bytes::from_static(b"ch-err")],
            "stderr should route through the channels data plane"
        );

        let code = tokio::time::timeout(std::time::Duration::from_secs(10), session.wait())
            .await
            .expect("wait didn't time out")
            .expect("wait returns exit code");
        assert_eq!(code, 3);
    }
}