{
  "summary": "## Conclusion\n\nThe broker's ~532–541 threads are **not legitimate session scaling for 4–6 hosted PTYs**. The dominant mechanism is an unbounded, broker-resident **one-native-thread-per-net-stream-subscriber** lifetime leak. A long-lived peer-pump IPC connection repeatedly subscribes to completed response streams; each subscription creates a `SubscriberSeat` writer thread, but stream EOF only queues an EOF envelope and leaves the seat/channel open. Those threads remain blocked in `rx.recv()` until the entire pump IPC connection closes. The same completed stream rows remain in `NetHost.streams` until their long-lived QUIC connection closes, and the dispatcher asks the broker to enumerate/serialize that growing table every 100 ms. This cleanly separates the observations:\n\n- **Thread count:** hundreds of mostly parked `SubscriberSeat` writers; only a few CPU-active threads is expected for this leak.\n- **Broker CPU and writes:** the active broker connection handler repeatedly executes `dispatch_net_streams` → `stream_infos` → JSON/frame write over an ever-growing stream table.\n- **Brain reads:** the corresponding brain dispatcher repeatedly receives those oversized `NET_STREAMS_REPLY` frames. Brain-internal routing is outside this assignment, but it is the consumer that makes the broker path hot.\n\n### Ranked mechanisms\n\n#### 1. Primary — permanent per-stream `SubscriberSeat` writer threads on a long-lived pump carrier\n\n**Creation and blocking semantics**\n\n- `crates/spt-daemon/src/nethost.rs:142-163` defines `SubscriberSeat`. It stores `sub`, `tx`, `done`, and `poisoned`, but **does not store a `JoinHandle` or cancellation token**.\n- `crates/spt-daemon/src/nethost.rs:174-224` (`SubscriberSeat::install`) creates a bounded channel and calls `std::thread::spawn` at line 196. After replay, the native thread executes `while let Ok(frame) = rx.recv()` at lines 211-217. With no new data and a live sender, it parks indefinitely.\n- `crates/spt-daemon/src/nethost.rs:518-559` (`StreamLog::begin_attach`) installs exactly one such seat per current stream subscriber. Replacing a seat sets the prior `subscriber` to `None`, which drops its sender and lets the old thread drain/exit; unique streams do not get this replacement.\n\n**Why stream EOF does not end the writer**\n\n- `crates/spt-daemon/src/nethost.rs:476-488` (`StreamLog::finish`) marks `finished = true` and enqueues `NetStreamEof`, but deliberately leaves `self.subscriber` populated. After writing the queued EOF, its writer returns to `rx.recv()` forever.\n- `crates/spt-daemon/src/nethost.rs:443-461` removes a seat only after a producer detects `Poisoned` or queue `Overflow`. A successfully completed, traffic-quiet stream produces neither condition.\n- The 15-second write bound is irrelevant to these permanent seats: it only bounds a write that fails or wedges. Once the EOF write succeeds, there is no subsequent write to time out.\n- `crates/spt-daemon/src/nethost.rs:1825-1860` explicitly defines `retire_stream` as visibility retirement, **not teardown**: the entry, send half, subscriber, and pump remain alive. It only sets `retired` and clears the ring.\n- `crates/spt-daemon/src/nethost.rs:1969-1991` has a detach operation, but it is only invoked by broker-IPC connection cleanup.\n- `crates/spt-daemon/src/broker.rs:3037-3062` accumulates every successful subscription in `my_stream_subs: Vec<u64>`; `crates/spt-daemon/src/broker.rs:3239-3286` detaches those seats only after the physical brain IPC read loop reaches EOF. There is no `KIND_NET_STREAM_UNSUBSCRIBE` / per-stream unsubscribe command.\n- `crates/spt-daemon/src/brain.rs:1584-1602` adds every subscription to `Brain.net_cursors`, with no EOF removal path. A search for `net_cursors.remove`, `net_stream_unsubscribe`, or `NET_STREAM_UNSUBSCRIBE` found none.\n\n**Why one connection owns hundreds of subscriptions**\n\n- `crates/spt-daemon/src/pump/mod.rs:524-553` creates two long-lived pump-mode `Brain` connections: one reused for all peer operations and another for presence.\n- The main `brain` is retained across the entire `run_peer_pump` loop (`crates/spt-daemon/src/pump/mod.rs:564-758`). Pull-style worker operations open and subscribe response streams through this same carrier. Fire-and-forget `push_feed` streams are also opened repeatedly at `crates/spt-daemon/src/pump/mod.rs:1117-1123`, although those do not themselves install subscriber threads.\n- The daemon log identifies the carrier. `C:/Users/decid/AppData/Local/spt-core/logs/daemon.stderr.log:404-407` shows the dispatcher/pump generation creating two `pump-ipc-reader`s plus the presence carrier. At line 500, broker connection 137 subscribes stream 50 at monotonic 72,862 ms. Lines 516-520 immediately show the same physical conn 137 accumulating streams 53, 56, 57, 60, and 61. Much later, lines 20230-20290, 21801-21840, and 23724-23728 show the same conn 137 continuing to accumulate new unique subscriptions, through streams 4564–4568 at monotonic ~4,646,279 ms. No `conn=137 ... event=transport-close` was found in the searched live-log range. Conversely, line 377 shows an older pump carrier, conn 12, closing with eight accumulated stream-subscriber labels; that physical close is the event that finally releases its seats.\n\n**Scale**\n\nLet $S$ be unique completed streams still subscribed by the live pump carrier. This mechanism contributes approximately $S$ native threads. The observed log pattern supports hundreds of unique streams on conn 137. [INFERENCE] A value near $S\\approx500$, plus normal session and fixed broker threads, explains the measured 532–541 total extremely closely.\n\nThis count is “expected under the current implementation,” but it is not legitimate bounded behavior. It scales with **completed stream history during the pump connection lifetime**, not with active sessions, active streams, or live peers.\n\n#### 2. Primary CPU/I/O amplifier — full stream-table serialization at 10 Hz\n\nThis is the active counterpart to the parked-thread leak.\n\n- `crates/spt-daemon/src/dispatch.rs:87` sets `DEFAULT_DISPATCH_POLL = 100 ms`.\n- `crates/spt-daemon/src/dispatch.rs:429-471` retains one dispatcher `Brain` and calls `brain.net_streams()` each loop. Only **after receiving the complete reply** does it skip locally initiated rows at lines 464-466.\n- `crates/spt-daemon/src/broker.rs:4365-4377` handles every request by constructing `NetStreamsReply { streams: host.stream_infos() }`, JSON-serializing it, and writing the frame.\n- `crates/spt-daemon/src/nethost.rs:1794-1824` locks the entire stream map, iterates every entry, locks every non-retired entry log, clones `remote_id_hex`, builds a `Vec<NetStreamInfo>`, and includes locally initiated rows. The receiver's later `initiated_locally` filter does not save broker enumeration, allocation, serialization, or IPC traffic.\n- `crates/spt-daemon/src/nethost.rs:954-967` removes stream rows only when the **physical QUIC connection** closes. A clean per-stream finish does not remove its row. A live peer connection therefore accumulates completed local pump streams indefinitely.\n- Dispatcher retirement at `crates/spt-daemon/src/dispatch.rs:506-514` applies only after a dispatcher worker serves a peer-initiated row. Locally initiated pump rows are skipped before worker creation, so this path does not retire them.\n- Even retired inbound rows remain in the backing map and are scanned by the `retired` filter; retirement only keeps them out of the serialized result.\n\n[INFERENCE] Thousands of retained `NetStreamInfo` records serialized about ten times per second are the strongest broker-side explanation for the measured small number of CPU-active threads and 3.74 MiB/s broker writes. The parked seat threads themselves do not continuously consume CPU or write bytes.\n\n#### 3. Normal per-session PTY/thread budget — far too small to explain ~537\n\nFor a live hosted session, broker production paths create:\n\n1. **PTY input writer:** `crates/spt-daemon/src/broker.rs:1788-1794`; blocks on the input channel or PTY write. It exits when the final sender drops (`input_writer`, lines 1840-1846).\n2. **PTY output drain:** `crates/spt-term/src/reader.rs:114-153`; blocks in `reader.read`, exits on EOF/error or after a returned read observes the stop flag.\n3. **Child exit waiter:** `crates/spt-daemon/src/broker.rs:3430-3464`; blocks in `PtySession::wait`, sends `Exit`, stamps reaped, then removes the session.\n4. **Current controller writer:** spawned at `crates/spt-daemon/src/broker.rs:947-950`; blocks on its bounded channel or a bounded broker-IPC write. Dropping/replacing/detaching the controller drops its sender; its loop exits at `crates/spt-daemon/src/broker.rs:1608-1623`.\n5. **One thread per viewer:** spawned at `crates/spt-daemon/src/broker.rs:1137-1139`; exits on channel close or write failure (`crates/spt-daemon/src/broker.rs:1410-1468`). `MAX_VIEWERS = 32` per session at `broker.rs:100-103`, so this class is explicitly bounded and would be visible as high viewer counts.\n6. **Optional translation pair:** one stdout reader at `crates/spt-daemon/src/translation.rs:273-291`, plus one supervised inject worker at `crates/spt-daemon/src/broker.rs:1991-2004`. The worker exits when `event_tx` closes and terminates/reaps the child (`broker.rs:2484-2486`); `TranslationChild::terminate` is bounded and `Drop` calls it (`translation.rs:327-367`).\n\nThus, with no viewers, $N$ live sessions contribute roughly $4N+2T$ threads, where $T\\le N$ is the number with translation binaries. Six fully translated sessions are approximately 36 threads. Even pathological viewer use is capped at $32N$ and requires actual viewer attachments. PTY/session threads cannot naturally produce ~537 from 4–6 ordinary hosted sessions.\n\n#### 4. Secondary real leak risk — PTY `Drain` teardown contract is not wired into `HostedSession`\n\nThis is not needed to explain the current count, but it is a concrete missing cancellation/reap path.\n\n- `crates/spt-term/src/reader.rs:11-18` states that ConPTY does not EOF while its writer is held and says deterministic teardown is the broker's responsibility.\n- `Drain::request_stop` at `reader.rs:87-93` is cooperative only; it does not interrupt a blocked read. `Drain::join` at lines 95-103 waits for the read to unblock.\n- The drain thread itself owns a `SharedWriter` clone (`reader.rs:114-150`).\n- `HostedSession` holds `drain: Drain` at `crates/spt-daemon/src/broker.rs:1696-1701`, with a comment saying it is kept for teardown, but there is no `Drop for HostedSession` and no `Drop for Drain`. Searches for both implementations returned no matches.\n- The exit waiter removes the `HostedSession` from the map at `broker.rs:3463`, but does not call `request_stop` or `join`. Dropping the `JoinHandle` merely detaches the thread.\n\n[INFERENCE] On Windows, a naturally exited historical session can therefore leave one output-drain thread blocked in `ReadFile` if the remaining writer clone prevents EOF. A blocked input writer is a related risk: channel closure cannot interrupt it mid-`write_input`, although killing/tearing down the child should normally make that write return. This mechanism scales with historical session exits, not current session count; the conn-137 one-thread-per-subscription evidence is a much tighter match to the current field count.\n\n#### 5. Normal per-connection threads — two per live broker IPC connection, not hundreds\n\n- `crates/spt-daemon/src/broker.rs:2786-2799` spawns one unnamed handler thread per accepted local broker IPC connection.\n- `crates/spt-daemon/src/conn.rs:429-453` creates one explicitly named `conn-watchdog` thread inside every `BrokerConn`.\n- The handler blocks in `read_frame`; EOF runs the detach loop and returns (`broker.rs:3037-3286`).\n- `BrokerConn::drop` sets watchdog shutdown, notifies it, joins it, and only then drops raw transport handles (`crates/spt-daemon/src/conn.rs:639-660`). This is a complete normal reap path.\n\nA long-lived client contributes one handler plus one watchdog regardless of whether it owns 1 or 500 subscriber seats. Fresh short-lived command/worker connections cause transient pairs, but the log repeatedly shows their `transport-close` events. Hundreds of watchdogs/handlers would require hundreds of simultaneously open physical IPC connections; that is falsified if `conn_handler_count` remains small. The source exposes `Broker::conn_handler_count` at `broker.rs:2922-2924` for tests/introspection.\n\n#### 6. Network stream pumps are Tokio tasks, not one OS thread per stream\n\n- `crates/spt-daemon/src/nethost.rs:980-991` spawns an async inbound-stream accept task.\n- `nethost.rs:1000-1064` registers each stream and spawns one async read-pump task.\n- `nethost.rs:1184-1190` creates a fixed two-worker runtime named `spt-broker-net`.\n\nThousands of stream tasks can add map/task/memory overhead, but they do not directly explain ~500 OS threads. The native per-stream threads come from `SubscriberSeat::install`, not `tokio::spawn`.\n\n## Safe, falsifiable live probes\n\n### Probe A — correlate native thread growth with unique conn-137 subscriptions\n\nThis is passive: it reads process counters and the existing daemon log; it neither writes to the broker nor stops a hosted session. Use an interval long enough to cross a registry/pump cadence (for example 75 seconds):\n\n```powershell\n$brokerPid = 72488\n$log = \"$env:LOCALAPPDATA\\spt-core\\logs\\daemon.stderr.log\"\n\nfunction Snapshot {\n    $ids = foreach ($m in Select-String -Path $log -Pattern 'CONN_LIFECYCLE: conn=137 .*event=stream-sub-attach stream=(\\d+)') {\n        if ($m.Line -match 'event=stream-sub-attach stream=(\\d+)') { [uint64]$Matches[1] }\n    }\n    $p = Get-Process -Id $brokerPid\n    [pscustomobject]@{\n        At                  = Get-Date\n        Threads             = $p.Threads.Count\n        CpuSeconds          = $p.CPU\n        UniqueConn137Seats  = @($ids | Sort-Object -Unique).Count\n        Conn137Closed       = @(Select-String -Path $log -Pattern 'CONN_LIFECYCLE: conn=137 .*event=transport-close').Count\n    }\n}\n\n$a = Snapshot\nStart-Sleep -Seconds 75\n$b = Snapshot\n$a\n$b\n[pscustomobject]@{\n    ThreadDelta = $b.Threads - $a.Threads\n    SeatDelta   = $b.UniqueConn137Seats - $a.UniqueConn137Seats\n    CpuDelta    = $b.CpuSeconds - $a.CpuSeconds\n}\n```\n\nPredictions:\n\n- **Subscriber-thread leak confirmed:** while conn 137 remains open, `ThreadDelta` tracks `SeatDelta` approximately 1:1. Small noise comes from transient connection handlers/watchdogs.\n- **Hypothesis falsified:** unique conn-137 subscriptions increase but total threads remain flat or fall promptly after each EOF.\n- **Connection leak instead:** thread count rises roughly two per new connection ID, with those IDs lacking `transport-close`, while `SeatDelta` stays flat.\n- **Normal steady state:** session/viewer/connection counts and total threads remain bounded across several pump rounds.\n\n### Probe B — identify the four CPU-active threads and hot broker stack without suspending the process\n\nA short WPR ETW sample is non-terminating and does not disconnect sessions:\n\n```powershell\nwpr -start GeneralProfile -filemode\nStart-Sleep -Seconds 15\nwpr -stop \"$env:TEMP\\spt-broker-72488.etl\"\n```\n\nIn Windows Performance Analyzer, filter **CPU Usage (Sampled)** and process/thread activity to PID 72488. The primary prediction is a hot handler stack containing the equivalents of:\n\n`Broker::dispatch_net_streams` → `NetHost::stream_infos` → `serde_json` / frame encoding → named-pipe write,\n\nrepeating at roughly the 100 ms dispatcher cadence. If the samples instead concentrate in PTY read/write loops or subscriber writers, the table-enumeration explanation is falsified. Also inspect context-switch/wait data: hundreds of subscriber threads should be parked in Rust `mpsc::Receiver::recv`, not accumulating CPU.\n\n### Probe C — distinguish the PTY drain leak without stopping a session\n\nWait for a session that exits naturally; sample `Get-Process -Id 72488` thread count and the broker session count immediately before and after. The expected healthy reduction is its input writer, drain, waiter, controller, and optional translation threads. If the session row disappears but one thread remains permanently blocked in the PTY/`ReadFile` path, the missing `Drain` teardown is confirmed. Do not kill an existing hosted session merely to run this probe.\n\n## Safe source-level remediation directions\n\n1. **End a seat at stream EOF:** after queuing the final EOF, drop/take the seat sender so the writer drains the EOF and exits. The already-finished attach case also needs a one-shot replay seat whose sender closes immediately after installation. This fixes the thread leak without closing the shared pump IPC carrier.\n2. **Add explicit stream completion ownership:** on the consumer's observed EOF, retire/remove the corresponding cursor and tell the broker it no longer needs the subscription. This also bounds `my_stream_subs` on a long-lived carrier.\n3. **Bound stream row lifetime:** remove a stream only after both transport halves are terminal and no in-flight reply send can race removal. Current `retire_stream` explicitly avoids teardown because of that race, so blindly changing it to immediate removal would be unsafe.\n4. **Do not send locally initiated history to the dispatcher:** at minimum, broker-side enumeration should exclude `initiated_locally` before allocation/serialization; better, completed rows should leave the map. Merely filtering in `dispatch.rs` is too late.\n5. **Implement deterministic `HostedSession`/`Drain` teardown:** terminate/reap the child, close input/writer ownership in the required order, request stop, then join the drain. A stop flag alone cannot interrupt ConPTY's blocked read.",
  "files": [
    {
      "path": "crates/spt-daemon/src/nethost.rs",
      "description": "Primary root. `SubscriberSeat::install` spawns one native writer per stream subscription (lines 174-224); `StreamLog::finish` queues EOF but retains the seat (476-488); `stream_infos` scans and clones every non-retired row (1794-1824); retirement explicitly retains subscriber/pump/entry (1825-1860); physical connection close is the stream-table removal path (954-967)."
    },
    {
      "path": "crates/spt-daemon/src/broker.rs",
      "description": "Broker IPC ownership. Accept loop spawns one handler per connection (2786-2799); each handler accumulates `my_stream_subs` and detaches only at connection EOF (3037-3286). `dispatch_net_streams` serializes the entire `stream_infos` result (4365-4377). Session production threads are controller (947-950), viewer (1137-1139), input writer (1788-1794), inject worker (1991-2004), and exit waiter (3430-3464). `HostedSession` owns a Drain but has no teardown Drop (1675-1735)."
    },
    {
      "path": "crates/spt-daemon/src/dispatch.rs",
      "description": "The dispatcher retains a broker client and polls `net_streams` every 100 ms (line 87; loop 429-471). It filters locally initiated streams only after the broker has enumerated, allocated, serialized, and written them (464-466). Worker completion retirement applies to served peer-initiated rows (506-514)."
    },
    {
      "path": "crates/spt-daemon/src/pump/mod.rs",
      "description": "Shows the long-lived pump carrier ownership: two pump-mode Brain connections are created once and retained across all rounds (524-553, 564-758). Repeated stream operations therefore share one physical broker IPC carrier; `push_feed` opens additional one-purpose streams (1117-1123)."
    },
    {
      "path": "crates/spt-daemon/src/brain.rs",
      "description": "`Brain::net_stream_subscribe` inserts stream cursors and sends subscribe requests (1584-1602), with no EOF cursor removal or unsubscribe operation. Pump mode creates one named `pump-ipc-reader` per pump carrier (230-252), which is brain-process—not broker-process—threading."
    },
    {
      "path": "crates/spt-daemon/src/conn.rs",
      "description": "One `conn-watchdog` thread per physical broker IPC connection is created at 429-453. `BrokerConn::drop` shuts down and joins it at 639-660, so this class is bounded by live connections and has an explicit reap path."
    },
    {
      "path": "crates/spt-term/src/reader.rs",
      "description": "PTY output Drain thread creation and teardown contract. ConPTY no-EOF-while-writer-held is documented at lines 11-18; request_stop is cooperative and join waits for read to unblock (87-103); the thread owns the writer and blocks in read (114-153). No Drop implementation exists."
    },
    {
      "path": "crates/spt-term/src/pty.rs",
      "description": "PTY session owns child, master, reader, and shared writer. Child wait/kill semantics are at 195-225; these resources must be ordered correctly to unblock the Drain on Windows."
    },
    {
      "path": "crates/spt-daemon/src/translation.rs",
      "description": "Optional per-session translation stdout reader is spawned at 273-291. `terminate` closes stdin, waits boundedly, kills, reaps, and is called from Drop (327-367), so this class has a concrete child reap path."
    },
    {
      "path": "crates/spt-daemon/src/daemon.rs",
      "description": "Starts the single broker serve thread and fixed daemon-lifetime service loops (broker at 192-195; digest/drive/tunnel and conditional net retry/autostart in the surrounding 185-315 range). These are O(1), not per stream or per session."
    },
    {
      "path": "C:/Users/decid/AppData/Local/spt-core/logs/daemon.stderr.log",
      "description": "Live field evidence. Conn 137 begins stream subscriptions at line 500 and accumulates multiple stream tags immediately at lines 516-520; the same physical conn continues subscribing at lines 20230-20290, 21801-21840, and 23724-23728. Conn 12 closes with accumulated subscriber tags at line 377, demonstrating physical-connection-scoped cleanup."
    }
  ],
  "architecture": "```mermaid\nflowchart LR\n  Pump[Brain peer pump\\nlong-lived Brain carrier] -->|many NET_STREAM_SUBSCRIBE requests| Handler[Broker handle_conn\\n1 handler thread]\n  Handler -->|SharedSend + stream id| Log[StreamLog per NetHost StreamEntry]\n  Log -->|SubscriberSeat::install| Seat[1 native writer thread\\nreplay then rx.recv forever]\n  Peer[Long-lived QUIC peer conn] --> Entry[StreamEntry table]\n  Entry --> Log\n  Peer -->|per-stream EOF| Finish[StreamLog::finish]\n  Finish -->|queues EOF only| Seat\n  Finish -. no seat drop .-> Leak[parked seat retained]\n  Peer -. only physical conn close .-> Sweep[conn-close sweep]\n  Sweep -->|remove all conn rows| Entry\n  Pump -. only IPC carrier EOF .-> Detach[handle_conn detach loop]\n  Detach -->|drop seat sender| Seat\n\n  Dispatcher[Dispatcher Brain\\n100 ms poll] -->|NET_STREAMS| Handler\n  Handler --> Enumerate[stream_infos\\nscan map + clone rows]\n  Enumerate --> Serialize[JSON serialize + IPC write]\n  Serialize --> Dispatcher\n  Entry -->|completed local rows retained| Enumerate\n\n  Session[Hosted PTY session] --> Input[Input writer thread]\n  Session --> Drain[PTY Drain thread]\n  Session --> Waiter[Child waiter thread]\n  Session --> Controller[Controller writer thread]\n  Session --> Viewer[0..32 viewer writers]\n  Session --> Translation[optional reader + inject worker]\n  Waiter -->|child exit; remove map row| DropSession[HostedSession dropped]\n  DropSession -. missing request_stop/join .-> Drain\n```\n\nThread-count model for the broker process is approximately:\n\n$$T \\approx T_{fixed} + 2C_{ipc} + S_{stream\\ subscribers} + 4N_{sessions} + V_{viewers} + 2T_{translations} + T_{transient}$$\n\nwhere the pathological unbounded term is $S_{stream\\ subscribers}$. For 4–6 sessions, ordinary session terms are tens of threads; the observed hundreds require subscriber history, many leaked IPC connections, maximal viewers, historical PTY drains, or a combination. The conn-137 lifecycle evidence specifically selects the subscriber-history mechanism. The active hot path is separate: one/few handler/runtime threads repeatedly scan and serialize the retained stream table, explaining why only about four threads consume CPU while roughly 500 remain parked."
}