80	/// The viewer-channel depth (frames). A bounded SPSC queue per viewer; when it
81	/// OVERFLOWS the viewer is evicted — the drain `try_send`s and never blocks
82	/// (REQ-HAZARD-VIEWER-ISOLATION). Generous enough to absorb a normal render
83	/// hiccup; a viewer that falls this far behind on the LIVE stream is genuinely
84	/// not keeping up. Replay-at-attach does NOT ride this bound (the writer thread
85	/// owns the replay batch), so a viewer attaching to a busy session is not
86	/// spuriously evicted.
87	const VIEWER_CHANNEL_DEPTH: usize = 256;
88
89	/// Soft cap on concurrent viewers per session (REQ-HAZARD-VIEWER-ISOLATION:
90	/// bounds the writer-thread count). A `Viewer` attach beyond the cap is refused.
91	const MAX_VIEWERS: usize = 32;
92
93	/// Whether a viewer's bounded-queue `try_send` result demands EVICTION
94	/// (REQ-HAZARD-VIEWER-ISOLATION): a `Full` queue (the viewer can't keep up with
95	/// the live stream) or a `Disconnected` one (its writer thread ended after a
96	/// dead socket). The drain evicts on either and NEVER blocks on a viewer — this
97	/// is the pure decision the fan-out applies under the log lock.
98	// [impl->REQ-HAZARD-VIEWER-ISOLATION]
99	fn viewer_send_evicts<T>(r: &Result<(), TrySendError<T>>) -> bool {
100	    r.is_err()
101	}
102
103	/// One read-only viewer's isolated sink (REQ-HAZARD-VIEWER-ISOLATION). The drain
104	/// thread `try_send`s output frames into `tx` (a bounded queue); a dedicated
105	/// `writer` thread does the BLOCKING socket write, so a wedged viewer backs up
106	/// only its own queue — never the drain. When the queue overflows the sink is
107	/// dropped (the `tx` closes → the writer ends) and removed from the map.
108	struct ViewerSink {
109	    /// The bounded live-frame queue to this viewer's writer thread.
110	    tx: SyncSender<Envelope>,
111	    /// The viewer's IPC send half — kept ONLY for identity matching on detach
112	    /// (a dropped brain connection removes its viewer rows); never written here.
113	    send: SharedSend,
114	    /// The writer thread handle (held for ownership; it exits when `tx` drops or
115	    /// a socket write fails).
116	    _writer: JoinHandle<()>,
117	}
118
119	/// A bounded, sequenced output log for one session — the gapless-resume buffer
120	/// AND the W2.5 controller/viewer fan-out hub (REQ-RCVIEW-1).
121	///
122	/// The session's single drain thread is the only producer ([`OutputLog::append`]).
123	/// Output fans out to ONE interactive `controller` (the authoritative,
124	/// cursor-advancing, blocking path) plus ANY NUMBER of read-only `viewers` (each
125	/// an isolated bounded queue + writer thread, evicted on overflow). Every
126	/// controller send and viewer `try_send` happens while the enclosing
127	/// `Mutex<OutputLog>` is held, so frames reach each sink in strict `seq` order.
128	struct OutputLog {
129	    session_id: u64,
130	    next_seq: u64,
131	    ring: VecDeque<(u64, Vec<u8>)>,
132	    cap_chunks: usize,
133	    /// The single interactive controller's IPC send (input + EXCLUSIVE resize;
134	    /// advances `delivered_through`). `None` ⇒ undriven (no controller).
135	    controller: Option<SharedSend>,
136	    /// The controller's identity: `Some(node)` = a remote operator, `None` =
137	    /// local (the daemon brain's spawn-reader / resume). Controller-ownership is
138	    /// keyed on this — a same-`by` re-subscribe silently re-takes (no
139	    /// `Displaced`, the gate-#7 self-kick guard); only a DIFFERENT remote's
140	    /// `Take` displaces. Paired with `controller` (both `Some` or both `None`).
141	    controller_by: Option<String>,
142	    /// The read-only viewers, keyed by a broker-minted viewer id.
143	    viewers: HashMap<u64, ViewerSink>,
144	    /// Monotonic viewer-id source.
145	    next_viewer_id: u64,
146	    /// The owning endpoint (the spawn's label; empty = unlabeled / raw test).
147	    /// The broker is the SINGLE writer of `driven_by` + `viewer_count` on this
148	    /// perch (REQ-RCVIEW-1, resolves the clear-race); empty ⇒ nowhere to stamp.
149	    endpoint: String,
150	    /// Last-known PTY size — seeded at spawn, updated on a controller resize.
151	    /// Pushed to a viewer on attach + to all viewers on resize (the letterbox
152	    /// feed); the broker holds it since the PTY exposes no size getter.
153	    size: (u16, u16),
154	    /// Cursor-of-record (restoration D4-1, ADR-0018 Q6): the next seq a
155	    /// re-attaching CONTROLLER still needs (= highest seq **successfully written**
156	    /// to the controller, +1). It is **log** state, not subscriber state, so it
157	    /// **survives `detach_if`** — a dead brain leaves its resume point behind, and
158	    /// a cold-starting brain reads it (via `KIND_SESSIONS` → `resume_seq`) instead
159	    /// of replaying the whole ring from 0. **Viewer writes NEVER move it**
160	    /// (REQ-RCVIEW-1: the brain-resume cursor tracks the controller only).
161	    ///
162	    /// **Advanced only on a successful socket write** (`Ok`), and **monotonic**
163	    /// (never decreases) — see [`advanced_cursor`]. Both invariants are
164	    /// load-bearing for at-least-once output: advancing past a *failed* write to a
165	    /// dying-but-not-yet-detached controller would make resume skip that chunk
166	    /// forever (at-least-once → at-most-once); decreasing on a deliberate
167	    /// `attach(sub, 0)` rewind-replay would reset the resume point under a live
168	    /// reader.
169	    delivered_through: u64,
170	}
171
172	/// The post-write resume cursor (pure — the D4-1 unit kernel, mirroring
173	/// `brainproc::consumer_gate`/`next_backoff`): advance to `seq + 1` **only** on a
174	/// successful write, and **monotonically** (never below `current`). A failed
175	/// write (`write_ok == false`) leaves the cursor untouched, and a re-send of an
176	/// already-delivered `seq` (an `attach(sub, 0)` rewind, `seq + 1 <= current`)
177	/// cannot lower it. This is the exact decision the `let _ = write_frame(...)`
178	/// swallow used to drop (doyle D4-1 amendments 1–3).
179	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
180	fn advanced_cursor(current: u64, seq: u64, write_ok: bool) -> u64 {
181	    if write_ok {
182	        current.max(seq + 1)
183	    } else {
184	        current
185	    }
186	}
187
188	impl OutputLog {
189	    fn new(session_id: u64, cap_chunks: usize, endpoint: String, size: (u16, u16)) -> Self {
190	        OutputLog {
191	            session_id,
192	            next_seq: 0,
193	            ring: VecDeque::new(),
194	            cap_chunks,
195	            controller: None,
196	            controller_by: None,
197	            viewers: HashMap::new(),
198	            next_viewer_id: 0,
199	            endpoint,
200	            size,
201	            delivered_through: 0,
202	        }
203	    }
204
205	    /// Append a freshly-produced output chunk: assign the next `seq`, retain it
206	    /// in the bounded ring, then FAN OUT — the controller on the authoritative
207	    /// blocking path (advancing the delivered cursor only on a successful write,
208	    /// D4-1), and each viewer via a `try_send` to its bounded queue, EVICTING any
209	    /// viewer whose queue overflows or whose writer has gone. The drain thread
210	    /// NEVER touches a viewer socket, so no viewer can stall it
211	    /// (REQ-HAZARD-VIEWER-ISOLATION). Called only from the session's drain thread.
212	    fn append(&mut self, bytes: &[u8]) {
213	        let seq = self.next_seq;
214	        self.next_seq += 1;
215	        self.ring.push_back((seq, bytes.to_vec()));
216	        while self.ring.len() > self.cap_chunks {
217	            self.ring.pop_front();
218	        }
219	        let frame = output_envelope(self.session_id, seq, bytes);
220	        // Controller: authoritative, blocking, advances the resume cursor.
221	        if let Some(sub) = &self.controller {
222	            let write_ok = match sub.lock() {
223	                Ok(mut s) => write_frame(&mut *s, &frame).is_ok(),
224	                Err(_) => false,
225	            };
226	            self.delivered_through = advanced_cursor(self.delivered_through, seq, write_ok);
227	        }
228	        // Viewers: non-blocking fan-out; a full / disconnected queue = eviction.
229	        if !self.viewers.is_empty() {
230	            let mut evicted: Vec<u64> = Vec::new();
231	            for (vid, sink) in self.viewers.iter() {
232	                if viewer_send_evicts(&sink.tx.try_send(frame.clone())) {
233	                    evicted.push(*vid);
234	                }
235	            }
236	            if !evicted.is_empty() {
237	                for vid in evicted {
238	                    self.viewers.remove(&vid);
239	                }
240	                self.stamp_viewer_count();
241	            }
242	        }
243	    }
244
245	    /// Become (or silently re-take) the interactive controller from `from_seq`.
246	    /// Replays the ring to the new controller and advances the delivered cursor
247	    /// to the last-successfully-written seq + 1 (D4-1: never the unsent tail, and
248	    /// only ever forward). Stamps `driven_by` to the new controller's identity.
249	    fn become_controller(&mut self, sub: SharedSend, by: Option<String>, from_seq: u64) {
250	        self.controller = Some(Arc::clone(&sub));
251	        self.controller_by = by;
252	        let mut last_ok: Option<u64> = None;
253	        if let Ok(mut s) = sub.lock() {
254	            for (seq, bytes) in self.ring.iter().filter(|(s, _)| *s >= from_seq) {
255	                let frame = output_envelope(self.session_id, *seq, bytes);
256	                if write_frame(&mut *s, &frame).is_ok() {
257	                    last_ok = Some(*seq);
258	                } else {
259	                    break; // stop at the first failed write; cursor stays put
260	                }
261	            }
262	        }
263	        if let Some(seq) = last_ok {
264	            self.delivered_through = advanced_cursor(self.delivered_through, seq, true);
265	        }
266	        self.stamp_driven_by();
267	    }
268
269	    /// Add a read-only viewer: snapshot the ring from `from_seq` into the writer
270	    /// thread (NOT through the bounded live queue — replay must not trip the
271	    /// overflow eviction), seed it the current size frame first, then run the
272	    /// live queue. The drain thread fans live frames in via `try_send`.
273	    fn add_viewer(&mut self, sub: SharedSend, from_seq: u64) {
274	        let vid = self.next_viewer_id;
275	        self.next_viewer_id += 1;
276	        // The size frame + the ring replay are the writer's initial batch (owned
277	        // by the thread, not the bounded queue).
278	        let mut initial: Vec<Envelope> =
279	            vec![size_envelope(self.session_id, self.size.0, self.size.1)];
280	        initial.extend(
281	            self.ring
282	                .iter()
283	                .filter(|(s, _)| *s >= from_seq)
284	                .map(|(seq, bytes)| output_envelope(self.session_id, *seq, bytes)),
285	        );
286	        let (tx, rx) = sync_channel::<Envelope>(VIEWER_CHANNEL_DEPTH);
287	        let writer_send = Arc::clone(&sub);
288	        let writer = thread::spawn(move || viewer_writer(writer_send, initial, rx));
289	        self.viewers.insert(
290	            vid,
291	            ViewerSink {
292	                tx,
293	                send: sub,
294	                _writer: writer,
295	            },
296	        );
297	    }
298
299	    /// Resolve a controller/viewer subscribe under the W2.5 model
300	    /// (REQ-RCVIEW-1 / REQ-KICK-1). Returns the [`SubscribeOutcome`]; performs
301	    /// the controller take / viewer add / loud displace as a side effect. See the
302	    /// identity matrix in the body — the `by`-keyed logic is what makes a
303	    /// brain-restart re-serve silent (gate #7) and a cross-operator `Take` loud.
304	    fn resolve_subscribe(
305	        &mut self,
306	        sub: SharedSend,
307	        from_seq: u64,
308	        intent: AttachIntent,
309	        by: Option<String>,
310	    ) -> SubscribeOutcome {
311	        if intent == AttachIntent::Viewer {
312	            if self.viewers.len() >= MAX_VIEWERS {
313	                // Soft cap reached — refuse the viewer (bounds thread count).
314	                return SubscribeOutcome::BusyControlled {
315	                    by: "viewer cap reached".to_string(),
316	                };
317	            }
318	            self.add_viewer(sub, from_seq);
319	            self.stamp_viewer_count();
320	            return SubscribeOutcome::Viewer;
321	        }
322	        // Control or Take.
323	        if self.controller.is_none() {
324	            // Free endpoint → become controller (nothing to displace).
325	            self.become_controller(sub, by, from_seq);
326	            return SubscribeOutcome::Controller;
327	        }
328	        let same_identity = self.controller_by == by;
329	        if same_identity {
330	            // Same operator (or both local) re-serving — silent re-take. This is
331	            // the gate-#7 path: a successor re-taking the slot after a brain
332	            // restart NEVER fires Displaced.
333	            self.become_controller(sub, by, from_seq);
334	            return SubscribeOutcome::Controller;
335	        }
336	        if by.is_none() {
337	            // The LOCAL brain re-attaching while a REMOTE controls — never steal;
338	            // fall back to a silent viewer (gate #7 for the local-resume path).
339	            self.add_viewer(sub, from_seq);
340	            self.stamp_viewer_count();
341	            return SubscribeOutcome::Viewer;
342	        }
343	        if self.controller_by.is_none() {
344	            // A remote takes an UNDRIVEN session (only the local spawner held the
345	            // slot) — silent control, no loud displace (there is no remote driver
346	            // to notify).
347	            self.become_controller(sub, by, from_seq);
348	            return SubscribeOutcome::Controller;
349	        }
350	        // A DIFFERENT remote controls it.
351	        match intent {
352	            AttachIntent::Take => {
353	                // The ONLY loud-displace path: notify the incumbent, then take.
354	                if let Some(old) = &self.controller {
355	                    let frame = displaced_envelope(self.session_id, by.as_deref().unwrap_or(""));
356	                    if let Ok(mut s) = old.lock() {
357	                        let _ = write_frame(&mut *s, &frame);
358	                    }
359	                }
360	                self.become_controller(sub, by, from_seq);
361	                SubscribeOutcome::TookControl
362	            }
363	            _ => SubscribeOutcome::BusyControlled {
364	                by: self.controller_by.clone().unwrap_or_default(),
365	            },
366	        }
367	    }
368
369	    /// Update the stored PTY size and push a `size` frame to every viewer (the
370	    /// letterbox feed). Called on a controller resize. Viewer pushes are
371	    /// non-blocking with eviction, like [`OutputLog::append`].
372	    fn set_size_and_notify(&mut self, rows: u16, cols: u16) {
373	        self.size = (rows, cols);
374	        if self.viewers.is_empty() {
375	            return;
376	        }
377	        let frame = size_envelope(self.session_id, rows, cols);
378	        let mut evicted: Vec<u64> = Vec::new();
379	        for (vid, sink) in self.viewers.iter() {
380	            if viewer_send_evicts(&sink.tx.try_send(frame.clone())) {
381	                evicted.push(*vid);
382	            }
383	        }
384	        if !evicted.is_empty() {
385	            for vid in evicted {
386	                self.viewers.remove(&vid);
387	            }
388	            self.stamp_viewer_count();
389	        }
390	    }
391
392	    /// Detach `sub` from whatever role it held: clear the controller slot iff it
393	    /// is the current controller (a no-op if a newer brain took over — a late
394	    /// disconnect can't unseat the live controller), and remove any viewer rows
395	    /// owned by `sub`. Leaves `delivered_through` intact (D4-1). Re-stamps
396	    /// `driven_by` / `viewer_count` when a role actually changed.
397	    fn detach_if(&mut self, sub: &SharedSend) {
398	        if let Some(cur) = &self.controller {
399	            if Arc::ptr_eq(cur, sub) {
400	                self.controller = None;
401	                self.controller_by = None;
402	                self.stamp_driven_by();
403	            }
404	        }
405	        let before = self.viewers.len();
406	        self.viewers.retain(|_, sink| !Arc::ptr_eq(&sink.send, sub));
407	        if self.viewers.len() != before {
408	            self.stamp_viewer_count();
409	        }
410	    }
411
412	    /// Every sink currently attached (controller + viewers) — the recipients of
413	    /// an `exit` frame.
414	    fn all_sinks(&self) -> Vec<SharedSend> {
415	        let mut v: Vec<SharedSend> = Vec::new();
416	        if let Some(c) = &self.controller {
417	            v.push(Arc::clone(c));
418	        }
419	        for sink in self.viewers.values() {
420	            v.push(Arc::clone(&sink.send));
421	        }
422	        v
423	    }
424
425	    /// Stamp the perch's `driven_by` to the current controller's identity (the
426	    /// broker is the single writer — resolves the clear-race). The remote-drive
427	    /// detection fact (REQ-REACH-1) moved here from `serve_attach` so a displaced
428	    /// operator's teardown can never wipe the new controller's marker.
429	    /// Best-effort: a session served without a perch (raw broker tests) has
430	    /// nowhere to record.
431	    // [impl->REQ-RCVIEW-1] [impl->REQ-REACH-1]
432	    fn stamp_driven_by(&self) {
433	        if self.endpoint.is_empty() {
434	            return;
435	        }
436	        let perch = resolve_perch_path(&self.endpoint, ParentHint::Infer);
437	        let _ = spt_store::info::set_driven_by(&perch, self.controller_by.as_deref());
438	    }
439
440	    /// Stamp the perch's `viewer_count` to the live viewer count (broker is the
441	    /// single writer). Best-effort, like [`OutputLog::stamp_driven_by`].
442	    // [impl->REQ-RCVIEW-1]
443	    fn stamp_viewer_count(&self) {
444	        if self.endpoint.is_empty() {
445	            return;
446	        }
447	        let perch = resolve_perch_path(&self.endpoint, ParentHint::Infer);
448	        let _ = spt_store::info::set_viewer_count(&perch, self.viewers.len() as u32);
449	    }
450
451	    /// Whether `sub` is the current controller (the resize-exclusivity check).
452	    fn is_controller(&self, sub: &SharedSend) -> bool {
453	        self.controller
454	            .as_ref()
455	            .is_some_and(|c| Arc::ptr_eq(c, sub))
456	    }
457
458	    /// The next seq a re-attaching controller still needs (the persisted resume
459	    /// cursor surfaced in `KIND_SESSIONS` → `resume_seq`).
460	    fn delivered_through(&self) -> u64 {
461	        self.delivered_through
462	    }
463	}
464
465	/// A viewer's dedicated writer thread (REQ-HAZARD-VIEWER-ISOLATION): write the
466	/// `initial` batch (size frame + ring replay) once, then drain live frames from
467	/// `rx` with a BLOCKING socket write each. A failed write (dead viewer) ends the
468	/// thread; the drain thread's next `try_send` then sees `Disconnected` and
469	/// evicts the row. The drain thread itself never blocks on this socket.
470	fn viewer_writer(send: SharedSend, initial: Vec<Envelope>, rx: Receiver<Envelope>) {
471	    for frame in &initial {
472	        let ok = match send.lock() {
473	            Ok(mut s) => write_frame(&mut *s, frame).is_ok(),
474	            Err(_) => false,
475	        };
476	        if !ok {
477	            return;
478	        }
479	    }
480	    while let Ok(frame) = rx.recv() {
481	        let ok = match send.lock() {
482	            Ok(mut s) => write_frame(&mut *s, &frame).is_ok(),
483	            Err(_) => false,
484	        };
485	        if !ok {
486	            return;
487	        }
488	    }
489	}
490
491	/// A child the broker hosts under a PTY — the §B-owned resources for one
492	/// session, plus its gapless-resume output log.
493	struct HostedSession {
494	    /// The PTY + child (PTY master/writer + spawned child process).
495	    session: Arc<PtySession>,
496	    /// The output drain pump (kept so teardown can stop it).
497	    #[allow(dead_code)] // held for ownership/teardown; not read directly
498	    drain: Drain,
499	    /// The sequenced, buffered output + current subscriber.
500	    log: Arc<Mutex<OutputLog>>,
501	    /// The owning endpoint (the spawn's label; empty = unlabeled). The
502	    /// inbound dispatcher's gate subject for remote-drive (D9-1).
503	    endpoint: String,
504	}
505
506	/// The broker process kernel. Owns the listening socket, the table of hosted
507	/// sessions, and the broker-owned idempotency anchor (B5); holds no logic state
508	/// (ADR-0004 §B). The [`EffectJournal`] is a §B resource — the durable
509	/// recovery anchor a brain crash must not lose, so it lives in the broker, not

