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