Skip to main content

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}