/// The viewer-channel depth (frames). A bounded SPSC queue per viewer; when it
/// OVERFLOWS the viewer is evicted — the drain `try_send`s and never blocks
/// (REQ-HAZARD-VIEWER-ISOLATION). Generous enough to absorb a normal render
/// hiccup; a viewer that falls this far behind on the LIVE stream is genuinely
/// not keeping up. Replay-at-attach does NOT ride this bound (the writer thread
/// owns the replay batch), so a viewer attaching to a busy session is not
/// spuriously evicted.
const VIEWER_CHANNEL_DEPTH: usize = 256;

/// Soft cap on concurrent viewers per session (REQ-HAZARD-VIEWER-ISOLATION:
/// bounds the writer-thread count). A `Viewer` attach beyond the cap is refused.
const MAX_VIEWERS: usize = 32;

/// Whether a viewer's bounded-queue `try_send` result demands EVICTION
/// (REQ-HAZARD-VIEWER-ISOLATION): a `Full` queue (the viewer can't keep up with
/// the live stream) or a `Disconnected` one (its writer thread ended after a
/// dead socket). The drain evicts on either and NEVER blocks on a viewer — this
/// is the pure decision the fan-out applies under the log lock.
// [impl->REQ-HAZARD-VIEWER-ISOLATION]
fn viewer_send_evicts<T>(r: &Result<(), TrySendError<T>>) -> bool {
    r.is_err()
}

/// One read-only viewer's isolated sink (REQ-HAZARD-VIEWER-ISOLATION). The drain
/// thread `try_send`s output frames into `tx` (a bounded queue); a dedicated
/// `writer` thread does the BLOCKING socket write, so a wedged viewer backs up
/// only its own queue — never the drain. When the queue overflows the sink is
/// dropped (the `tx` closes → the writer ends) and removed from the map.
struct ViewerSink {
    /// The bounded live-frame queue to this viewer's writer thread.
    tx: SyncSender<Envelope>,
    /// The viewer's IPC send half — kept ONLY for identity matching on detach
    /// (a dropped brain connection removes its viewer rows); never written here.
    send: SharedSend,
    /// The writer thread handle (held for ownership; it exits when `tx` drops or
    /// a socket write fails).
    _writer: JoinHandle<()>,
}

