Skip to main content

turbo_debug_console/
proto.rs

1// Copyright (c) 2026 Enzo Lombardi
2// SPDX-License-Identifier: MIT
3
4//! The `HELLO` handshake.
5//!
6//! ```text
7//! client -> control 7878 :  HELLO <version> <kind> <name>\n
8//! server ->              :  PORT <n>\n              (or  ERR <reason>\n)
9//! ```
10//!
11//! `<kind>` is `tokens` or `trace`.
12//!
13//! A first line that is not a `HELLO` is not an error: the connection is
14//! treated as a raw anonymous stream defaulting to the `tokens` kind, so
15//! `nc host 7878 < capture.txt` works with no ceremony.
16
17/// Maximum session-name length, in bytes.
18pub const NAME_MAX: usize = 64;
19
20/// The protocol version this console speaks. The single source of truth for
21/// what a `HELLO` must claim to be accepted.
22pub const PROTOCOL_VERSION: u32 = 1;
23
24/// What kind of stream a session renders.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum StreamKind {
27    /// A model token stream, rendered through the `trace-stream` pipeline.
28    Tokens,
29    /// A `tracing-subscriber` JSON-lines record stream.
30    Trace,
31}
32
33impl StreamKind {
34    /// Parses the `<kind>` field of a `HELLO`.
35    ///
36    /// # Errors
37    /// [`HelloError::UnknownStreamKind`] for anything but `tokens` or `trace`.
38    pub fn parse(s: &str) -> Result<Self, HelloError> {
39        match s {
40            "tokens" => Ok(Self::Tokens),
41            "trace" => Ok(Self::Trace),
42            other => Err(HelloError::UnknownStreamKind(other.to_string())),
43        }
44    }
45}
46
47/// Why a line was not a usable `HELLO`.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum HelloError {
50    /// Not a handshake at all — treat the connection as a raw stream.
51    NotHello,
52    /// A handshake with an unusable name.
53    BadName,
54    /// A `HELLO` with no version field at all. Nothing is deployed yet, so
55    /// this ambiguity is cheapest to close now rather than silently
56    /// assuming version 1.
57    MissingVersion,
58    /// A version field that isn't a bare non-negative integer.
59    BadVersion,
60    /// A well-formed version this console does not speak.
61    UnsupportedVersion(u32),
62    /// The old two-field `HELLO <version> <name>` form: there is no way to
63    /// tell whether the missing field was meant to be a kind or a name, and
64    /// guessing is exactly the ambiguity this error avoids.
65    MissingStreamKind,
66    /// A well-formed kind field that isn't `tokens` or `trace`.
67    UnknownStreamKind(String),
68}
69
70impl HelloError {
71    /// The line to send back, without its newline.
72    #[must_use]
73    pub fn wire(&self) -> String {
74        match self {
75            Self::NotHello => "ERR not a handshake".to_string(),
76            Self::BadName => "ERR bad name".to_string(),
77            Self::MissingVersion => "ERR missing protocol version".to_string(),
78            Self::BadVersion => "ERR bad protocol version".to_string(),
79            Self::UnsupportedVersion(v) => format!("ERR unsupported protocol version {v}"),
80            Self::MissingStreamKind => "ERR missing stream kind".to_string(),
81            Self::UnknownStreamKind(k) => format!("ERR unknown stream kind {k}"),
82        }
83    }
84}
85
86/// Parses one handshake line, returning the stream kind and session name.
87///
88/// # Errors
89/// [`HelloError::NotHello`] when the line has no `HELLO ` prefix;
90/// [`HelloError::MissingVersion`] when there is no version field at all;
91/// [`HelloError::BadVersion`] when the version field is not a bare
92/// non-negative integer; [`HelloError::UnsupportedVersion`] when the version
93/// is well-formed but not [`PROTOCOL_VERSION`]; [`HelloError::MissingStreamKind`]
94/// when there is no kind field (the old two-field form);
95/// [`HelloError::UnknownStreamKind`] when the kind is neither `tokens` nor
96/// `trace`; [`HelloError::BadName`] when the name is empty, longer than
97/// [`NAME_MAX`], or contains anything but printable non-space ASCII.
98pub fn parse_hello(line: &str) -> Result<(StreamKind, String), HelloError> {
99    let line = line.trim_end_matches(['\r', '\n']);
100    let rest = line.strip_prefix("HELLO ").ok_or(HelloError::NotHello)?;
101
102    let (version, rest) = rest.split_once(' ').ok_or(HelloError::MissingVersion)?;
103    if version.is_empty() {
104        return Err(HelloError::MissingVersion);
105    }
106    let version: u32 = version.parse().map_err(|_| HelloError::BadVersion)?;
107    if version != PROTOCOL_VERSION {
108        return Err(HelloError::UnsupportedVersion(version));
109    }
110
111    let (kind, name) = rest.split_once(' ').ok_or(HelloError::MissingStreamKind)?;
112    let kind = StreamKind::parse(kind)?;
113
114    if name.is_empty() || name.len() > NAME_MAX {
115        return Err(HelloError::BadName);
116    }
117    if !name.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
118        return Err(HelloError::BadName);
119    }
120    Ok((kind, name.to_string()))
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn accepts_a_well_formed_tokens_hello() {
129        assert_eq!(
130            parse_hello("HELLO 1 tokens build-agent").unwrap(),
131            (StreamKind::Tokens, "build-agent".to_string())
132        );
133    }
134
135    #[test]
136    fn accepts_a_well_formed_trace_hello() {
137        assert_eq!(
138            parse_hello("HELLO 1 trace myapp").unwrap(),
139            (StreamKind::Trace, "myapp".to_string())
140        );
141    }
142
143    #[test]
144    fn trailing_cr_is_tolerated() {
145        assert_eq!(
146            parse_hello("HELLO 1 tokens x\r").unwrap(),
147            (StreamKind::Tokens, "x".to_string())
148        );
149    }
150
151    #[test]
152    fn a_non_hello_line_is_not_an_error_but_a_raw_stream() {
153        assert!(matches!(
154            parse_hello("hello there"),
155            Err(HelloError::NotHello)
156        ));
157        assert!(matches!(
158            parse_hello("{\"tok\":1}"),
159            Err(HelloError::NotHello)
160        ));
161    }
162
163    #[test]
164    fn hello_with_no_version_is_missing_version_not_assumed_v1() {
165        assert!(matches!(
166            parse_hello("HELLO build-agent"),
167            Err(HelloError::MissingVersion)
168        ));
169    }
170
171    #[test]
172    fn non_numeric_version_is_bad_version_not_a_fallback() {
173        assert!(matches!(
174            parse_hello("HELLO v1 tokens build-agent"),
175            Err(HelloError::BadVersion)
176        ));
177        assert!(matches!(
178            parse_hello("HELLO -1 tokens build-agent"),
179            Err(HelloError::BadVersion)
180        ));
181    }
182
183    #[test]
184    fn unsupported_version_is_rejected_by_number() {
185        assert_eq!(
186            parse_hello("HELLO 2 tokens build-agent"),
187            Err(HelloError::UnsupportedVersion(2))
188        );
189        assert_eq!(
190            HelloError::UnsupportedVersion(2).wire(),
191            "ERR unsupported protocol version 2"
192        );
193    }
194
195    /// The old two-field `HELLO <version> <name>` form is a distinct, honest
196    /// error — not silently defaulted to `tokens`, and not confused with a
197    /// bad-name rejection.
198    #[test]
199    fn the_old_two_field_form_is_missing_stream_kind() {
200        assert_eq!(
201            parse_hello("HELLO 1 build-agent"),
202            Err(HelloError::MissingStreamKind)
203        );
204        assert_eq!(
205            HelloError::MissingStreamKind.wire(),
206            "ERR missing stream kind"
207        );
208    }
209
210    #[test]
211    fn an_unknown_stream_kind_is_rejected_by_name() {
212        assert_eq!(
213            parse_hello("HELLO 1 bogus build-agent"),
214            Err(HelloError::UnknownStreamKind("bogus".to_string()))
215        );
216        assert_eq!(
217            HelloError::UnknownStreamKind("bogus".to_string()).wire(),
218            "ERR unknown stream kind bogus"
219        );
220    }
221
222    #[test]
223    fn empty_oversized_and_whitespace_names_are_rejected() {
224        assert!(matches!(
225            parse_hello("HELLO 1 tokens "),
226            Err(HelloError::BadName)
227        ));
228        assert!(matches!(
229            parse_hello("HELLO 1 tokens a b"),
230            Err(HelloError::BadName)
231        ));
232        let long = "x".repeat(65);
233        assert!(matches!(
234            parse_hello(&format!("HELLO 1 tokens {long}")),
235            Err(HelloError::BadName)
236        ));
237        assert!(parse_hello(&format!("HELLO 1 tokens {}", "x".repeat(64))).is_ok());
238    }
239
240    #[test]
241    fn non_printable_names_are_rejected() {
242        assert!(matches!(
243            parse_hello("HELLO 1 tokens na\u{7}me"),
244            Err(HelloError::BadName)
245        ));
246        assert!(matches!(
247            parse_hello("HELLO 1 tokens café"),
248            Err(HelloError::BadName)
249        ));
250    }
251}