microvms_edges/session/shell.rs
1// SPDX-License-Identifier: Apache-2.0
2//! The client half of `microvm shell`: a real PTY carried over the endpoint's shell
3//! WebSocket. Issue #69, Option B — a thin client over the platform's session, not a
4//! line-mode wrapper over exec.
5//!
6//! Everything protocol-shaped in this module is measured, not inferred
7//! (`docs/PLATFORM.md`, "The shell endpoint is a real PTY over a WebSocket", 2026-08-15):
8//!
9//! * The endpoint is the VM's ordinary endpoint URL, opened as a WebSocket with **two**
10//! subprotocols — the marker and the authentication value carrying the shell JWE.
11//! There is **no port subprotocol**: the shell is not a port, and offering
12//! `lambda-microvms.port.<n>` neither helps nor hurts, so this client offers exactly
13//! what suffices.
14//! * The server sends one **text** frame on connect
15//! (`{"type":"session_init","session_id":"<uuid>"}`), then **binary** frames carry raw
16//! terminal bytes both ways. Client input is raw keystrokes: `0x03` raises SIGINT in
17//! the guest, `"exit\n"` ends the shell.
18//! * A resize is a JSON **control frame**, `{"type":"resize","cols":120,"rows":40}`.
19//! Before the first one the guest's `stty size` reports `0 0`, which is why
20//! [`run_shell`] sends the caller's initial size unprompted.
21//! * A clean exit is close code **1000**, reason `shell exited`.
22//!
23//! # Sharp edge 1: a malformed control frame becomes keystrokes, not an error
24//!
25//! The platform does not reject an unrecognized control frame — it injects it into the
26//! shell as literal input. Measured: `{"type":"window_size",...}` produced
27//! `bash: type:window_sizestty: command not found` in the session. A typo in a control
28//! message corrupts the terminal instead of erroring, so **every** outbound text frame
29//! goes through [`validate_control_frame`] before it is sent, and the only constructor
30//! for one is [`resize_frame`]. A frame the validator does not recognize is refused on
31//! this side of the wire, where the failure has a message instead of a corrupted prompt.
32//!
33//! # Sharp edge 2: there is no exit-status channel, and this module does not invent one
34//!
35//! The shell's exit is a WebSocket close, full stop. There are no exec ids, no separated
36//! stdout/stderr, and no exit codes — which is precisely why this is its own surface and
37//! not a method on the exec path (TRAP-11). [`ShellEnd`] reports how the *transport*
38//! ended and nothing about the last command's status; a caller who needs a command's
39//! status must ask the shell itself (`echo $?`) and read the byte stream. Anything else
40//! would be a fabricated exit code.
41//!
42//! # The token gates the handshake, not the session
43//!
44//! The shell JWE comes from `CreateMicrovmShellAuthToken`
45//! ([`microvms_app::control::ControlPlane::mint_shell_auth_token`]) and is spent once, on the
46//! upgrade. An established session outlives the token's expiry, so there is no refresh
47//! loop here — one mint, one handshake, one PTY.
48
49use futures_util::{SinkExt as _, StreamExt as _};
50use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _};
51use tokio_tungstenite::tungstenite::client::IntoClientRequest as _;
52use tokio_tungstenite::tungstenite::{Message, protocol::CloseFrame};
53
54use microvms_app::error::{Error, ErrorKind};
55use microvms_app::session::proxy::{WS_AUTH_SUBPROTOCOL_PREFIX, WS_SUBPROTOCOL};
56
57/// The close reason the platform sends when the shell process exits.
58///
59/// Recorded for diagnostics rather than matched on: the **code** (1000) is what
60/// classifies a close as the shell exiting, because a reason string is free text the
61/// platform could reword without an API change.
62pub const SHELL_EXITED_REASON: &str = "shell exited";
63
64/// Bytes read from the local input per relayed frame.
65///
66/// The tunnel's chunk, for the tunnel's reason: matching the daemon-side relay keeps
67/// either side from being the one that fragments a stream. Interactive input is single
68/// keystrokes almost always, so the size only matters for a paste.
69pub const SHELL_CHUNK_BYTES: usize = 64 * 1024;
70
71/// How a shell session ended. A value rather than an error, because every variant is a
72/// session that did its transport job — see the module docs on the missing exit-status
73/// channel before adding anything status-shaped here.
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub enum ShellEnd {
76 /// The platform closed with code 1000: the shell process exited. Says nothing about
77 /// the last command's status — there is no channel that could.
78 Exited { reason: String },
79 /// The local input reached EOF, so this side hung up on a shell that was still
80 /// running. Distinct from [`ShellEnd::Exited`] because "the user closed their end"
81 /// and "the shell ended" are different facts to report.
82 LocalClosed,
83 /// The session ended some other way: a dropped connection, a proxy-side refusal, or
84 /// a close code this system does not originate. 1006 with no reason is what every
85 /// endpoint-proxy failure collapses to (the tunnel's measurement holds here too).
86 Disconnected { code: u16, reason: String },
87}
88
89/// What [`run_shell`] hands back: the session's identity and how it ended.
90#[derive(Clone, Debug)]
91pub struct ShellOutcome {
92 /// The `session_id` from the `session_init` frame, when one arrived. `None` means
93 /// the stream started without one — protocol drift worth surfacing, not an error
94 /// worth killing a working session over.
95 pub session_id: Option<String>,
96 pub end: ShellEnd,
97}
98
99/// The `wss://` URL for a VM's shell: the endpoint itself, no path.
100///
101/// The same scheme rule as [`crate::session::tunnel::tunnel_url`], for the same reason:
102/// the platform hands back a bare hostname, and defaulting a missing prefix to plaintext
103/// would put the shell credential on the wire in clear. Explicit `http://`/`ws://` stays
104/// plaintext for the local-daemon test case.
105pub fn shell_url(endpoint: &str) -> String {
106 let host = endpoint
107 .trim_end_matches('/')
108 .trim_start_matches("https://")
109 .trim_start_matches("http://")
110 .trim_start_matches("wss://")
111 .trim_start_matches("ws://");
112 let scheme = if endpoint.starts_with("http://") || endpoint.starts_with("ws://") {
113 "ws"
114 } else {
115 "wss"
116 };
117 format!("{scheme}://{host}/")
118}
119
120/// The two subprotocols a shell handshake offers: the marker and the credential.
121///
122/// Two, not three. Every other WebSocket through the proxy adds
123/// `lambda-microvms.port.<n>`, and the shell deliberately does not: the token names no
124/// port (`CreateMicrovmShellAuthToken` has no `allowedPorts` member) and the measured
125/// handshake succeeds without one. Offering the port value anyway would work today —
126/// measured as "neither helps nor hurts" — and would teach every reader that the shell
127/// is a port, which is the misunderstanding this crate keeps having to correct.
128pub fn shell_subprotocols(shell_jwe: &str) -> [String; 2] {
129 [
130 WS_SUBPROTOCOL.to_string(),
131 format!("{WS_AUTH_SUBPROTOCOL_PREFIX}{shell_jwe}"),
132 ]
133}
134
135/// A resize control frame, validated before it leaves this function.
136///
137/// The one control frame the platform speaks, built here and nowhere else. The
138/// validation call is not redundant with construction: it is the guard that stays in
139/// the path if this function grows a second frame shape, and it is the same check
140/// [`run_shell`] applies to anything it sends — so a frame that would be injected into
141/// the shell as keystrokes (sharp edge 1) cannot be produced by this crate at all.
142pub fn resize_frame(cols: u16, rows: u16) -> Result<String, Error> {
143 if cols == 0 || rows == 0 {
144 // A 0×0 resize is what the guest already reports before any resize arrives;
145 // sending one would set the size every full-screen program treats as "unknown".
146 // The caller passing 0 read a size from something that is not a terminal.
147 return Err(Error::new(
148 ErrorKind::InvalidArg,
149 format!(
150 "a terminal cannot be resized to {cols}x{rows}: a zero dimension is the \
151 'no size known' value, not a size. Reading the size from something that \
152 is not a terminal returns this."
153 ),
154 ));
155 }
156 let frame = format!("{{\"type\":\"resize\",\"cols\":{cols},\"rows\":{rows}}}");
157 validate_control_frame(&frame)?;
158 Ok(frame)
159}
160
161/// Refuses any outbound text frame the platform would not recognize as a control frame.
162///
163/// The platform's failure mode for an unrecognized control frame is to type it into the
164/// shell (sharp edge 1, measured: `{"type":"window_size",...}` became literal keystrokes
165/// and a corrupted prompt). So the rule here is a strict allowlist of what the platform
166/// is measured to accept — exactly `{"type":"resize","cols":<n>,"rows":<n>}` — and
167/// anything else is refused client-side, where the failure is a sentence rather than
168/// garbage in someone's terminal.
169pub fn validate_control_frame(text: &str) -> Result<(), Error> {
170 let refuse = |why: String| {
171 Error::new(
172 ErrorKind::InvalidArg,
173 format!(
174 "refusing to send a control frame the platform would not recognize: {why}. An \
175 unrecognized control frame is not rejected — it is injected into the shell as \
176 literal keystrokes (docs/PLATFORM.md, 2026-08-15). Frame: {text}"
177 ),
178 )
179 };
180
181 let value: serde_json::Value =
182 serde_json::from_str(text).map_err(|err| refuse(format!("it is not JSON ({err})")))?;
183 let object = value
184 .as_object()
185 .ok_or_else(|| refuse("it is JSON but not an object".to_string()))?;
186
187 match object.get("type").and_then(serde_json::Value::as_str) {
188 Some("resize") => {}
189 Some(other) => {
190 return Err(refuse(format!(
191 "\"{other}\" is not a control type the platform speaks; \"resize\" is the \
192 only one"
193 )));
194 }
195 None => return Err(refuse("it has no string \"type\" member".to_string())),
196 }
197 for key in object.keys() {
198 if key != "type" && key != "cols" && key != "rows" {
199 return Err(refuse(format!(
200 "\"{key}\" is not a member of a resize frame"
201 )));
202 }
203 }
204 for dimension in ["cols", "rows"] {
205 let looks_sized = object
206 .get(dimension)
207 .and_then(serde_json::Value::as_u64)
208 .is_some_and(|size| size >= 1 && size <= u64::from(u16::MAX));
209 if !looks_sized {
210 return Err(refuse(format!(
211 "\"{dimension}\" must be an integer in 1..=65535"
212 )));
213 }
214 }
215 Ok(())
216}
217
218/// Opens the shell and relays raw terminal bytes until one side ends the session.
219///
220/// `input`/`output` are any async byte streams, so the CLI drives this with the process's
221/// raw-mode stdin/stdout and a test drives it with pipes. `resize` is a watch channel of
222/// `(cols, rows)`: the value at entry is sent immediately — the guest reports `0 0` until
223/// something does — and every change afterwards becomes one validated control frame.
224///
225/// Output is flushed per frame, because the reader on the other side of `output` is a
226/// human watching a prompt: a byte held in a buffer is a keystroke that appears to have
227/// been eaten.
228pub async fn run_shell<I, O>(
229 mut input: I,
230 mut output: O,
231 endpoint: &str,
232 shell_jwe: &str,
233 mut resize: tokio::sync::watch::Receiver<(u16, u16)>,
234) -> Result<ShellOutcome, Error>
235where
236 I: AsyncRead + Unpin,
237 O: AsyncWrite + Unpin,
238{
239 let url = shell_url(endpoint);
240 let mut request = url.as_str().into_client_request().map_err(|err| {
241 Error::new(
242 ErrorKind::InvalidArg,
243 format!("{url} is not a usable WebSocket URL: {err}"),
244 )
245 })?;
246 {
247 // The two platform values as one comma-separated list, exactly as the tunnel
248 // offers its three. No `authorization` header: the shell terminates at the
249 // platform's PTY, not at the daemon, so there is no bearer check on this path
250 // and the shell JWE in the subprotocol is the whole credential.
251 let offered = shell_subprotocols(shell_jwe);
252 request.headers_mut().insert(
253 "sec-websocket-protocol",
254 offered.join(", ").parse().map_err(|err| {
255 Error::new(
256 ErrorKind::Unexpected,
257 format!("the shell subprotocols are not a legal header value: {err}"),
258 )
259 })?,
260 );
261 }
262
263 let (mut socket, _response) =
264 tokio_tungstenite::connect_async(request)
265 .await
266 .map_err(|err| {
267 Error::new(
268 ErrorKind::Unexpected,
269 format!(
270 "the shell handshake failed: {err}. Every endpoint-proxy WebSocket \
271 failure is close code 1006 with no reason, so if this names nothing: \
272 the shell token may have expired before the connect (it gates the \
273 handshake, not the session), or the endpoint may be wrong. A VM \
274 launched without SHELL_INGRESS fails earlier, at the token mint."
275 ),
276 )
277 })?;
278
279 // The caller's size, sent before any keystroke: until a resize arrives the guest's
280 // line discipline believes the terminal is 0x0, and the first full-screen program
281 // the user starts would render into that. `borrow_and_update` rather than `borrow` +
282 // a later `mark_unchanged`: the caller's poller is already publishing into this
283 // channel, and clearing the change flag in a separate step would eat a resize that
284 // landed in between — the stored value would then be what future sizes are compared
285 // against, and the missed resize would never be re-sent.
286 let (cols, rows) = *resize.borrow_and_update();
287 if let Ok(frame) = resize_frame(cols, rows) {
288 // A (0, 0) initial value is not an error here: the caller read the size from
289 // something that is not a terminal (a pipe, a CI runner), and the session is
290 // still usable — the guest just keeps its size-unknown default.
291 socket
292 .send(Message::Text(frame.into()))
293 .await
294 .map_err(|err| {
295 Error::new(
296 ErrorKind::Unexpected,
297 format!("the shell closed before the initial resize could be sent: {err}"),
298 )
299 })?;
300 }
301
302 let mut session_id: Option<String> = None;
303 let mut buffer = vec![0_u8; SHELL_CHUNK_BYTES];
304 loop {
305 tokio::select! {
306 // Raw keystrokes from the local terminal, framed as binary, sent as-is.
307 read = input.read(&mut buffer) => match read {
308 Ok(0) => {
309 // The local side is done sending. A close frame rather than a drop,
310 // so the platform tears the PTY down instead of holding a session
311 // whose keyboard has vanished.
312 let _ = socket.send(Message::Close(None)).await;
313 let _ = output.flush().await;
314 return Ok(ShellOutcome { session_id, end: ShellEnd::LocalClosed });
315 }
316 Ok(count) => {
317 if socket
318 .send(Message::Binary(buffer[..count].to_vec().into()))
319 .await
320 .is_err()
321 {
322 let _ = output.flush().await;
323 return Ok(ShellOutcome {
324 session_id,
325 end: ShellEnd::Disconnected {
326 code: 1006,
327 reason: "the connection dropped while sending input".to_string(),
328 },
329 });
330 }
331 }
332 Err(err) => {
333 return Err(Error::new(
334 ErrorKind::Unexpected,
335 format!("reading the local terminal failed: {err}"),
336 ));
337 }
338 },
339 // A size change becomes one validated control frame. The validation is the
340 // sharp-edge-1 guard: nothing leaves this loop as text without passing it.
341 changed = resize.changed() => {
342 if changed.is_err() {
343 // The sender is gone; the session continues at its current size.
344 continue;
345 }
346 let (cols, rows) = *resize.borrow_and_update();
347 let frame = match resize_frame(cols, rows) {
348 Ok(frame) => frame,
349 // A transient 0 from a size probe is not worth ending a session over.
350 Err(_) => continue,
351 };
352 if socket.send(Message::Text(frame.into())).await.is_err() {
353 let _ = output.flush().await;
354 return Ok(ShellOutcome {
355 session_id,
356 end: ShellEnd::Disconnected {
357 code: 1006,
358 reason: "the connection dropped while sending a resize".to_string(),
359 },
360 });
361 }
362 },
363 // Terminal bytes from the guest, written through and flushed per frame.
364 frame = socket.next() => match frame {
365 Some(Ok(Message::Binary(bytes))) => {
366 if output.write_all(&bytes).await.is_err()
367 || output.flush().await.is_err()
368 {
369 let _ = socket.send(Message::Close(None)).await;
370 return Ok(ShellOutcome { session_id, end: ShellEnd::LocalClosed });
371 }
372 }
373 Some(Ok(Message::Text(text))) => {
374 // The one modeled text frame is the greeting. Anything after it is
375 // protocol drift; written through rather than swallowed, because
376 // hiding bytes from a terminal user is worse than showing them
377 // something unexpected.
378 if session_id.is_none()
379 && let Some(id) = parse_session_init(&text)
380 {
381 session_id = Some(id);
382 continue;
383 }
384 if output.write_all(text.as_bytes()).await.is_err()
385 || output.flush().await.is_err()
386 {
387 let _ = socket.send(Message::Close(None)).await;
388 return Ok(ShellOutcome { session_id, end: ShellEnd::LocalClosed });
389 }
390 }
391 Some(Ok(Message::Close(frame))) => {
392 let _ = output.flush().await;
393 return Ok(ShellOutcome {
394 session_id,
395 end: classify_close(frame.as_ref()),
396 });
397 }
398 Some(Ok(_)) => continue,
399 Some(Err(err)) => {
400 let _ = output.flush().await;
401 return Ok(ShellOutcome {
402 session_id,
403 end: ShellEnd::Disconnected {
404 code: 1006,
405 reason: format!("the connection failed mid-session: {err}"),
406 },
407 });
408 }
409 None => {
410 let _ = output.flush().await;
411 return Ok(ShellOutcome {
412 session_id,
413 end: ShellEnd::Disconnected {
414 code: 1006,
415 reason: "the connection ended with no close frame".to_string(),
416 },
417 });
418 }
419 },
420 }
421 }
422}
423
424/// Reads a `session_init` greeting, or `None` when the text is something else.
425///
426/// Deliberately narrow: only `{"type":"session_init",...}` with a string `session_id`
427/// parses. A text frame that is almost-but-not a greeting goes to the caller's output,
428/// per the drift stance in [`run_shell`].
429fn parse_session_init(text: &str) -> Option<String> {
430 let value: serde_json::Value = serde_json::from_str(text).ok()?;
431 if value.get("type")?.as_str()? != "session_init" {
432 return None;
433 }
434 Some(value.get("session_id")?.as_str()?.to_string())
435}
436
437/// Reads the platform's close frame into a [`ShellEnd`].
438///
439/// Code 1000 is the shell exiting — the **code** classifies, the reason is carried for
440/// display. A close with no frame at all is *not* the measured clean exit, so it is
441/// reported as a disconnect (1005, "no status received") rather than dressed up as one:
442/// the caller cannot know whether the shell ended or the proxy hung up.
443fn classify_close(frame: Option<&CloseFrame>) -> ShellEnd {
444 let Some(frame) = frame else {
445 return ShellEnd::Disconnected {
446 code: 1005,
447 reason: "the session closed with no close frame".to_string(),
448 };
449 };
450 let code: u16 = frame.code.into();
451 if code == 1000 {
452 return ShellEnd::Exited {
453 reason: frame.reason.to_string(),
454 };
455 }
456 ShellEnd::Disconnected {
457 code,
458 reason: frame.reason.to_string(),
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 #[test]
467 fn a_bare_endpoint_host_becomes_wss_at_the_root() {
468 // The tunnel's downgrade rule, applied to the shell: a missing prefix must not
469 // put the shell credential on a plaintext socket.
470 assert_eq!(shell_url("vm-abc.example.aws"), "wss://vm-abc.example.aws/");
471 assert_eq!(
472 shell_url("https://vm-abc.example.aws/"),
473 "wss://vm-abc.example.aws/"
474 );
475 // Explicit plaintext is a caller saying they know — the local test case.
476 assert_eq!(shell_url("http://127.0.0.1:9000"), "ws://127.0.0.1:9000/");
477 }
478
479 /// Two subprotocols, and **no port value** — the absence is the measured protocol,
480 /// not an omission. The marker stays aliased to the protocol crate's constant.
481 ///
482 /// **Falsification** — watched fail 2026-08-31: appending
483 /// `lambda-microvms.port.9000` to [`shell_subprotocols`] fails the length assertion,
484 /// and the no-port scan fails with the offending value in the message.
485 #[test]
486 fn the_shell_offers_two_subprotocols_and_no_port() {
487 let offered = shell_subprotocols("opaque-jwe");
488 assert_eq!(offered.len(), 2);
489 assert_eq!(offered[0], "lambda-microvms");
490 assert_eq!(offered[1], "lambda-microvms.authentication.opaque-jwe");
491 for value in &offered {
492 assert!(
493 !value.starts_with("lambda-microvms.port."),
494 "the shell is not a port, so no handshake value may name one: {value}"
495 );
496 }
497 }
498
499 /// The resize frame, byte for byte the measured shape. Pinned as a literal because
500 /// the platform parses this with something that types unrecognized frames into the
501 /// shell — the frame's exact spelling is the contract.
502 #[test]
503 fn the_resize_frame_is_the_measured_shape_byte_for_byte() {
504 assert_eq!(
505 resize_frame(120, 40).expect("a legal size"),
506 r#"{"type":"resize","cols":120,"rows":40}"#
507 );
508 }
509
510 /// A zero dimension is refused with the reason: 0×0 is the guest's "no size known"
511 /// state, not a size.
512 #[test]
513 fn a_zero_dimension_is_refused() {
514 for (cols, rows) in [(0, 40), (120, 0), (0, 0)] {
515 let error = resize_frame(cols, rows).expect_err("zero is not a size");
516 assert_eq!(error.kind(), ErrorKind::InvalidArg);
517 }
518 }
519
520 /// **Sharp edge 1, as a guard.** The exact frame the measurement watched corrupt a
521 /// session — `{"type":"window_size",...}` — is refused client-side, along with every
522 /// other shape the platform would type into the shell as keystrokes.
523 ///
524 /// **Falsification** — watched fail 2026-08-31: making `validate_control_frame`
525 /// accept any object with a `"type"` member passes the `window_size` case straight
526 /// through, and this test fails on it.
527 #[test]
528 fn a_control_frame_the_platform_would_not_recognize_is_refused_before_the_wire() {
529 // The measured corruption, verbatim from docs/PLATFORM.md.
530 let measured = r#"{"type":"window_size","cols":120,"rows":40}"#;
531 let error = validate_control_frame(measured).expect_err("window_size corrupts");
532 let message = error.to_string();
533 assert!(message.contains("window_size"), "{message}");
534 assert!(
535 message.contains("literal keystrokes"),
536 "the message must say what the platform does with it: {message}"
537 );
538
539 // The rest of the refusal surface: not JSON, not an object, no type, an extra
540 // member, and a dimension that is missing, non-numeric, zero, or oversized.
541 for bad in [
542 "resize 120 40",
543 "[1,2,3]",
544 r#"{"cols":120,"rows":40}"#,
545 r#"{"type":"resize","cols":120,"rows":40,"pad":1}"#,
546 r#"{"type":"resize","cols":120}"#,
547 r#"{"type":"resize","cols":"120","rows":40}"#,
548 r#"{"type":"resize","cols":0,"rows":40}"#,
549 r#"{"type":"resize","cols":120,"rows":65536}"#,
550 ] {
551 assert!(validate_control_frame(bad).is_err(), "must refuse: {bad}");
552 }
553
554 // And the one legal frame still passes, or the guard is a wall rather than a door.
555 validate_control_frame(r#"{"type":"resize","cols":120,"rows":40}"#)
556 .expect("the measured frame is legal");
557 }
558
559 /// Close classification: 1000 is the shell exiting whatever the reason says; any
560 /// other code — and a missing frame — is a disconnect, never dressed up as an exit.
561 #[test]
562 fn only_code_1000_is_the_shell_exiting() {
563 let clean = CloseFrame {
564 code: 1000.into(),
565 reason: SHELL_EXITED_REASON.into(),
566 };
567 assert_eq!(
568 classify_close(Some(&clean)),
569 ShellEnd::Exited {
570 reason: "shell exited".to_string()
571 }
572 );
573
574 // The reason is display material, not the classifier: a reworded platform
575 // string must not turn clean exits into failures.
576 let reworded = CloseFrame {
577 code: 1000.into(),
578 reason: "session complete".into(),
579 };
580 assert!(matches!(
581 classify_close(Some(&reworded)),
582 ShellEnd::Exited { .. }
583 ));
584
585 // No frame is not the measured clean exit, and saying "exited" would fake a
586 // fact nobody has.
587 assert_eq!(
588 classify_close(None),
589 ShellEnd::Disconnected {
590 code: 1005,
591 reason: "the session closed with no close frame".to_string()
592 }
593 );
594
595 let dropped = CloseFrame {
596 code: 1006.into(),
597 reason: String::new().into(),
598 };
599 assert!(matches!(
600 classify_close(Some(&dropped)),
601 ShellEnd::Disconnected { code: 1006, .. }
602 ));
603 }
604
605 /// The greeting parses, and near-greetings do not: a text frame that is almost a
606 /// `session_init` goes to the terminal rather than being silently eaten.
607 #[test]
608 fn the_session_init_greeting_parses_and_near_misses_do_not() {
609 assert_eq!(
610 parse_session_init(r#"{"type":"session_init","session_id":"abc-123"}"#),
611 Some("abc-123".to_string())
612 );
613 for near_miss in [
614 r#"{"type":"session_start","session_id":"abc"}"#,
615 r#"{"type":"session_init"}"#,
616 r#"{"type":"session_init","session_id":7}"#,
617 "not json",
618 ] {
619 assert_eq!(parse_session_init(near_miss), None, "{near_miss}");
620 }
621 }
622}