/// A bounded, sequenced output log for one session — the gapless-resume buffer
/// AND the W2.5 controller/viewer fan-out hub (REQ-RCVIEW-1).
///
/// The session's single drain thread is the only producer ([`OutputLog::append`]).
/// Output fans out to ONE interactive `controller` (the authoritative,
/// cursor-advancing, blocking path) plus ANY NUMBER of read-only `viewers` (each
/// an isolated bounded queue + writer thread, evicted on overflow). Every
/// controller send and viewer `try_send` happens while the enclosing
/// `Mutex<OutputLog>` is held, so frames reach each sink in strict `seq` order.
struct OutputLog {
    session_id: u64,
    next_seq: u64,
    ring: VecDeque<(u64, Vec<u8>)>,
    cap_chunks: usize,
    /// The single interactive controller's IPC send (input + EXCLUSIVE resize;
    /// advances `delivered_through`). `None` ⇒ undriven (no controller).
    controller: Option<SharedSend>,
    /// The controller's identity: `Some(node)` = a remote operator, `None` =
    /// local (the daemon brain's spawn-reader / resume). Controller-ownership is
    /// keyed on this — a same-`by` re-subscribe silently re-takes (no
    /// `Displaced`, the gate-#7 self-kick guard); only a DIFFERENT remote's
    /// `Take` displaces. Paired with `controller` (both `Some` or both `None`).
    controller_by: Option<String>,
    /// The read-only viewers, keyed by a broker-minted viewer id.
    viewers: HashMap<u64, ViewerSink>,
    /// Monotonic viewer-id source.
    next_viewer_id: u64,
    /// The owning endpoint (the spawn's label; empty = unlabeled / raw test).
    /// The broker is the SINGLE writer of `driven_by` + `viewer_count` on this
    /// perch (REQ-RCVIEW-1, resolves the clear-race); empty ⇒ nowhere to stamp.
    endpoint: String,
    /// Last-known PTY size — seeded at spawn, updated on a controller resize.
    /// Pushed to a viewer on attach + to all viewers on resize (the letterbox
    /// feed); the broker holds it since the PTY exposes no size getter.
    size: (u16, u16),
    /// Cursor-of-record (restoration D4-1, ADR-0018 Q6): the next seq a
    /// re-attaching CONTROLLER still needs (= highest seq **successfully written**
    /// to the controller, +1). It is **log** state, not subscriber state, so it
    /// **survives `detach_if`** — a dead brain leaves its resume point behind, and
    /// a cold-starting brain reads it (via `KIND_SESSIONS` → `resume_seq`) instead
    /// of replaying the whole ring from 0. **Viewer writes NEVER move it**
    /// (REQ-RCVIEW-1: the brain-resume cursor tracks the controller only).
    ///
    /// **Advanced only on a successful socket write** (`Ok`), and **monotonic**
    /// (never decreases) — see [`advanced_cursor`]. Both invariants are
    /// load-bearing for at-least-once output: advancing past a *failed* write to a
    /// dying-but-not-yet-detached controller would make resume skip that chunk
    /// forever (at-least-once → at-most-once); decreasing on a deliberate
    /// `attach(sub, 0)` rewind-replay would reset the resume point under a live
    /// reader.
    delivered_through: u64,
}

/// The post-write resume cursor (pure — the D4-1 unit kernel, mirroring
/// `brainproc::consumer_gate`/`next_backoff`): advance to `seq + 1` **only** on a
/// successful write, and **monotonically** (never below `current`). A failed
/// write (`write_ok == false`) leaves the cursor untouched, and a re-send of an
/// already-delivered `seq` (an `attach(sub, 0)` rewind, `seq + 1 <= current`)
/// cannot lower it. This is the exact decision the `let _ = write_frame(...)`
/// swallow used to drop (doyle D4-1 amendments 1–3).
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
fn advanced_cursor(current: u64, seq: u64, write_ok: bool) -> u64 {
    if write_ok {
        current.max(seq + 1)
    } else {
        current
    }
}

impl OutputLog {
    fn new(session_id: u64, cap_chunks: usize, endpoint: String, size: (u16, u16)) -> Self {
        OutputLog {
            session_id,
            next_seq: 0,
            ring: VecDeque::new(),
            cap_chunks,
            controller: None,
            controller_by: None,
            viewers: HashMap::new(),
            next_viewer_id: 0,
            endpoint,
            size,
            delivered_through: 0,
        }
    }

    /// Append a freshly-produced output chunk: assign the next `seq`, retain it
    /// in the bounded ring, then FAN OUT — the controller on the authoritative
    /// blocking path (advancing the delivered cursor only on a successful write,
    /// D4-1), and each viewer via a `try_send` to its bounded queue, EVICTING any
    /// viewer whose queue overflows or whose writer has gone. The drain thread
    /// NEVER touches a viewer socket, so no viewer can stall it
    /// (REQ-HAZARD-VIEWER-ISOLATION). Called only from the session's drain thread.
    fn append(&mut self, bytes: &[u8]) {
        let seq = self.next_seq;
        self.next_seq += 1;
        self.ring.push_back((seq, bytes.to_vec()));
        while self.ring.len() > self.cap_chunks {
            self.ring.pop_front();
        }
        let frame = output_envelope(self.session_id, seq, bytes);
        // Controller: authoritative, blocking, advances the resume cursor.
        if let Some(sub) = &self.controller {
            let write_ok = match sub.lock() {
                Ok(mut s) => write_frame(&mut *s, &frame).is_ok(),
                Err(_) => false,
            };
            self.delivered_through = advanced_cursor(self.delivered_through, seq, write_ok);
        }
        // Viewers: non-blocking fan-out; a full / disconnected queue = eviction.
        if !self.viewers.is_empty() {
            let mut evicted: Vec<u64> = Vec::new();
            for (vid, sink) in self.viewers.iter() {
                if viewer_send_evicts(&sink.tx.try_send(frame.clone())) {
                    evicted.push(*vid);
                }
            }
            if !evicted.is_empty() {
                for vid in evicted {
                    self.viewers.remove(&vid);
                }
                self.stamp_viewer_count();
            }
        }
    }

    /// Become (or silently re-take) the interactive controller from `from_seq`.
    /// Replays the ring to the new controller and advances the delivered cursor
    /// to the last-successfully-written seq + 1 (D4-1: never the unsent tail, and
    /// only ever forward). Stamps `driven_by` to the new controller's identity.
    fn become_controller(&mut self, sub: SharedSend, by: Option<String>, from_seq: u64) {
        self.controller = Some(Arc::clone(&sub));
        self.controller_by = by;
        let mut last_ok: Option<u64> = None;
        if let Ok(mut s) = sub.lock() {
            for (seq, bytes) in self.ring.iter().filter(|(s, _)| *s >= from_seq) {
                let frame = output_envelope(self.session_id, *seq, bytes);
                if write_frame(&mut *s, &frame).is_ok() {
                    last_ok = Some(*seq);
                } else {
                    break; // stop at the first failed write; cursor stays put
                }
            }
        }
        if let Some(seq) = last_ok {
            self.delivered_through = advanced_cursor(self.delivered_through, seq, true);
        }
        self.stamp_driven_by();
    }

    /// Add a read-only viewer: snapshot the ring from `from_seq` into the writer
    /// thread (NOT through the bounded live queue — replay must not trip the
    /// overflow eviction), seed it the current size frame first, then run the
    /// live queue. The drain thread fans live frames in via `try_send`.
    fn add_viewer(&mut self, sub: SharedSend, from_seq: u64) {
        let vid = self.next_viewer_id;
        self.next_viewer_id += 1;
        // The size frame + the ring replay are the writer's initial batch (owned
        // by the thread, not the bounded queue).
        let mut initial: Vec<Envelope> =
            vec![size_envelope(self.session_id, self.size.0, self.size.1)];
        initial.extend(
            self.ring
                .iter()
                .filter(|(s, _)| *s >= from_seq)
                .map(|(seq, bytes)| output_envelope(self.session_id, *seq, bytes)),
        );
        let (tx, rx) = sync_channel::<Envelope>(VIEWER_CHANNEL_DEPTH);
        let writer_send = Arc::clone(&sub);
        let writer = thread::spawn(move || viewer_writer(writer_send, initial, rx));
        self.viewers.insert(
            vid,
            ViewerSink {
                tx,
                send: sub,
                _writer: writer,
            },
        );
    }

    /// Resolve a controller/viewer subscribe under the W2.5 model
    /// (REQ-RCVIEW-1 / REQ-KICK-1). Returns the [`SubscribeOutcome`]; performs
    /// the controller take / viewer add / loud displace as a side effect. See the
    /// identity matrix in the body — the `by`-keyed logic is what makes a
    /// brain-restart re-serve silent (gate #7) and a cross-operator `Take` loud.
    fn resolve_subscribe(
        &mut self,
        sub: SharedSend,
        from_seq: u64,
        intent: AttachIntent,
        by: Option<String>,
    ) -> SubscribeOutcome {
        if intent == AttachIntent::Viewer {
            if self.viewers.len() >= MAX_VIEWERS {
                // Soft cap reached — refuse the viewer (bounds thread count).
                return SubscribeOutcome::BusyControlled {
                    by: "viewer cap reached".to_string(),
                };
            }
            self.add_viewer(sub, from_seq);
            self.stamp_viewer_count();
            return SubscribeOutcome::Viewer;
        }
        // Control or Take.
        if self.controller.is_none() {
            // Free endpoint → become controller (nothing to displace).
            self.become_controller(sub, by, from_seq);
            return SubscribeOutcome::Controller;
        }
        let same_identity = self.controller_by == by;
        if same_identity {
            // Same operator (or both local) re-serving — silent re-take. This is
            // the gate-#7 path: a successor re-taking the slot after a brain
            // restart NEVER fires Displaced.
            self.become_controller(sub, by, from_seq);
            return SubscribeOutcome::Controller;
        }
        if by.is_none() {
            // The LOCAL brain re-attaching while a REMOTE controls — never steal;
            // fall back to a silent viewer (gate #7 for the local-resume path).
            self.add_viewer(sub, from_seq);
            self.stamp_viewer_count();
            return SubscribeOutcome::Viewer;
        }
        if self.controller_by.is_none() {
            // A remote takes an UNDRIVEN session (only the local spawner held the
            // slot) — silent control, no loud displace (there is no remote driver
            // to notify).
            self.become_controller(sub, by, from_seq);
            return SubscribeOutcome::Controller;
        }
        // A DIFFERENT remote controls it.
        match intent {
            AttachIntent::Take => {
                // The ONLY loud-displace path: notify the incumbent, then take.
                if let Some(old) = &self.controller {
                    let frame = displaced_envelope(self.session_id, by.as_deref().unwrap_or(""));
                    if let Ok(mut s) = old.lock() {
                        let _ = write_frame(&mut *s, &frame);
                    }
                }
                self.become_controller(sub, by, from_seq);
                SubscribeOutcome::TookControl
            }
            _ => SubscribeOutcome::BusyControlled {
                by: self.controller_by.clone().unwrap_or_default(),
            },
        }
    }

    /// Update the stored PTY size and push a `size` frame to every viewer (the
    /// letterbox feed). Called on a controller resize. Viewer pushes are
    /// non-blocking with eviction, like [`OutputLog::append`].
    fn set_size_and_notify(&mut self, rows: u16, cols: u16) {
        self.size = (rows, cols);
        if self.viewers.is_empty() {
            return;
        }
        let frame = size_envelope(self.session_id, rows, cols);
        let mut evicted: Vec<u64> = Vec::new();
        for (vid, sink) in self.viewers.iter() {
            if viewer_send_evicts(&sink.tx.try_send(frame.clone())) {
                evicted.push(*vid);
            }
        }
        if !evicted.is_empty() {
            for vid in evicted {
                self.viewers.remove(&vid);
            }
            self.stamp_viewer_count();
        }
    }

    /// Detach `sub` from whatever role it held: clear the controller slot iff it
    /// is the current controller (a no-op if a newer brain took over — a late
    /// disconnect can't unseat the live controller), and remove any viewer rows
    /// owned by `sub`. Leaves `delivered_through` intact (D4-1). Re-stamps
    /// `driven_by` / `viewer_count` when a role actually changed.
    fn detach_if(&mut self, sub: &SharedSend) {
        if let Some(cur) = &self.controller {
            if Arc::ptr_eq(cur, sub) {
                self.controller = None;
                self.controller_by = None;
                self.stamp_driven_by();
            }
        }
        let before = self.viewers.len();
        self.viewers.retain(|_, sink| !Arc::ptr_eq(&sink.send, sub));
        if self.viewers.len() != before {
            self.stamp_viewer_count();
        }
    }

    /// Every sink currently attached (controller + viewers) — the recipients of
    /// an `exit` frame.
    fn all_sinks(&self) -> Vec<SharedSend> {
        let mut v: Vec<SharedSend> = Vec::new();
        if let Some(c) = &self.controller {
            v.push(Arc::clone(c));
        }
        for sink in self.viewers.values() {
            v.push(Arc::clone(&sink.send));
        }
        v
    }

    /// Stamp the perch's `driven_by` to the current controller's identity (the
    /// broker is the single writer — resolves the clear-race). The remote-drive
    /// detection fact (REQ-REACH-1) moved here from `serve_attach` so a displaced
    /// operator's teardown can never wipe the new controller's marker.
    /// Best-effort: a session served without a perch (raw broker tests) has
    /// nowhere to record.
    // [impl->REQ-RCVIEW-1] [impl->REQ-REACH-1]
    fn stamp_driven_by(&self) {
        if self.endpoint.is_empty() {
            return;
        }
        let perch = resolve_perch_path(&self.endpoint, ParentHint::Infer);
        let _ = spt_store::info::set_driven_by(&perch, self.controller_by.as_deref());
    }

