turbo_debug_console/registry.rs
1// Copyright (c) 2026 Enzo Lombardi
2// SPDX-License-Identifier: MIT
3
4//! Named, reconnectable stream sessions over loopback TCP.
5//!
6//! One control listener performs the handshake and allocates a dedicated data
7//! listener per session. A session outlives its data socket, so a client that
8//! drops can reconnect to the same port and rejoin the same window with its
9//! transcript intact.
10//!
11//! Thread growth is unbounded by design at this scope: one thread per control
12//! connection, one per session's accept loop, and one per attached data
13//! connection. Acceptable given the expected session counts; not a resource
14//! pool.
15
16use std::collections::HashMap;
17use std::io::{BufRead, BufReader, Read, Write};
18use std::net::{TcpListener, TcpStream};
19use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
20use std::sync::mpsc::{Receiver, Sender, channel};
21use std::sync::{Arc, Mutex};
22use std::time::{Duration, Instant};
23
24use crate::proto::{HelloError, StreamKind, parse_hello};
25
26/// How long the handshake line may take to arrive.
27const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
28/// Read chunk size for a data socket.
29const READ_CHUNK: usize = 8192;
30
31/// Stable identifier for a session, independent of its name.
32pub type SessionId = u64;
33
34/// Something the UI needs to know about.
35#[derive(Debug, Clone)]
36pub enum ServerEvent {
37 /// A new session exists; open a window for it.
38 Opened {
39 id: SessionId,
40 name: String,
41 port: u16,
42 kind: StreamKind,
43 },
44 /// A data socket just attached to a session (`live` flipped false ->
45 /// true). Fires for the ordinary first attach of a brand-new session
46 /// *and* every later reattach — `reattached` tells the two apart so the
47 /// UI draws its "-- reconnected --" rule only for a genuine rejoin.
48 /// This subsumes the old handshake-level `Reconnected` event: a
49 /// repeat `HELLO` only ever matters to the UI once the client's data
50 /// socket actually attaches, so attach is the single source of truth
51 /// for "connected" (see `.superpowers/sdd/lifecycle-fixes-report.md`,
52 /// defect 1).
53 Attached { id: SessionId, reattached: bool },
54 /// Stream bytes for a session.
55 Bytes { id: SessionId, data: Vec<u8> },
56 /// The data socket closed; the session and its port stay alive.
57 Disconnected { id: SessionId },
58 /// The session's idle TTL expired and it was dropped; close its window.
59 /// Sent by [`Server::reap`], on the same channel as every other
60 /// lifecycle event.
61 Closed { id: SessionId },
62}
63
64#[derive(Debug)]
65struct Session {
66 id: SessionId,
67 port: u16,
68 /// True while a data socket is attached; guards against two writers.
69 live: Arc<AtomicBool>,
70 /// Bumped by one on every successful attach (never reset). A value of
71 /// `1` means "first ever attach" (so `Attached.reattached` is false);
72 /// anything higher is a genuine reattach. A `LiveGuard` captures the
73 /// value at its own attach and, on drop, only tears the attachment
74 /// down if this counter still matches — otherwise a newer attachment
75 /// already owns the session and the old, asynchronously-observed EOF
76 /// must not clobber it (defect 3: reconnect race).
77 generation: Arc<AtomicU64>,
78 /// Set when the data socket detaches (or the session is first created,
79 /// before anything ever attaches); cleared the instant a client
80 /// attaches — via a fresh `HELLO` reconnect or a new data-socket
81 /// accept — so a session currently in use is never a reap candidate.
82 idle_since: Option<Instant>,
83 /// Told to `true` by [`Server::reap`] to stop this session's data-port
84 /// accept loop (unused, always `false`, for an anonymous session, which
85 /// has no listener of its own — see the `NotHello` branch of
86 /// `handle_control`).
87 shutdown: Arc<AtomicBool>,
88}
89
90/// Shared teardown for one attached data connection: whatever ends `pump`
91/// (clean EOF, read error, or a panic unwinding through the caller), the
92/// live flag must come back down and `idle_since` must be set — a stuck
93/// `true` would permanently refuse every future reconnect attempt for this
94/// session, which is the exact failure this design exists to prevent. A
95/// guard makes that unconditional, and is shared by the named-session
96/// accept loop and the anonymous raw-stream path so both age out the same
97/// way (see finding 3 in `.superpowers/sdd/task-9-report.md`).
98struct LiveGuard {
99 sessions: Arc<Mutex<HashMap<String, Session>>>,
100 name: String,
101 tx: Sender<ServerEvent>,
102 id: SessionId,
103 /// The generation this guard's attachment owns; see `Session::generation`.
104 generation: u64,
105}
106
107impl Drop for LiveGuard {
108 fn drop(&mut self) {
109 // The live-flag flip, the idle_since update and the "was this guard
110 // outrun by a newer attach" check must happen as one atomic step
111 // under the session-map lock — not as separate unguarded accesses —
112 // or a newer attacher's own lock-held transaction (see
113 // `open_or_reuse`'s accept loop) could interleave with this one and
114 // reintroduce exactly the torn read/write defect 3 exists to close.
115 let mut map = self.sessions.lock().unwrap();
116 let Some(s) = map.get_mut(&self.name) else {
117 return;
118 };
119 if s.generation.load(Ordering::SeqCst) != self.generation {
120 // A newer attachment has already taken over this session; it
121 // owns `live` and `idle_since` now. Tearing them down here
122 // would be exactly the stale-guard bug: it would mark a
123 // currently-attached session both not-live and idle, and emit
124 // a spurious Disconnected for a connection that never left.
125 return;
126 }
127 s.live.store(false, Ordering::SeqCst);
128 s.idle_since = Some(Instant::now());
129 drop(map);
130 let _ = self.tx.send(ServerEvent::Disconnected { id: self.id });
131 }
132}
133
134/// The listening server.
135///
136/// `bind` spawns a background thread that owns the control [`TcpListener`]
137/// and accepts connections for the life of the process; dropping `Server`
138/// does **not** stop that thread or close the control port — there is
139/// currently no shutdown handshake for the control listener itself. Only
140/// per-session data listeners are torn down early, and only through
141/// [`Server::reap`]. This is a deliberate, narrower scope than "dropping it
142/// stops accepting" would suggest; widening it is future work, not a claim
143/// this code already makes good on.
144#[derive(Debug)]
145pub struct Server {
146 control_port: u16,
147 sessions: Arc<Mutex<HashMap<String, Session>>>,
148 tx: Sender<ServerEvent>,
149 rx: Receiver<ServerEvent>,
150}
151
152impl Server {
153 /// Binds the control port. Pass `0` to let the OS choose (tests do).
154 ///
155 /// # Errors
156 /// Returns the OS error when the address cannot be bound.
157 pub fn bind(port: u16) -> std::io::Result<Self> {
158 let control = TcpListener::bind(("127.0.0.1", port))?;
159 let control_port = control.local_addr()?.port();
160 let (tx, rx) = channel();
161 let sessions: Arc<Mutex<HashMap<String, Session>>> = Arc::new(Mutex::new(HashMap::new()));
162
163 let s = Arc::clone(&sessions);
164 let t = tx.clone();
165 std::thread::spawn(move || {
166 for stream in control.incoming().flatten() {
167 let s = Arc::clone(&s);
168 let t = t.clone();
169 std::thread::spawn(move || handle_control(stream, &s, &t));
170 }
171 });
172
173 Ok(Self {
174 control_port,
175 sessions,
176 tx,
177 rx,
178 })
179 }
180
181 /// The port clients send `HELLO` to.
182 #[must_use]
183 pub fn control_port(&self) -> u16 {
184 self.control_port
185 }
186
187 /// Events for the UI to drain.
188 #[must_use]
189 pub fn events(&self) -> &Receiver<ServerEvent> {
190 &self.rx
191 }
192
193 /// Number of sessions with a live data socket.
194 ///
195 /// # Panics
196 /// If the internal session-map mutex is poisoned.
197 #[must_use]
198 pub fn live_count(&self) -> usize {
199 self.sessions
200 .lock()
201 .unwrap()
202 .values()
203 .filter(|s| s.live.load(Ordering::SeqCst))
204 .count()
205 }
206
207 /// Drops sessions that have been detached longer than `ttl`, sending a
208 /// [`ServerEvent::Closed`] for each one on the same channel as every
209 /// other lifecycle event — the coherent way for a caller draining
210 /// [`Server::events`] to learn what to close, whether it comes from a
211 /// live connection or from reaping.
212 ///
213 /// Each reaped session's data-port listener thread is told to stop and
214 /// its listener is dropped, releasing the port: a session's TCP port
215 /// does not outlive the session. Simply dropping a `TcpListener` while
216 /// another thread is blocked in `accept()` does not reliably wake that
217 /// thread (the behaviour is platform-dependent), so a shutdown flag is
218 /// set first and then a throwaway self-connect forces one more
219 /// iteration of the accept loop, which observes the flag and exits.
220 ///
221 /// A connected session, or one that has reconnected since it last
222 /// detached, never expires: `idle_since` is `None` in both cases.
223 ///
224 /// # Panics
225 /// If the internal session-map mutex is poisoned.
226 pub fn reap(&mut self, ttl: Duration) {
227 let mut sessions = self.sessions.lock().unwrap();
228 let mut reaped: Vec<(SessionId, u16, Arc<AtomicBool>)> = Vec::new();
229 sessions.retain(|_, s| {
230 let expired = s
231 .idle_since
232 .is_some_and(|t| t.elapsed() > ttl && !s.live.load(Ordering::SeqCst));
233 if expired {
234 reaped.push((s.id, s.port, Arc::clone(&s.shutdown)));
235 }
236 !expired
237 });
238 drop(sessions);
239
240 for (id, port, shutdown) in reaped {
241 wake_and_shutdown(id, port, &shutdown);
242 let _ = self.tx.send(ServerEvent::Closed { id });
243 }
244 }
245
246 /// Tears a session down immediately, regardless of its idle state:
247 /// used when the UI window is closed by the user rather than by the
248 /// idle TTL. Reuses `reap`'s shutdown mechanism (shutdown flag plus a
249 /// self-connect to wake the blocked accept loop) so the session's data
250 /// port and accept thread are actually released, not just forgotten —
251 /// see defect 2 in `.superpowers/sdd/lifecycle-fixes-report.md` for why
252 /// closing a window must tear the server-side session down rather than
253 /// leaving it live forever with no window to show it.
254 ///
255 /// No event is sent: the caller (the UI) already knows it is closing
256 /// this session and is not waiting to be told.
257 ///
258 /// # Panics
259 /// If the internal session-map mutex is poisoned.
260 pub fn close_session(&mut self, id: SessionId) {
261 let removed = {
262 let mut sessions = self.sessions.lock().unwrap();
263 let name = sessions
264 .iter()
265 .find(|(_, s)| s.id == id)
266 .map(|(n, _)| n.clone());
267 name.and_then(|n| sessions.remove(&n))
268 };
269 let Some(session) = removed else { return };
270 wake_and_shutdown(id, session.port, &session.shutdown);
271 }
272}
273
274/// Tells a session's accept loop to stop and forces it to notice: dropping
275/// a `TcpListener` does not reliably wake a thread blocked in `accept()` on
276/// all platforms, so the shutdown flag is set first and then a throwaway
277/// self-connect forces one more iteration of the accept loop, which
278/// observes the flag and exits, releasing the port. Retries on transient
279/// failures (EMFILE, fd exhaustion, etc). A no-op for an anonymous session
280/// (`port == 0`), which has no listener of its own.
281fn wake_and_shutdown(id: SessionId, port: u16, shutdown: &Arc<AtomicBool>) {
282 shutdown.store(true, Ordering::SeqCst);
283 if port != 0 {
284 const MAX_ATTEMPTS: u32 = 3;
285 for attempt in 1..=MAX_ATTEMPTS {
286 match TcpStream::connect(("127.0.0.1", port)) {
287 Ok(_) => break,
288 Err(e) if attempt == MAX_ATTEMPTS => {
289 eprintln!("Failed to wake session {id} on port {port}: {e}");
290 }
291 Err(_) => {
292 std::thread::sleep(Duration::from_millis(10));
293 }
294 }
295 }
296 }
297}
298
299/// Result of one accept-loop attach attempt, decided atomically under the
300/// session-map lock (see the accept loop in `open_or_reuse`).
301enum AttachOutcome {
302 /// The session was removed (closed or reaped) out from under this
303 /// listener; stop accepting.
304 SessionGone,
305 /// Another data socket is already live for this session.
306 Rejected,
307 /// This connection is now the session's live attachment, at this
308 /// generation.
309 Attached { generation: u64 },
310}
311
312/// Next session id and anonymous-name counter.
313static NEXT_ID: AtomicU64 = AtomicU64::new(1);
314static NEXT_ANON: AtomicU64 = AtomicU64::new(1);
315
316/// Runs the handshake on one control connection.
317fn handle_control(
318 stream: TcpStream,
319 sessions: &Arc<Mutex<HashMap<String, Session>>>,
320 tx: &Sender<ServerEvent>,
321) {
322 let _ = stream.set_read_timeout(Some(HANDSHAKE_TIMEOUT));
323 let mut reader = BufReader::new(match stream.try_clone() {
324 Ok(s) => s,
325 Err(_) => return,
326 });
327 let mut writer = stream;
328
329 let mut line = String::new();
330 if reader.read_line(&mut line).is_err() {
331 return;
332 }
333
334 match parse_hello(&line) {
335 Ok((kind, name)) => match open_or_reuse(&name, kind, sessions, tx) {
336 Ok(port) => {
337 let _ = writeln!(writer, "PORT {port}");
338 }
339 Err(_) => {
340 let _ = writeln!(writer, "ERR no port");
341 }
342 },
343 Err(
344 e @ (HelloError::BadName
345 | HelloError::MissingVersion
346 | HelloError::BadVersion
347 | HelloError::UnsupportedVersion(_)
348 | HelloError::MissingStreamKind
349 | HelloError::UnknownStreamKind(_)),
350 ) => {
351 let _ = writeln!(writer, "{}", e.wire());
352 }
353 Err(HelloError::NotHello) => {
354 // Not a handshake: an anonymous raw stream. The line already read
355 // is part of the stream and must not be lost.
356 let n = NEXT_ANON.fetch_add(1, Ordering::SeqCst);
357 let name = format!("anon-{n}");
358 let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
359 // An anonymous session is a one-shot: this connection is its
360 // only ever attachment, so generation is fixed at 1 (first and
361 // only attach) for the life of the session.
362 sessions.lock().unwrap().insert(
363 name.clone(),
364 Session {
365 id,
366 port: 0,
367 live: Arc::new(AtomicBool::new(true)),
368 generation: Arc::new(AtomicU64::new(1)),
369 idle_since: None,
370 shutdown: Arc::new(AtomicBool::new(false)),
371 },
372 );
373 let _ = tx.send(ServerEvent::Opened {
374 id,
375 name: name.clone(),
376 port: 0,
377 kind: StreamKind::Tokens,
378 });
379 let _ = tx.send(ServerEvent::Attached {
380 id,
381 reattached: false,
382 });
383 let _ = tx.send(ServerEvent::Bytes {
384 id,
385 data: line.into_bytes(),
386 });
387 let _ = writer.set_read_timeout(None);
388 // The guard's drop sends Disconnected and sets idle_since,
389 // giving this anonymous session the same reapable lifecycle as
390 // a named session's data socket (see finding 3).
391 let _guard = LiveGuard {
392 sessions: Arc::clone(sessions),
393 name,
394 tx: tx.clone(),
395 id,
396 generation: 1,
397 };
398 pump(reader, id, tx);
399 }
400 }
401}
402
403/// Returns the data port for `name`, creating the session if it is new.
404fn open_or_reuse(
405 name: &str,
406 kind: StreamKind,
407 sessions: &Arc<Mutex<HashMap<String, Session>>>,
408 tx: &Sender<ServerEvent>,
409) -> std::io::Result<u16> {
410 {
411 let mut map = sessions.lock().unwrap();
412 if let Some(existing) = map.get_mut(name) {
413 // A HELLO reconnect counts as the client showing up again, even
414 // before a new data socket attaches: clear idle_since now so a
415 // concurrent reap cannot drop the session out from under the
416 // client that is about to dial the data port. The UI is not
417 // told anything here — `ServerEvent::Attached` (sent once the
418 // data socket actually attaches) is the sole source of truth
419 // for "connected", so there is nothing to notify yet.
420 existing.idle_since = None;
421 let port = existing.port;
422 return Ok(port);
423 }
424 }
425
426 let listener = TcpListener::bind(("127.0.0.1", 0))?;
427 let port = listener.local_addr()?.port();
428 let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
429 let shutdown = Arc::new(AtomicBool::new(false));
430
431 sessions.lock().unwrap().insert(
432 name.to_string(),
433 Session {
434 id,
435 port,
436 live: Arc::new(AtomicBool::new(false)),
437 generation: Arc::new(AtomicU64::new(0)),
438 idle_since: Some(Instant::now()),
439 shutdown: Arc::clone(&shutdown),
440 },
441 );
442 let _ = tx.send(ServerEvent::Opened {
443 id,
444 name: name.to_string(),
445 port,
446 kind,
447 });
448
449 let tx = tx.clone();
450 let sessions = Arc::clone(sessions);
451 let name = name.to_string();
452 std::thread::spawn(move || {
453 // Each accepted connection gets its own thread: `pump` blocks on
454 // reading that socket until it closes, and the accept loop must
455 // keep running underneath it — otherwise a first, still-open
456 // writer would starve `incoming()` and a genuine second writer
457 // (or a reconnect after a clean disconnect) could never be
458 // accepted at all.
459 for stream in listener.incoming().flatten() {
460 if shutdown.load(Ordering::SeqCst) {
461 // Reaped: `Server::reap` set the flag and forced this wake
462 // with a throwaway self-connect. Stop accepting and drop
463 // `listener` (falling out of this closure), which releases
464 // the port. The stream that woke us is discarded.
465 break;
466 }
467
468 // The "is someone already attached" check and the attach
469 // itself (flipping `live`, bumping `generation`, clearing
470 // `idle_since`) must happen as one atomic transaction under the
471 // session-map lock: doing the check and the flip as separate
472 // unguarded atomic ops (the old `live.swap`) is exactly the
473 // torn read/write that let a stale `LiveGuard::drop` race a
474 // fresh attach (defect 3).
475 let attach = {
476 let mut map = sessions.lock().unwrap();
477 match map.get_mut(&name) {
478 None => AttachOutcome::SessionGone,
479 Some(s) if s.live.load(Ordering::SeqCst) => AttachOutcome::Rejected,
480 Some(s) => {
481 s.live.store(true, Ordering::SeqCst);
482 let generation = s.generation.fetch_add(1, Ordering::SeqCst) + 1;
483 s.idle_since = None;
484 AttachOutcome::Attached { generation }
485 }
486 }
487 };
488
489 match attach {
490 AttachOutcome::SessionGone => break,
491 AttachOutcome::Rejected => {
492 // Already streaming: one writer per session.
493 let mut s = stream;
494 let _ = writeln!(s, "ERR already attached");
495 }
496 AttachOutcome::Attached { generation } => {
497 let reattached = generation > 1;
498 let _ = tx.send(ServerEvent::Attached { id, reattached });
499
500 let tx = tx.clone();
501 let sessions = Arc::clone(&sessions);
502 let name = name.clone();
503 std::thread::spawn(move || {
504 let _guard = LiveGuard {
505 sessions,
506 name,
507 tx: tx.clone(),
508 id,
509 generation,
510 };
511 pump(BufReader::new(stream), id, &tx);
512 });
513 }
514 }
515 }
516 });
517
518 Ok(port)
519}
520
521/// Reads a data socket to EOF, forwarding chunks as events.
522fn pump(mut reader: BufReader<TcpStream>, id: SessionId, tx: &Sender<ServerEvent>) {
523 let _ = reader.get_ref().set_read_timeout(None);
524 let mut buf = vec![0u8; READ_CHUNK];
525 loop {
526 match reader.read(&mut buf) {
527 Ok(0) | Err(_) => break,
528 Ok(n) => {
529 if tx
530 .send(ServerEvent::Bytes {
531 id,
532 data: buf[..n].to_vec(),
533 })
534 .is_err()
535 {
536 break;
537 }
538 }
539 }
540 }
541}
542
543/// Deterministic, white-box coverage for defect 3 (the reconnect race).
544///
545/// `close_then_immediate_redial_ends_up_attached_with_one_writer_and_no_spurious_disconnect`
546/// in `tests/protocol.rs` drives the real race over loopback TCP under many
547/// rapid iterations, but the actual window — an old `LiveGuard::drop`
548/// racing a brand-new attach — is a matter of thread-scheduling luck: it
549/// reproduced reliably against the pre-fix code the first few times this
550/// was tried, but is not *guaranteed* to reproduce on every machine or
551/// every run (confirmed here: 500 iterations of the deliberately
552/// reintroduced pre-fix logic passed clean on this machine in one run).
553/// These tests instead construct the exact ordering directly — no network,
554/// no scheduler dependency — so the invariant is checked every time, not
555/// "usually".
556#[cfg(test)]
557mod guard_generation_tests {
558 use super::*;
559 use std::sync::mpsc::channel;
560
561 fn make_session(port: u16) -> Session {
562 Session {
563 id: 1,
564 port,
565 live: Arc::new(AtomicBool::new(true)),
566 generation: Arc::new(AtomicU64::new(1)),
567 idle_since: None,
568 shutdown: Arc::new(AtomicBool::new(false)),
569 }
570 }
571
572 /// The exact ordering defect 3 describes: an old attachment's guard
573 /// drops *after* a newer attachment has already taken the session over
574 /// (generation bumped, `live` still true). The old guard must not clear
575 /// `live`, must not set `idle_since`, and must not send a
576 /// `Disconnected` — any of those would tear down or misreport a
577 /// connection that is still genuinely attached.
578 #[test]
579 fn outrun_guard_does_not_clobber_a_newer_attachment() {
580 let sessions: Arc<Mutex<HashMap<String, Session>>> = Arc::new(Mutex::new(HashMap::new()));
581 let name = "race".to_string();
582 sessions
583 .lock()
584 .unwrap()
585 .insert(name.clone(), make_session(4242));
586
587 let (tx, rx) = channel();
588 let outrun_guard = LiveGuard {
589 sessions: Arc::clone(&sessions),
590 name: name.clone(),
591 tx,
592 id: 1,
593 generation: 1,
594 };
595
596 // A newer attachment takes over exactly as the accept loop's
597 // atomic transaction does: bump generation, `live` stays true.
598 {
599 let map = sessions.lock().unwrap();
600 let s = map.get(&name).unwrap();
601 s.generation.fetch_add(1, Ordering::SeqCst);
602 assert!(s.live.load(Ordering::SeqCst));
603 }
604
605 drop(outrun_guard);
606
607 let map = sessions.lock().unwrap();
608 let s = map.get(&name).unwrap();
609 assert!(
610 s.live.load(Ordering::SeqCst),
611 "an outrun guard cleared `live` out from under the new attachment"
612 );
613 assert!(
614 s.idle_since.is_none(),
615 "an outrun guard marked a currently-attached session idle"
616 );
617 drop(map);
618 assert!(
619 rx.try_recv().is_err(),
620 "an outrun guard sent a spurious Disconnected"
621 );
622 }
623
624 /// The mirror case: a guard whose generation is still current (nothing
625 /// newer has attached) must tear the attachment down exactly as before
626 /// — this is not a change in the ordinary, non-racing path.
627 #[test]
628 fn current_guard_tears_down_normally() {
629 let sessions: Arc<Mutex<HashMap<String, Session>>> = Arc::new(Mutex::new(HashMap::new()));
630 let name = "race".to_string();
631 sessions
632 .lock()
633 .unwrap()
634 .insert(name.clone(), make_session(4242));
635
636 let (tx, rx) = channel();
637 let guard = LiveGuard {
638 sessions: Arc::clone(&sessions),
639 name: name.clone(),
640 tx,
641 id: 1,
642 generation: 1,
643 };
644 drop(guard);
645
646 let map = sessions.lock().unwrap();
647 let s = map.get(&name).unwrap();
648 assert!(!s.live.load(Ordering::SeqCst));
649 assert!(s.idle_since.is_some());
650 drop(map);
651 assert!(matches!(
652 rx.try_recv(),
653 Ok(ServerEvent::Disconnected { id: 1 })
654 ));
655 }
656}