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, 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}