    /// Stamp the perch's `viewer_count` to the live viewer count (broker is the
    /// single writer). Best-effort, like [`OutputLog::stamp_driven_by`].
    // [impl->REQ-RCVIEW-1]
    fn stamp_viewer_count(&self) {
        if self.endpoint.is_empty() {
            return;
        }
        let perch = resolve_perch_path(&self.endpoint, ParentHint::Infer);
        let _ = spt_store::info::set_viewer_count(&perch, self.viewers.len() as u32);
    }

    /// Whether `sub` is the current controller (the resize-exclusivity check).
    fn is_controller(&self, sub: &SharedSend) -> bool {
        self.controller
            .as_ref()
            .is_some_and(|c| Arc::ptr_eq(c, sub))
    }

    /// The next seq a re-attaching controller still needs (the persisted resume
    /// cursor surfaced in `KIND_SESSIONS` → `resume_seq`).
    fn delivered_through(&self) -> u64 {
        self.delivered_through
    }
}

/// A viewer's dedicated writer thread (REQ-HAZARD-VIEWER-ISOLATION): write the
/// `initial` batch (size frame + ring replay) once, then drain live frames from
/// `rx` with a BLOCKING socket write each. A failed write (dead viewer) ends the
/// thread; the drain thread's next `try_send` then sees `Disconnected` and
/// evicts the row. The drain thread itself never blocks on this socket.
fn viewer_writer(send: SharedSend, initial: Vec<Envelope>, rx: Receiver<Envelope>) {
    for frame in &initial {
        let ok = match send.lock() {
            Ok(mut s) => write_frame(&mut *s, frame).is_ok(),
            Err(_) => false,
        };
        if !ok {
            return;
        }
    }
    while let Ok(frame) = rx.recv() {
        let ok = match send.lock() {
            Ok(mut s) => write_frame(&mut *s, &frame).is_ok(),
            Err(_) => false,
        };
        if !ok {
            return;
        }
    }
}

/// A child the broker hosts under a PTY — the §B-owned resources for one
/// session, plus its gapless-resume output log.
struct HostedSession {
    /// The PTY + child (PTY master/writer + spawned child process).
    session: Arc<PtySession>,
    /// The output drain pump (kept so teardown can stop it).
    #[allow(dead_code)] // held for ownership/teardown; not read directly
    drain: Drain,
    /// The sequenced, buffered output + current subscriber.
    log: Arc<Mutex<OutputLog>>,
    /// The owning endpoint (the spawn's label; empty = unlabeled). The
    /// inbound dispatcher's gate subject for remote-drive (D9-1).
    endpoint: String,
}

/// The broker process kernel. Owns the listening socket, the table of hosted
/// sessions, and the broker-owned idempotency anchor (B5); holds no logic state
/// (ADR-0004 §B). The [`EffectJournal`] is a §B resource — the durable
/// recovery anchor a brain crash must not lose, so it lives in the broker, not


doyle's root **confirmed in code**:

`OutputLog::append` (broker.rs:212-243), called only from the drain thread (broker.rs:879 `log_drain.lock().unwrap().append(chunk)`):
- **Controller** (221-227): `sub.lock()` → `write_frame(...)` = **synchronous blocking socket write INLINE on the drain thread, while holding the `Mutex<OutputLog>`**.
- **Viewers** (228-242): `try_send` to bounded queue, evict on overflow — never blocks.

So the wedge chain: controller's 64KB loopback duplex fills (slow/flooded rc) → `write_frame` blocks → drain thread parks **inside `append` holding the OutputLog mutex** → (1) no PTY output drained (echo stall), (2) any new attach/resize to that session blocks on the held mutex (wedge), (3) PTY output buffer fills → harness blocks on stdout → stops reading stdin → my `write_input` facet blocks too (downstream consequence, not the trigger).

My input-side facet is downstream of doyle's output-side park. doyle's is the root.

Now PROVE it on the real fixture. Check existing broker test harness + the seeded REQs to pick the cheapest faithful repro.


Bash

ls C:/Users/decid/Documents/projects/spt-core/crates/spt-daemon/tests/ 2>/dev/null; echo "---REQ ENTRIES---"; grep -n -A4 'REQ-HAZARD-INJECT-CONTROL-COEXIST\|REQ-HAZARD-RC-INPUT-KEY-ENCODING\|REQ-HAZARD-ATTACH-WEDGE' C:/Users/decid/Documents/projects/spt-core/traceable-reqs.toml

List daemon tests + show seeded REQ entries


<owl_messages>
<owl_message from="doyle" priority="high">Good catch on the input-side facet — that's NOT benign, keep it in scope. The shared PTY writer mutex (pty.rs write_input + the DSR auto-answer reader.rs:136 contending it) is the SAME serialization point W2's "spt-core owns every PTY write, applied ATOMICALLY" depends on — so the two facets interact, treat them as one root with two faces.

Gating distinction to nail on the real fixture (label which park your instrumentation hits):
(a) OUTPUT park = append→controller write_frame blocks the drain thread (broker.rs:221) — the output-stall / echo-lag / wedge symptom.
(b) INPUT park = write_input write_all blocks the dispatch thread on a full ConPTY input buffer.
(c) MUTEX contention = DSR auto-answer reader vs dispatch input both grabbing the writer mutex → can interleave-stall or, worst case, deadlock with (b).

My read: (a) is THE operator-visible 'sluggish/wedge' for the output-stall symptom; (b)/(c) are the injection-coexist hazard (one injected keystroke ok, the next wedges = a writer-mutex/ConPTY-buffer park). Likely BOTH are real and the W1 fix must bound all three: controller delivery off the drain thread (a), a bounded/fail-fast PTY write so write_input can't park forever (b), and the DSR auto-answer must not hold the writer mutex across a blocking write (c). That last one is the atomic-write coordination W2 reuses — design it once here.

Confirm on the real fixture which park(s) actually fire (don't fix a benign one), ping me the labeled root + the part-2 byte, I gate then. Standing by.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


access.rs
attach.rs
brain_swap.rs
broker.rs
budget.rs
daemon_e2e.rs
daemon_lifecycle_real_brain.rs
digest.rs
dispatch.rs
handoff.rs
idempotent.rs
mesh.rs
netbroker.rs
netstream.rs
notifsync.rs
pairjoin.rs
presence.rs
propagate.rs
pump.rs
pumpdeadline.rs
replicate.rs
reseed.rs
resume.rs
rosterprop.rs
seedproofx.rs
shellchan.rs
sync.rs
two_origin_spanning.rs
twohost.rs
wanmsg.rs
xfer.rs
---REQ ENTRIES---
1143:id = "REQ-HAZARD-ATTACH-WEDGE"
1144-title = "A legitimately dead PTY child (real crash/kill) + an undrained operator pump must NOT wedge the broker for all other clients. ROOT (v0.12.0 real-harness defect): loopback attach output is a blocking write_all into a bounded 64KB tokio duplex (nethost.rs:1040,1090); when the operator's rc pump stops draining (tab closed) the buffer fills and write_all blocks forever (the 'loopback never hangs' assumption at nethost.rs:1103 is false), parking a worker in the 2-worker net runtime (nethost.rs:640); a couple of these saturate BOTH workers → every new attach / `endpoint run` stalls right after 'PUMP_IPC_READER: spawned' → 30s FIRST_EVENT_GRACE → 'no output / dead or wedged'; `daemon stop` cannot join the stuck workers. DISTINCT from the removed B1 path-(c) mutex deadlock. DISPOSITION = PROVE-DON'T-CHANGE (doyle GATE-PASS @e883f45, 2026-06-18): this ROOT is the SUPERSEDED v0.12.0 hypothesis — the post-L0 code ALREADY prevents the wedge, so NO fail-fast / worker-count code was added. serve_attach forwards fire-and-forget (net_stream_send op_id=None) and the broker-side send_stream is already BROKER-QUIC-DEADLINE-bounded (bounded_block_on, 10s); the loopback duplex is drained broker-INTERNALLY by the operator row's own read pump (RecvHalf::Loopback, retentive_cap==0 → evict-not-park) so a dead rc (a dropped IPC subscriber) never backs peer_w up; bounded_block_on parks the BROKER DISPATCH thread, not a net worker → no worker-pool exhaustion (full mechanism in the required_stages comment). Folds the status=online sub-check: a dead spt-hosted endpoint is marked OFFLINE within one reconcile tick on abrupt child death (broker exit-waiter reaps the session → B2 sees it absent) — PROVEN, no change. (v0.12.1)"
1145-required_stages = ["int"]  # ACTIVATED v0.12.1 L2 (todlando) as PROVE-DON'T-CHANGE (like the M11-W4 capstone): the post-L0 code ALREADY prevents the wedge, so a test proves it and NO impl/unit code changes. The original "loopback write_all blocks forever → parks the 2-worker net runtime" root is STALE: (1) serve_attach forwards via fire-and-forget net_stream_send(op_id=None) → broker-side send_stream is ALREADY bounded by bounded_block_on (BROKER-QUIC-DEADLINE, 10s), not forever; (2) the loopback duplex is drained broker-INTERNALLY by the operator row's own read pump (nethost.rs RecvHalf::Loopback), which for an ordinary attach stream (retentive_cap==0) NEVER parks, so peer_w never backs up on a dead rc — a dead rc is just a dropped IPC subscriber against a bounded (evicting) ring; (3) bounded_block_on uses runtime.handle().block_on → parks the BROKER DISPATCH thread, not a net worker, so the worker-pool-exhaustion mechanism doesn't hold. int = attach_wedge_e2e.rs on a REAL detached daemon + the dummy-harness fixture: bring up + SERVE the victim (rc sees its tick), then abruptly kill rc (undrained pump) + kill the PTY child, and assert a NEW endpoint still comes online + is served (no wedge), the dead endpoint is OFFLINED within one reconcile tick (folds L3 — broker exit-waiter reaps the dead session; reconcile_hosted_liveness clears the latch), and `daemon stop` completes bounded (folds L4). GREEN 2026-06-18
1146-[[requirements]]
1147-id = "REQ-PICKER-HISTORY-FRESH"
--
1160:title = "A clean `spt rc` attach to a LIVE spt-hosted (`endpoint run`) harness must DELIVER the harness's PTY output. KEYSTONE — the operator's central 'attach shows no output' symptom, reproduced on the real dummy-harness fixture (v0.12.1 Wave 1) with NO death and NO wedge: bringup succeeds (online, harness pid alive + heartbeating, psyche hosted), the attach CONNECTS (PUMP_IPC_READER spawned, no RC_FAIL, holds the full window) — but receives EXACTLY 0 bytes over 10s of the harness's flushed [session.self] stdout. DISTINCT from REQ-HAZARD-VIEWER-CLOSE-DETACH (death) and REQ-HAZARD-ATTACH-WEDGE (dead-child backpressure): here the harness is ALIVE and the attach is a clean first subscribe. This BLOCKS the 'view is independent' invariant — re-attach is meaningless if a live endpoint-run harness shows nothing. KNOWN-GOOD (rules out 'no drain'): attach.rs `local_attach_via_loopback_conn_rides_the_same_pump` + `broker_spawns_the_pty_child_in_the_requested_cwd` prove the broker DOES drain+fan a `spawn_session` PTY child to a loopback attach over the SAME transport rc uses. Both spawn_session and endpoint-run's spawn_session_pid send KIND_SPAWN → the same dispatch_spawn (broker.rs:706/835) which starts the per-session drain+OutputLog — so the gap is NARROWER than 'no drain', endpoint-run-specific. Root candidates: (a) spawn_session_pid's SpawnReq stdio/env/cwd differs so the dummy's stdout isn't the captured ConPTY; (b) the harness stdout WRITE BLOCKS because the ConPTY buffer fills (drain not reading THIS pty) — explains alive-but-0-bytes; (c) ConPTY reader-park (KH 7.6) on this path; (d) `spt rc` resolve_session/subscribe for an endpoint-run session subscribes to the wrong/empty log. (v0.12.1)"
1161-required_stages = ["impl", "unit", "int"]  # ROOT (v0.12.1 L0, instrumented on the real dummy fixture): NOT candidates a/b/c/d. The drain DOES fill the OutputLog ring; the bug is a REPLAY-vs-FORWARD IPC DEADLOCK in serve_attach. It RECEIVES session output (broker→serve) AND SENDS its wire forwards (serve→broker) on the SAME broker IPC conn; become_controller's SYNCHRONOUS inline multi-frame ring replay holds the broker's single-threaded per-conn handler, so it can't read serve's forwards → mutual backpressure → operator gets 0 bytes. Triggers ONLY with a NON-EMPTY ring at attach = an already-producing harness = exactly endpoint-run (the loopback E2Es attach to an idle child → empty-ring replay → never caught it; cross-node has the same single-conn multiplex → covered by construction). FIX (Option a, doyle ruling): serve_attach forwards on a SEPARATE broker conn from the one it receives on (send_stream is stream_id-keyed/conn-agnostic; order preserved — one serve thread reads in seq order on conn-1, forwards in order on conn-2); does NOT touch the authoritative delivered_through cursor path. impl = attach.rs serve_attach two-conn split (+broker_name param threaded to dispatch/tests). unit = attach.rs loopback_attach_to_a_prepopulated_ring_delivers_without_deadlock (non-empty ring at attach does not wedge the forward path). int = dummy_harness_e2e rc_saw_tick hard assert (LIVE already-producing endpoint-run harness delivers DUMMY_HARNESS_TICK). DEEPER ROOT / POST-v0.12.1 FOLLOW-UP (Option b): the CONTROLLER replay is synchronous+inline while VIEWERS use the async dedicated-writer-thread+bounded-channel pattern (add_viewer/viewer_writer) — unifying the controller onto that async path removes the inline-replay block at the source, but touches the authoritative cursor path (NOT in this PATCH)
1162-
1163-# ───────────────────────────── v0.12.1 CLI polish (help markdown render) ──────────────────
1164-[[requirements]]
--
1189:id = "REQ-HAZARD-INJECT-CONTROL-COEXIST"
1190:title = "SPINE INVARIANT (v0.13.0 keystone): the broker must accept INJECTED keystrokes into an spt-hosted PTY (the v0.11.0 raw direct-inject today; the ADR-0022 translation-binary choreography tomorrow) WHILE a live `spt rc` controller is attached to the SAME PTY, without (a) the operator losing control, (b) the endpoint latching ONLINE+CONTROLLED, or (c) the broker wedging. The injection inlet is PERMANENT — spt-claude-code requires keystroke injection — so this is root-caused + fixed at the PTY-injection layer, IN STEP with the ADR-0022 delivery redesign that formalizes the inlet. REOPENS the wedge facet of REQ-HAZARD-ATTACH-WEDGE: the v0.12.1 prove-don't-change covered only DEAD-CHILD backpressure, NOT the injection trigger (operator's signal — one injected keystroke succeeds, the next wedges → the single-threaded broker parks on a blocking PTY/loopback write after injection-induced harness output). REPRO-FIRST on the real dummy-harness fixture (NO theory): instrument to nail the exact blocking call before any fix. Fix candidates: non-blocking/fail-fast PTY write, split input/output, bounded-evicting. Mechanism shared with W2 — spt-core owns EVERY PTY write and applies an injected sequence ATOMICALLY (controller input buffered during the sequence, flushed after) so a stash/restore can't be clobbered. CONFIRMED ROOT (doyle /diagnose 2026-06-19, code-grounded): Broker::append (broker.rs:205-227) fans each live output chunk to the CONTROLLER on a SYNCHRONOUS BLOCKING write_frame held inline in the session's drain thread (the 'authoritative, advances delivered_through' path, D4-1), while VIEWERS use a dedicated writer thread + bounded evicting sync_channel (add_viewer:273 / viewer_writer) that can never stall the drain. So a slow/backed-up controller socket — or the full 64KB loopback duplex (the ATTACH-WEDGE buffer) — BLOCKS the drain thread → output stalls → keystroke echoes stall (PERCEIVED input lag) → unrecoverable wedge when the consumer never drains. TRIGGERS ON NORMAL INTERACTIVE rc USE under heavy harness output (TUI redraw), NOT only message injection — same root, wider repro. FIX DIRECTION: move controller delivery off the drain thread onto a dedicated writer (the viewer_writer pattern) BUT preserve the authoritative cursor — block the WRITER thread (not the drain), bound the wedge (deadline → detach/mark-gone, never park forever), never silently evict the operator's authoritative view. (v0.13.0)"
1191-required_stages = []  # SEED — activate [impl,unit,int] at W1 build. int gate (real dummy fixture + real daemon): after an injected keystroke sequence into an rc-attached spt-hosted session, the controller keeps control, no ONLINE+CONTROLLED latch, no wedge, a NEW attach still serves, and `daemon stop` is bounded.
1192-
1193-# ── W2 KEYSTONE: the translation-binary idle-delivery redesign (ADR-0022) ──
1194-[[requirements]]
--
1196:title = "spt-hosted idle message delivery via an adapter TRANSLATION BINARY (ADR-0022). New opt-in manifest key `[message-idle-translation-binary]` = a scalar binary PATH; spt-core LIFECYCLE-manages it (spawn when the endpoint comes up, terminate when it goes down). The binary is a PURE stdin→stdout filter; spt-core owns EVERY PTY write. stdin (JSON-lines): `{type:\"init\",endpoint_id,node}` first · `{type:\"event\",envelope:\"<EVENT…>\"}` per inbound message (ADR-0020 envelope) · `{type:\"input\"}` content-free ping on each operator keystroke (binary tracks user-idle for its own idle-gated buffering; PTY input content NOT duplicated). stdout (JSON-lines): keystroke-commands `{key:…}`/`{delay_ms:…}`/`{text:…}` (extensible). spt-core applies the emitted sequence to the broker PTY ATOMICALLY (the W1 coordination — REQ-HAZARD-INJECT-CONTROL-COEXIST). The daemon poll feed is the ONE idle substrate for both topologies (Q1=A): harness-hosted consumer = the Monitor child, spt-hosted consumer = this binary; spt-core PREFERS a perch's poll listener if one exists (so spt-hosted can run a listener AND keep `spt rc`). Idle-only; busy/mid-turn = adapter hook-injection. Closes the current grounding gap: `api bind` registers no listener port → a listener-less spt-hosted perch SPOOLS inbound (only spooling+adapter-poll works today) → this delivers real inbound into the PTY. The v0.11.0 raw inject is the degenerate `{text:payload}{key:enter}` case. (v0.13.0)"
1197-required_stages = []  # SEED — activate [doc,impl,unit,int] at W2 build (depends W0 ADR-0022, done). doc = manifest-key row in the inject-seam family + CONTEXT.md delivery section (W0 reworded :39). int gate: a real dummy translation-binary delivers an inbound message into an rc-attached spt-hosted session via the choreography WITHOUT breaking control (composes with W1's invariant).
1198-
1199-# ── W3: a fresh spt-hosted endpoint shows under its own project (info.cwd on bind) ──
1200-[[requirements]]
--
1219:id = "REQ-HAZARD-RC-INPUT-KEY-ENCODING"
1220-title = "An `spt rc` session forwards the Backspace key as the VT DEL byte (0x7f), so the hosted TUI (Claude Code) deletes ONE character — never a whole word. SYMPTOM (operator dogfooding): Backspace in an rc session always behaves like ctrl+Backspace — deletes the entire last word. ROOT (doyle /diagnose, code-grounded, byte PENDING HITL confirm): rc is a RAW VERBATIM byte pump — spawn_stdin_reader (rc.rs:152) reads std::io::stdin() bytes under crossterm raw mode and forwards them unchanged (parse_stdin_chunk only intercepts the ctrl-b detach prefix); there is NO key-event encoding and NO 0x08↔0x7f normalization ANYWHERE in the tree (grep: zero SetConsoleMode / ENABLE_VIRTUAL_TERMINAL_INPUT). On Windows, crossterm enable_raw_mode does NOT set ENABLE_VIRTUAL_TERMINAL_INPUT, so the LEGACY console delivers ^H (0x08, ctrl+h) for Backspace instead of VT DEL (0x7f); Claude Code maps ^H → backward-kill-word → the observed whole-word delete. CONFIRM-FIRST (build the loop): an env-gated hexdump in spawn_stdin_reader (SPT_RC_DEBUG_KEYS) prints the forwarded byte; operator presses Backspace + ctrl+Backspace in a real rc session. FIX CANDIDATES: (a) enable ENABLE_VIRTUAL_TERMINAL_INPUT on the rc stdin console on Windows so the console emits proper VT (Backspace→0x7f, arrows/Home/End as CSI) — cleanest, fixes the whole key map not just Backspace; (b) narrow normalize bare 0x08→0x7f in the rc input path (riskier — a real ctrl+h is also 0x08). Prefer (a) unless it regresses other keys. Add a KNOWN-HAZARDS.md entry on landing. (v0.13.0)"
1221-required_stages = []  # SEED — activate [impl,unit] at W7 build (Windows-specific; unit can drive the byte-normalization/console-mode decision seam purely; the end-to-end keypress is HITL operator-confirm). doc = KNOWN-HAZARDS.md entry. Confirm the forwarded byte via the SPT_RC_DEBUG_KEYS hexdump BEFORE the fix (gate the root, not the patch).


doyle high-priority: input-side facet is in scope — one root, three faces (a) output park, (b) input park, (c) DSR-mutex contention. Fix must bound all three. Acking.


$OWL send doyle <<'EOF'
ACK — one root, three faces, instrumentation will LABEL which park fires (don't fix a benign one):
(a) OUTPUT park = append→controller write_frame blocks the drain thread (broker.rs:221) — output-stall/echo-lag/wedge.
(b) INPUT park = write_input write_all blocks the dispatch thread on a full ConPTY input buffer (pty.rs:238).
(c) MUTEX contention = DSR auto-answer (reader.rs:136) vs dispatch write_input both grabbing the shared writer mutex → interleave-stall, worst-case deadlock with (b).

Confirmed structurally already: append controller path is inline-blocking on the drain thread holding Mutex<OutputLog> (asymmetric vs viewers' dedicated-writer+bounded-evicting-channel). That's (a). (b)/(c) are the injection-coexist faces via the shared SharedWriter mutex (pty.rs:27/237 + reader.rs:136). Same serialization point W2's "spt-core owns every PTY write, applied atomically" reuses — agreed, design the coordination once here.

Repro plan (real dummy fixture + real daemon, instrumentation env-gated, labels A/B/C park enter/exit):
- Trigger 1 (output backpressure, no injection): rc-attach controller, flood harness stdout, hold the rc consumer slow so the 64KB loopback duplex fills → expect APPEND_CTRL_WRITE_ENTER with no EXIT = (a) park proven; assert second attach to same endpoint wedges + echo unbounded.
- Trigger 2 (injection): inject a sequence into the rc-attached session; harness stops draining stdin while busy → expect write_input write_all park (b) and/or DSR-mutex stall (c).
Will report which park(s) actually fire with the brain.stderr evidence, labeled, BEFORE any patch. Also delivering the part-2 SPT_RC_DEBUG_KEYS byte in the same ping.

Fix target acked: (a) controller off the drain thread (viewer_writer pattern, but authoritative cursor preserved, writer thread bounded by deadline→detach/mark-gone, never silent-evict the operator); (b) bounded/fail-fast PTY write; (c) DSR answer must not hold the writer mutex across a blocking write. Building the repro now.
EOF

Ack doyle three-park labeling directive


➜ SENT:doyle


Building the repro. Read existing broker/attach test harness to learn how to construct a Broker + controller + flooding child in-process (fastest faithful park-(a) proof), and the loopback duplex cap.


1	//! B1 integration: a [`Broker`] hosts one real PTY child and relays its output
2	//! and injected input over the versioned local IPC, on both OSes.
3	//!
4	//! The child is an echo program (`cat` on Unix, `findstr "."` on Windows — the
5	//! same cross-OS shape spt-term's injection tests use): bytes the brain injects
6	//! round-trip through the real PTY and come back as `output` events. We then
7	//! kill it and observe the `exit` event. This exercises the whole B1 contract —
8	//! spawn under a broker PTY, stream output, accept input, report exit — through
9	//! the actual named-pipe / UDS transport.
10	//!
11	//! The brain is driven **single-threaded**: send a command, then read frames
12	//! until the expected reply/event arrives (accumulating any interleaved output).
13	//! interprocess `Stream::split()` shares the underlying fd, so a split client
14	//! would only signal EOF once *both* halves drop; keeping one `Stream` means
15	//! dropping it closes the fd, the broker's command loop hits EOF, and it tears
16	//! the session down deterministically — which we then assert.
17	// [int->REQ-DAEMON-2]
18
19	use std::sync::atomic::{AtomicU32, Ordering};
20	use std::sync::Arc;
21	use std::thread;
22	use std::time::Duration;
23
24	use interprocess::local_socket::Stream;
25	use spt_daemon::codec::{read_frame, write_frame};
26	use spt_daemon::frame::{Envelope, Role};
27	use spt_daemon::msg::{
28	    decode_bytes, encode_bytes, EndpointInjected, EndpointInputReq, ExitEvent, InputReq, KillReq,
29	    OutputEvent, SpawnReq, Spawned, SubscribeReq, KIND_ENDPOINT_INJECTED, KIND_ENDPOINT_INPUT,
30	    KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL, KIND_OUTPUT, KIND_SPAWN, KIND_SPAWNED,
31	    KIND_SUBSCRIBE,
32	};
33	use spt_daemon::transport::{send_hello, LocalSocketTransport};
34	use spt_daemon::Broker;
35
36	static SEQ: AtomicU32 = AtomicU32::new(0);
37	fn unique_name() -> String {
38	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
39	    format!("spt-daemon-b1-{}-{}.sock", std::process::id(), n)
40	}
41
42	/// The echo child: reads stdin, writes each line to stdout.
43	fn echo_spawn_req() -> SpawnReq {
44	    #[cfg(unix)]
45	    let (program, args) = ("cat".to_string(), Vec::<String>::new());
46	    #[cfg(windows)]
47	    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
48	    SpawnReq {
49	        program,
50	        args,
51	        rows: 24,
52	        cols: 80,
53	        endpoint: String::new(),
54	        cwd: None,
55	        env: Default::default(),
56	    }
57	}
58
59	fn send(conn: &mut Stream, kind: &str, payload: serde_json::Value) {
60	    write_frame(conn, &Envelope::new(kind, payload)).expect("send frame");
61	}
62
63	fn main_test() {
64	    let name = unique_name();
65	    let broker = Broker::bind(&name).expect("bind broker");
66
67	    // The broker accepts one brain and serves it on a handler thread.
68	    let server_broker = Arc::clone(&broker);
69	    let server = thread::spawn(move || {
70	        let handle = server_broker.serve_once().expect("accept brain");
71	        handle.join().expect("handler thread");
72	    });
73
74	    // Brain side: connect (retry briefly in case accept lags the bind).
75	    let mut conn = None;
76	    for _ in 0..200 {
77	        match LocalSocketTransport::connect(&name) {
78	            Ok(c) => {
79	                conn = Some(c);
80	                break;
81	            }
82	            Err(_) => thread::sleep(Duration::from_millis(10)),
83	        }
84	    }
85	    let mut conn = conn.expect("connect to broker");
86	    // (No per-read timeout: Windows named pipes reject I/O timeouts. The broker
87	    // always pushes `spawned`/`output`/`exit` frames, so the blocking reads
88	    // below return; a genuine stall is caught by the test runner's own timeout.)
89
90	    send_hello(&mut conn, Role::Brain).expect("send hello");
91
92	    // Spawn the echo child under a broker PTY; read until the `spawned` reply,
93	    // accumulating any output that interleaves.
94	    send(
95	        &mut conn,
96	        KIND_SPAWN,
97	        serde_json::to_value(echo_spawn_req()).unwrap(),
98	    );
99	    let mut out = Vec::<u8>::new();
100	    let sid = loop {
101	        let f = read_frame(&mut conn).expect("frame before spawned");
102	        match f.kind.as_str() {
103	            KIND_SPAWNED => {
104	                break serde_json::from_value::<Spawned>(f.payload)
105	                    .unwrap()
106	                    .session_id;
107	            }
108	            KIND_OUTPUT => accumulate_output(&f, &mut out),
109	            _ => {}
110	        }
111	    };
112
113	    // Inject a line; it must round-trip through the real PTY back as output.
114	    send(
115	        &mut conn,
116	        KIND_INPUT,
117	        serde_json::to_value(InputReq {
118	            session_id: sid,
119	            data_b64: encode_bytes(b"INJECT-ALPHA\r"),
120	            op_id: None,

//! B1 integration: a [`Broker`] hosts one real PTY child and relays its output
//! and injected input over the versioned local IPC, on both OSes.
//!
//! The child is an echo program (`cat` on Unix, `findstr "."` on Windows — the
//! same cross-OS shape spt-term's injection tests use): bytes the brain injects
//! round-trip through the real PTY and come back as `output` events. We then
//! kill it and observe the `exit` event. This exercises the whole B1 contract —
//! spawn under a broker PTY, stream output, accept input, report exit — through
//! the actual named-pipe / UDS transport.
//!
//! The brain is driven **single-threaded**: send a command, then read frames
//! until the expected reply/event arrives (accumulating any interleaved output).
//! interprocess `Stream::split()` shares the underlying fd, so a split client
//! would only signal EOF once *both* halves drop; keeping one `Stream` means
//! dropping it closes the fd, the broker's command loop hits EOF, and it tears
//! the session down deterministically — which we then assert.
// [int->REQ-DAEMON-2]

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use interprocess::local_socket::Stream;
use spt_daemon::codec::{read_frame, write_frame};
use spt_daemon::frame::{Envelope, Role};
use spt_daemon::msg::{
    decode_bytes, encode_bytes, EndpointInjected, EndpointInputReq, ExitEvent, InputReq, KillReq,
    OutputEvent, SpawnReq, Spawned, SubscribeReq, KIND_ENDPOINT_INJECTED, KIND_ENDPOINT_INPUT,
    KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL, KIND_OUTPUT, KIND_SPAWN, KIND_SPAWNED,
    KIND_SUBSCRIBE,
};
use spt_daemon::transport::{send_hello, LocalSocketTransport};
use spt_daemon::Broker;

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-b1-{}-{}.sock", std::process::id(), n)
}

/// The echo child: reads stdin, writes each line to stdout.
fn echo_spawn_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq {
        program,
        args,
        rows: 24,
        cols: 80,
        endpoint: String::new(),
        cwd: None,
        env: Default::default(),
    }
}

fn send(conn: &mut Stream, kind: &str, payload: serde_json::Value) {
    write_frame(conn, &Envelope::new(kind, payload)).expect("send frame");
}

fn main_test() {
    let name = unique_name();
    let broker = Broker::bind(&name).expect("bind broker");

    // The broker accepts one brain and serves it on a handler thread.
    let server_broker = Arc::clone(&broker);
    let server = thread::spawn(move || {
        let handle = server_broker.serve_once().expect("accept brain");
        handle.join().expect("handler thread");
    });

    // Brain side: connect (retry briefly in case accept lags the bind).
    let mut conn = None;
    for _ in 0..200 {
        match LocalSocketTransport::connect(&name) {
            Ok(c) => {
                conn = Some(c);
                break;
            }
            Err(_) => thread::sleep(Duration::from_millis(10)),
        }
    }
    let mut conn = conn.expect("connect to broker");
    // (No per-read timeout: Windows named pipes reject I/O timeouts. The broker
    // always pushes `spawned`/`output`/`exit` frames, so the blocking reads
    // below return; a genuine stall is caught by the test runner's own timeout.)

    send_hello(&mut conn, Role::Brain).expect("send hello");

    // Spawn the echo child under a broker PTY; read until the `spawned` reply,
    // accumulating any output that interleaves.
    send(
        &mut conn,
        KIND_SPAWN,
        serde_json::to_value(echo_spawn_req()).unwrap(),
    );
    let mut out = Vec::<u8>::new();
    let sid = loop {
        let f = read_frame(&mut conn).expect("frame before spawned");
        match f.kind.as_str() {
            KIND_SPAWNED => {
                break serde_json::from_value::<Spawned>(f.payload)
                    .unwrap()
                    .session_id;
            }
            KIND_OUTPUT => accumulate_output(&f, &mut out),
            _ => {}
        }
    };

    // Inject a line; it must round-trip through the real PTY back as output.
    send(
        &mut conn,
        KIND_INPUT,
        serde_json::to_value(InputReq {
            session_id: sid,
            data_b64: encode_bytes(b"INJECT-ALPHA\r"),
            op_id: None,


104:const STREAM_READ_CHUNK: usize = 64 * 1024;
165:    /// `retentive_cap = 0` (a slow/absent brain must not OOM the broker via an
167:    retentive_cap: usize,
179:            retentive_cap: 0,
185:        self.retentive_cap = cap;
196:        if self.retentive_cap == 0 {
395:    /// its backpressure cap (M11-W3, REQ-SHELL-4) — see [`StreamLog::retentive_cap`].
528:    // at its `retentive_cap`. The unread bytes stay in the transport's buffer, so
531:    // streams have `retentive_cap == 0`, so this gate is a no-op for them.
564:/// ring has room below `retentive_cap`, woken by a `drain` on `room`. A no-op for
565:/// an ordinary stream (`retentive_cap == 0`). Holds no lock across the await.
571:            if l.retentive_cap == 0 || l.ring.len() < l.retentive_cap {
1040:                let (op_io, peer_io) = tokio::io::duplex(STREAM_READ_CHUNK);


Confirmed: loopback duplex = 64KB (`tokio::io::duplex(STREAM_READ_CHUNK)`, nethost.rs:1040; `STREAM_READ_CHUNK = 64*1024`). The in-process broker test harness (a real PTY child + real IPC + a controller that's pre-attached at spawn) is the most direct, deterministic way to prove park (a) — if the controller conn stops reading, `append→write_frame` blocks the drain thread holding the OutputLog mutex.

Build the proof. Step 1: env-gated, line-level instrumentation at all three park points (labels A/B/C).


1	//! The broker kernel (B1 + B2) — the stable half of the broker/brain split.
2	//!
3	//! Per ADR-0004 §B the broker holds **only** the resources a live consumer
4	//! would lose continuity on if the logic restarted: the PTY master+writer and
5	//! the spawned child (both via [`spt_term::PtySession`]), and the accepted
6	//! client / listening sockets. It runs **no logic** — no routing, registry,
7	//! manifest parse, or lifecycle. It is a relay: spawn a child under a PTY,
8	//! forward its output to the brain, inject the brain's input, report exit.
9	//!
10	//! ## Gapless handoff (B2 — the load-bearing invariant)
11	//!
12	//! A brain may be killed and restarted at any moment (a routine self-update, a
13	//! crash). The broker — and every hosted child — must survive that untouched,
14	//! and the new brain must re-attach to the output stream **gaplessly and
15	//! exactly-once** (Spike #1 made real). Two mechanisms:
16	//!
17	//! - **Sequenced, buffered output.** Each session's output is appended to an
18	//!   [`OutputLog`]: every chunk gets a monotonic per-session `seq` and is held
19	//!   in a bounded ring. The current subscriber (a brain connection) receives
20	//!   chunks live; all subscriber writes happen *under the log lock*, so frames
21	//!   reach the brain in strict `seq` order with no interleave.
22	//! - **Resume from a cursor.** A reconnecting brain sends `subscribe` with its
23	//!   last-seen `seq + 1`; the broker replays the ring from there, then streams
24	//!   live. The brain dedups by `seq`. Output produced *while no brain was
25	//!   attached* sits in the ring and is replayed on the next subscribe — so a
26	//!   brain restart loses nothing.
27	//!
28	//! Sessions are therefore **broker-owned and outlive any one connection**: a
29	//! brain disconnect only detaches the subscriber; the child, its PTY, and the
30	//! output log persist. A session leaves the table only when its child exits
31	//! (reaped by the exit waiter) or is explicitly killed.
32	// [impl->REQ-DAEMON-2]
33
34	use std::collections::{HashMap, VecDeque};
35	use std::io;
36	use std::sync::atomic::{AtomicU64, Ordering};
37	use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
38	use std::sync::{Arc, Mutex};
39	use std::thread::{self, JoinHandle};
40
41	use interprocess::local_socket::{prelude::*, SendHalf, Stream};
42	use spt_term::{Drain, PtySession, SessionSurface, SurfaceSize};
43
44	use spt_net::net::attach::AttachIntent;
45	use spt_net::net::endpoint::EndpointAddr;
46	use spt_store::perch::{resolve_perch_path, ParentHint};
47
48	use crate::codec::{read_frame, write_frame};
49	use crate::effect::{EffectJournal, EffectKind, Outcome};
50	use crate::frame::{Envelope, Role};
51	use crate::msg::{
52	    applied_envelope, decode_bytes, displaced_envelope, endpoint_injected_envelope,
53	    net_dialed_envelope, output_envelope,
54	    size_envelope, subscribed_envelope, BrainRestarted, EndpointInputReq,
55	    ErrorEvent, ExitEvent, InputReq, KillReq, NetDialReq, NetPresenceSubscribeReq, NetSent,
56	    NetStatusReply, NetStreamOpenReq, NetStreamOpened, NetStreamSendReq, NetStreamSubscribeReq,
57	    NetStreamsReply, PairJoinReply, PairJoinReq, ResizeReq, SessionInfo, SessionsReply, SpawnReq,
58	    Spawned, SubscribeOutcome, SubscribeReq, UnsubscribeReq, KIND_BRAIN_RESTART, KIND_BRAIN_RESTARTED, KIND_ENDPOINT_INPUT, KIND_ERROR, KIND_EXIT,
59	    KIND_INPUT, KIND_KILL, KIND_NET_DIAL, KIND_NET_DIAL_LOOPBACK, KIND_NET_PRESENCE_SUBSCRIBE,
60	    KIND_NET_SENT,
61	    KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_NET_STREAMS, KIND_NET_STREAMS_REPLY,
62	    KIND_NET_STREAM_OPEN, KIND_NET_STREAM_OPENED, KIND_NET_STREAM_SEND, KIND_NET_STREAM_SUBSCRIBE,
63	    KIND_PAIR_JOIN, KIND_PAIR_JOINED, KIND_RESIZE, KIND_SESSIONS, KIND_SESSIONS_REPLY, KIND_SPAWN,
64	    KIND_SPAWNED, KIND_SUBSCRIBE, KIND_UNSUBSCRIBE,
65	};
66	use crate::nethost::{NetHost, NET_EFFECT_SESSION};
67	use crate::transport::{recv_hello, DaemonTransport, LocalSocketTransport};
68
69	/// The shared, serialized send side of one brain connection. Output frames,
70	/// replay frames, command acks, and exit/error events all write through this.
71	/// `pub(crate)`: the net host's stream logs (D4b) live-send through the same
72	/// handle under the same lock discipline.
73	pub(crate) type SharedSend = Arc<Mutex<SendHalf>>;
74
75	/// Default bounded output-log depth (chunks). Large enough that a brain restart
76	/// window never evicts unreplayed output in practice; the bound is what keeps a
77	/// disconnected session from buffering without limit.
78	const DEFAULT_LOG_CHUNKS: usize = 4096;
79
80	/// The viewer-channel depth (frames). A bounded SPSC queue per viewer; when it

//! The broker kernel (B1 + B2) — the stable half of the broker/brain split.
//!
//! Per ADR-0004 §B the broker holds **only** the resources a live consumer
//! would lose continuity on if the logic restarted: the PTY master+writer and
//! the spawned child (both via [`spt_term::PtySession`]), and the accepted
//! client / listening sockets. It runs **no logic** — no routing, registry,
//! manifest parse, or lifecycle. It is a relay: spawn a child under a PTY,
//! forward its output to the brain, inject the brain's input, report exit.
//!
//! ## Gapless handoff (B2 — the load-bearing invariant)
//!
//! A brain may be killed and restarted at any moment (a routine self-update, a
//! crash). The broker — and every hosted child — must survive that untouched,
//! and the new brain must re-attach to the output stream **gaplessly and
//! exactly-once** (Spike #1 made real). Two mechanisms:
//!
//! - **Sequenced, buffered output.** Each session's output is appended to an
//!   [`OutputLog`]: every chunk gets a monotonic per-session `seq` and is held
//!   in a bounded ring. The current subscriber (a brain connection) receives
//!   chunks live; all subscriber writes happen *under the log lock*, so frames
//!   reach the brain in strict `seq` order with no interleave.
//! - **Resume from a cursor.** A reconnecting brain sends `subscribe` with its
//!   last-seen `seq + 1`; the broker replays the ring from there, then streams
//!   live. The brain dedups by `seq`. Output produced *while no brain was
//!   attached* sits in the ring and is replayed on the next subscribe — so a
//!   brain restart loses nothing.
//!
//! Sessions are therefore **broker-owned and outlive any one connection**: a
//! brain disconnect only detaches the subscriber; the child, its PTY, and the
//! output log persist. A session leaves the table only when its child exits
//! (reaped by the exit waiter) or is explicitly killed.
// [impl->REQ-DAEMON-2]

use std::collections::{HashMap, VecDeque};
use std::io;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};

use interprocess::local_socket::{prelude::*, SendHalf, Stream};
use spt_term::{Drain, PtySession, SessionSurface, SurfaceSize};

use spt_net::net::attach::AttachIntent;
use spt_net::net::endpoint::EndpointAddr;
use spt_store::perch::{resolve_perch_path, ParentHint};

use crate::codec::{read_frame, write_frame};
use crate::effect::{EffectJournal, EffectKind, Outcome};
use crate::frame::{Envelope, Role};
use crate::msg::{
    applied_envelope, decode_bytes, displaced_envelope, endpoint_injected_envelope,
    net_dialed_envelope, output_envelope,
    size_envelope, subscribed_envelope, BrainRestarted, EndpointInputReq,
    ErrorEvent, ExitEvent, InputReq, KillReq, NetDialReq, NetPresenceSubscribeReq, NetSent,
    NetStatusReply, NetStreamOpenReq, NetStreamOpened, NetStreamSendReq, NetStreamSubscribeReq,
    NetStreamsReply, PairJoinReply, PairJoinReq, ResizeReq, SessionInfo, SessionsReply, SpawnReq,
    Spawned, SubscribeOutcome, SubscribeReq, UnsubscribeReq, KIND_BRAIN_RESTART, KIND_BRAIN_RESTARTED, KIND_ENDPOINT_INPUT, KIND_ERROR, KIND_EXIT,
    KIND_INPUT, KIND_KILL, KIND_NET_DIAL, KIND_NET_DIAL_LOOPBACK, KIND_NET_PRESENCE_SUBSCRIBE,
    KIND_NET_SENT,
    KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_NET_STREAMS, KIND_NET_STREAMS_REPLY,
    KIND_NET_STREAM_OPEN, KIND_NET_STREAM_OPENED, KIND_NET_STREAM_SEND, KIND_NET_STREAM_SUBSCRIBE,
    KIND_PAIR_JOIN, KIND_PAIR_JOINED, KIND_RESIZE, KIND_SESSIONS, KIND_SESSIONS_REPLY, KIND_SPAWN,
    KIND_SPAWNED, KIND_SUBSCRIBE, KIND_UNSUBSCRIBE,
};
use crate::nethost::{NetHost, NET_EFFECT_SESSION};
use crate::transport::{recv_hello, DaemonTransport, LocalSocketTransport};

/// The shared, serialized send side of one brain connection. Output frames,
/// replay frames, command acks, and exit/error events all write through this.
/// `pub(crate)`: the net host's stream logs (D4b) live-send through the same
/// handle under the same lock discipline.
pub(crate) type SharedSend = Arc<Mutex<SendHalf>>;

/// Default bounded output-log depth (chunks). Large enough that a brain restart
/// window never evicts unreplayed output in practice; the bound is what keeps a
/// disconnected session from buffering without limit.
const DEFAULT_LOG_CHUNKS: usize = 4096;

/// The viewer-channel depth (frames). A bounded SPSC queue per viewer; when it


Add labeled, env-gated instrumentation. First the broker's park (a) — `append` controller write.


Edit

/// Default bounded output-log depth (chunks). Large enough that a brain restart
/// window never evicts unreplayed output in practice; the bound is what keeps a
/// disconnected session from buffering without limit.
const DEFAULT_LOG_CHUNKS: usize = 4096;

/// Default bounded output-log depth (chunks). Large enough that a brain restart
/// window never evicts unreplayed output in practice; the bound is what keeps a
/// disconnected session from buffering without limit.
const DEFAULT_LOG_CHUNKS: usize = 4096;

/// W1 (REQ-HAZARD-INJECT-CONTROL-COEXIST) repro instrumentation. When
/// `SPT_BROKER_DEBUG_PARK` is set, the drain-thread's inline controller write in
/// [`OutputLog::append`] brackets itself with `APPEND_CTRL_ENTER`/`_EXIT` lines
/// on stderr — so a real-fixture run can PROVE the park is the controller
/// `write_frame` (an ENTER with no matching EXIT = the drain thread parked there
/// holding `Mutex<OutputLog>`). Cached once; zero cost when unset.
fn park_debug() -> bool {
    use std::sync::OnceLock;
    static ON: OnceLock<bool> = OnceLock::new();
    *ON.get_or_init(|| std::env::var_os("SPT_BROKER_DEBUG_PARK").is_some())
}


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\broker.rs has been updated successfully. (file state is current in your context — no need to Read it back)

/// Default bounded output-log depth (chunks). Large enough that a brain restart
/// window never evicts unreplayed output in practice; the bound is what keeps a
/// disconnected session from buffering without limit.
const DEFAULT_LOG_CHUNKS: usize = 4096;

/// W1 (REQ-HAZARD-INJECT-CONTROL-COEXIST) repro instrumentation. When
/// `SPT_BROKER_DEBUG_PARK` is set, the drain-thread's inline controller write in
/// [`OutputLog::append`] brackets itself with `APPEND_CTRL_ENTER`/`_EXIT` lines
/// on stderr — so a real-fixture run can PROVE the park is the controller
/// `write_frame` (an ENTER with no matching EXIT = the drain thread parked there
/// holding `Mutex<OutputLog>`). Cached once; zero cost when unset.
fn park_debug() -> bool {
    use std::sync::OnceLock;
    static ON: OnceLock<bool> = OnceLock::new();
    *ON.get_or_init(|| std::env::var_os("SPT_BROKER_DEBUG_PARK").is_some())
}

+/// W1 (REQ-HAZARD-INJECT-CONTROL-COEXIST) repro instrumentation. When

+/// `SPT_BROKER_DEBUG_PARK` is set, the drain-thread's inline controller write in

+/// [`OutputLog::append`] brackets itself with `APPEND_CTRL_ENTER`/`_EXIT` lines

+/// on stderr — so a real-fixture run can PROVE the park is the controller

+/// `write_frame` (an ENTER with no matching EXIT = the drain thread parked there

+/// holding `Mutex<OutputLog>`). Cached once; zero cost when unset.

+    static ON: OnceLock<bool> = OnceLock::new();

+    *ON.get_or_init(|| std::env::var_os("SPT_BROKER_DEBUG_PARK").is_some())

/// The viewer-channel depth (frames). A bounded SPSC queue per viewer; when it

/// OVERFLOWS the viewer is evicted — the drain `try_send`s and never blocks

/// (REQ-HAZARD-VIEWER-ISOLATION). Generous enough to absorb a normal render


        let frame = output_envelope(self.session_id, seq, bytes);
        // Controller: authoritative, blocking, advances the resume cursor.
        if let Some(sub) = &self.controller {
            let write_ok = match sub.lock() {
                Ok(mut s) => write_frame(&mut *s, &frame).is_ok(),
                Err(_) => false,
            };
            self.delivered_through = advanced_cursor(self.delivered_through, seq, write_ok);
        }

        let frame = output_envelope(self.session_id, seq, bytes);
        // Controller: authoritative, blocking, advances the resume cursor.
        if let Some(sub) = &self.controller {
            // W1 park (a): this inline write_frame is held ON the drain thread
            // while `Mutex<OutputLog>` is locked — a backed-up controller socket
            // parks the drain here. The ENTER/EXIT bracket proves it.
            if park_debug() {
                eprintln!("APPEND_CTRL_ENTER sid={} seq={}", self.session_id, seq);
            }
            let write_ok = match sub.lock() {
                Ok(mut s) => write_frame(&mut *s, &frame).is_ok(),
                Err(_) => false,
            };
            if park_debug() {
                eprintln!(
                    "APPEND_CTRL_EXIT sid={} seq={} ok={}",
                    self.session_id, seq, write_ok
                );
            }
            self.delivered_through = advanced_cursor(self.delivered_through, seq, write_ok);
        }


        let frame = output_envelope(self.session_id, seq, bytes);
        // Controller: authoritative, blocking, advances the resume cursor.
        if let Some(sub) = &self.controller {
            let write_ok = match sub.lock() {
                Ok(mut s) => write_frame(&mut *s, &frame).is_ok(),
                Err(_) => false,
            };
            self.delivered_through = advanced_cursor(self.delivered_through, seq, write_ok);
        }

        let frame = output_envelope(self.session_id, seq, bytes);
        // Controller: authoritative, blocking, advances the resume cursor.
        if let Some(sub) = &self.controller {
            // W1 park (a): this inline write_frame is held ON the drain thread
            // while `Mutex<OutputLog>` is locked — a backed-up controller socket
            // parks the drain here. The ENTER/EXIT bracket proves it.
            if park_debug() {
                eprintln!("APPEND_CTRL_ENTER sid={} seq={}", self.session_id, seq);
            }
            let write_ok = match sub.lock() {
                Ok(mut s) => write_frame(&mut *s, &frame).is_ok(),
                Err(_) => false,
            };
            if park_debug() {
                eprintln!(
                    "APPEND_CTRL_EXIT sid={} seq={} ok={}",
                    self.session_id, seq, write_ok
                );
            }
            self.delivered_through = advanced_cursor(self.delivered_through, seq, write_ok);
        }

+            // W1 park (a): this inline write_frame is held ON the drain thread

+            // while `Mutex<OutputLog>` is locked — a backed-up controller socket

+            // parks the drain here. The ENTER/EXIT bracket proves it.

+                eprintln!("APPEND_CTRL_ENTER sid={} seq={}", self.session_id, seq);

+                    "APPEND_CTRL_EXIT sid={} seq={} ok={}",

+                    self.session_id, seq, write_ok

// Viewers: non-blocking fan-out; a full / disconnected queue = eviction.


Now spt-term parks (b) input write and (c) DSR-answer mutex. Add a shared helper in pty.rs, instrument both.


/// Shared, serialized handle to the PTY master writer. Cloned to the A2 drain
/// thread so the DSR auto-answer and caller input never interleave mid-write.
pub(crate) type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;

/// Shared, serialized handle to the PTY master writer. Cloned to the A2 drain
/// thread so the DSR auto-answer and caller input never interleave mid-write.
pub(crate) type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;

/// W1 (REQ-HAZARD-INJECT-CONTROL-COEXIST) repro instrumentation. When
/// `SPT_TERM_DEBUG_PARK` is set, the two PTY-write park candidates bracket
/// themselves on stderr: park (b) — [`PtySession::write_input`]'s blocking
/// `write_all` on a full ConPTY input buffer (`PTY_WRITE_ENTER`/`_EXIT`) — and
/// park (c) — the drain's DSR auto-answer holding the shared writer mutex across
/// a blocking write (`DSR_LOCK_WAIT`/`DSR_WRITE_DONE`, in `reader.rs`). An ENTER
/// with no matching EXIT pinpoints the parked call. Cached once; zero cost unset.
pub(crate) fn park_debug() -> bool {
    use std::sync::OnceLock;
    static ON: OnceLock<bool> = OnceLock::new();
    *ON.get_or_init(|| std::env::var_os("SPT_TERM_DEBUG_PARK").is_some())
}


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-term\src\pty.rs has been updated successfully. (file state is current in your context — no need to Read it back)

/// Shared, serialized handle to the PTY master writer. Cloned to the A2 drain
/// thread so the DSR auto-answer and caller input never interleave mid-write.
pub(crate) type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;

/// W1 (REQ-HAZARD-INJECT-CONTROL-COEXIST) repro instrumentation. When
/// `SPT_TERM_DEBUG_PARK` is set, the two PTY-write park candidates bracket
/// themselves on stderr: park (b) — [`PtySession::write_input`]'s blocking
/// `write_all` on a full ConPTY input buffer (`PTY_WRITE_ENTER`/`_EXIT`) — and
/// park (c) — the drain's DSR auto-answer holding the shared writer mutex across
/// a blocking write (`DSR_LOCK_WAIT`/`DSR_WRITE_DONE`, in `reader.rs`). An ENTER
/// with no matching EXIT pinpoints the parked call. Cached once; zero cost unset.
pub(crate) fn park_debug() -> bool {
    use std::sync::OnceLock;
    static ON: OnceLock<bool> = OnceLock::new();
    *ON.get_or_init(|| std::env::var_os("SPT_TERM_DEBUG_PARK").is_some())
}

+/// W1 (REQ-HAZARD-INJECT-CONTROL-COEXIST) repro instrumentation. When

+/// `SPT_TERM_DEBUG_PARK` is set, the two PTY-write park candidates bracket

+/// themselves on stderr: park (b) — [`PtySession::write_input`]'s blocking

+/// `write_all` on a full ConPTY input buffer (`PTY_WRITE_ENTER`/`_EXIT`) — and

+/// park (c) — the drain's DSR auto-answer holding the shared writer mutex across

+/// a blocking write (`DSR_LOCK_WAIT`/`DSR_WRITE_DONE`, in `reader.rs`). An ENTER

+/// with no matching EXIT pinpoints the parked call. Cached once; zero cost unset.

+    static ON: OnceLock<bool> = OnceLock::new();

+    *ON.get_or_init(|| std::env::var_os("SPT_TERM_DEBUG_PARK").is_some())

/// A child process running under a native PTY, exposed as a [`SessionSurface`].

/// Build one with [`PtySession::spawn`]; drive input via [`SessionSurface::write_input`]


    fn write_input(&self, bytes: &[u8]) -> Result<(), SurfaceError> {
        let mut w = self.writer.lock().unwrap();
        w.write_all(bytes).map_err(SurfaceError::Io)?;
        w.flush().map_err(SurfaceError::Io)
    }

    fn write_input(&self, bytes: &[u8]) -> Result<(), SurfaceError> {
        // W1 park (b)/(c): the lock is the shared writer the DSR auto-answer also
        // takes (contention = park c); the `write_all` blocks on a full ConPTY
        // input buffer while the lock is held (park b). The bracket pinpoints it.
        if park_debug() {
            eprintln!("PTY_WRITE_ENTER n={}", bytes.len());
        }
        let mut w = self.writer.lock().unwrap();
        if park_debug() {
            eprintln!("PTY_WRITE_LOCKED n={}", bytes.len());
        }
        w.write_all(bytes).map_err(SurfaceError::Io)?;
        let r = w.flush().map_err(SurfaceError::Io);
        if park_debug() {
            eprintln!("PTY_WRITE_EXIT n={} ok={}", bytes.len(), r.is_ok());
        }
        r
    }


    fn write_input(&self, bytes: &[u8]) -> Result<(), SurfaceError> {
        let mut w = self.writer.lock().unwrap();
        w.write_all(bytes).map_err(SurfaceError::Io)?;
        w.flush().map_err(SurfaceError::Io)
    }

    fn write_input(&self, bytes: &[u8]) -> Result<(), SurfaceError> {
        // W1 park (b)/(c): the lock is the shared writer the DSR auto-answer also
        // takes (contention = park c); the `write_all` blocks on a full ConPTY
        // input buffer while the lock is held (park b). The bracket pinpoints it.
        if park_debug() {
            eprintln!("PTY_WRITE_ENTER n={}", bytes.len());
        }
        let mut w = self.writer.lock().unwrap();
        if park_debug() {
            eprintln!("PTY_WRITE_LOCKED n={}", bytes.len());
        }
        w.write_all(bytes).map_err(SurfaceError::Io)?;
        let r = w.flush().map_err(SurfaceError::Io);
        if park_debug() {
            eprintln!("PTY_WRITE_EXIT n={} ok={}", bytes.len(), r.is_ok());
        }
        r
    }

+        // W1 park (b)/(c): the lock is the shared writer the DSR auto-answer also

+        // takes (contention = park c); the `write_all` blocks on a full ConPTY

+        // input buffer while the lock is held (park b). The bracket pinpoints it.

+            eprintln!("PTY_WRITE_ENTER n={}", bytes.len());

+            eprintln!("PTY_WRITE_LOCKED n={}", bytes.len());

-        w.flush().map_err(SurfaceError::Io)

+        let r = w.flush().map_err(SurfaceError::Io);

+            eprintln!("PTY_WRITE_EXIT n={} ok={}", bytes.len(), r.is_ok());

fn resize(&self, size: SurfaceSize) -> Result<(), SurfaceError> {


                        if hits > 0 {
                            // Answer through the shared, serialized writer so the
                            // CPR never interleaves with caller-injected input.
                            if let Ok(mut w) = writer.lock() {
                                for _ in 0..hits {
                                    let _ = w.write_all(DSR_REPLY);
                                }
                                let _ = w.flush();
                            }
                            t_dsr.fetch_add(hits, Ordering::Relaxed);
                        }

                        if hits > 0 {
                            // Answer through the shared, serialized writer so the
                            // CPR never interleaves with caller-injected input.
                            // W1 park (c): this holds the shared writer mutex
                            // ACROSS a blocking write — if the caller (dispatch)
                            // is mid-`write_input` blocked on a full input buffer
                            // holding this same lock, the two contend / can wedge.
                            if crate::pty::park_debug() {
                                eprintln!("DSR_LOCK_WAIT hits={hits}");
                            }
                            if let Ok(mut w) = writer.lock() {
                                if crate::pty::park_debug() {
                                    eprintln!("DSR_LOCK_HELD hits={hits}");
                                }
                                for _ in 0..hits {
                                    let _ = w.write_all(DSR_REPLY);
                                }
                                let _ = w.flush();
                            }
                            if crate::pty::park_debug() {
                                eprintln!("DSR_WRITE_DONE hits={hits}");
                            }
                            t_dsr.fetch_add(hits, Ordering::Relaxed);
                        }


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-term\src\reader.rs has been updated successfully. (file state is current in your context — no need to Read it back)

                        if hits > 0 {
                            // Answer through the shared, serialized writer so the
                            // CPR never interleaves with caller-injected input.
                            if let Ok(mut w) = writer.lock() {
                                for _ in 0..hits {
                                    let _ = w.write_all(DSR_REPLY);
                                }
                                let _ = w.flush();
                            }
                            t_dsr.fetch_add(hits, Ordering::Relaxed);
                        }

                        if hits > 0 {
                            // Answer through the shared, serialized writer so the
                            // CPR never interleaves with caller-injected input.
                            // W1 park (c): this holds the shared writer mutex
                            // ACROSS a blocking write — if the caller (dispatch)
                            // is mid-`write_input` blocked on a full input buffer
                            // holding this same lock, the two contend / can wedge.
                            if crate::pty::park_debug() {
                                eprintln!("DSR_LOCK_WAIT hits={hits}");
                            }
                            if let Ok(mut w) = writer.lock() {
                                if crate::pty::park_debug() {
                                    eprintln!("DSR_LOCK_HELD hits={hits}");
                                }
                                for _ in 0..hits {
                                    let _ = w.write_all(DSR_REPLY);
                                }
                                let _ = w.flush();
                            }
                            if crate::pty::park_debug() {
                                eprintln!("DSR_WRITE_DONE hits={hits}");
                            }
                            t_dsr.fetch_add(hits, Ordering::Relaxed);
                        }

+                            // W1 park (c): this holds the shared writer mutex

+                            // ACROSS a blocking write — if the caller (dispatch)

+                            // is mid-`write_input` blocked on a full input buffer

+                            // holding this same lock, the two contend / can wedge.

+                            if crate::pty::park_debug() {

+                                eprintln!("DSR_LOCK_WAIT hits={hits}");

+                                if crate::pty::park_debug() {

+                                    eprintln!("DSR_LOCK_HELD hits={hits}");

+                                eprintln!("DSR_WRITE_DONE hits={hits}");


Now the Layer-A repro test: in-process broker + real PTY flooding child + a controller conn that stops reading → proves park (a) deterministically (drain parks in `append` holding the OutputLog mutex; `session_output_seq` hangs while `session_count` stays live).