1	//! The broker-owned network host (D4a) — ADR-0004 §B made real for the wire.
2	//!
3	//! [`NetHost`] is the broker's ownership of the node's live network presence:
4	//! the iroh endpoint ([`spt_net::net::endpoint::NetEndpoint`]) and every
5	//! accepted/dialed QUIC connection. A live cross-node connection is exactly the
6	//! kind of resource ADR-0004 §B assigns to the broker — a consumer would lose
7	//! continuity on brain restart — so the endpoint is constructed *here*, in the
8	//! stable kernel, never in the brain. The brain drives net operations over the
9	//! versioned IPC ([`crate::msg::KIND_NET_STATUS`] / [`crate::msg::KIND_NET_DIAL`])
10	//! and may detach/reattach freely; connections persist in the broker's table.
11	//!
12	//! ## Runtime placement (M4-D4-PLAN locked decision)
13	//!
14	//! The broker is otherwise sync/thread-based; iroh requires tokio. So the host
15	//! owns a **dedicated multi-thread tokio runtime** on named worker threads
16	//! (`spt-broker-net`): QUIC objects live in structures only the runtime's tasks
17	//! and the host's own `block_on` bridges touch, and the runtime drops with the
18	//! host (broker teardown stops all net tasks). Spike #3's broker binary proved
19	//! exactly this shape — a broker-owned endpoint surviving brain kill/restart.
20	//!
21	//! ## Ownership closes two §B rows by construction
22	//!
23	//! `MdnsAddressLookup` and the relay session are both constructed *inside* the
24	//! iroh endpoint, so broker ownership of the endpoint closes the ADR-0004 §B
25	//! "mDNS socket / relay session" row with no extra machinery: there is no
26	//! separate socket to own.
27	// [impl->REQ-DAEMON-2]
28
29	use std::collections::{HashMap, VecDeque};
30	use std::io;
31	use std::sync::atomic::{AtomicU64, Ordering};
32	use std::sync::{Arc, Mutex};
33
34	use spt_net::net::endpoint::{
35	    BindScope, Connection, EndpointAddr, LocalDiscovery, NetEndpoint, RecvStream, RelayPolicy,
36	    SendStream, SPT_NET_ALPN,
37	};
38	use spt_proto::identity::{Identity, PublicKey};
39
40	use crate::broker::SharedSend;
41	use crate::codec::write_frame;
42	use crate::msg::{
43	    net_presence_event_envelope, net_stream_data_envelope, net_stream_eof_envelope,
44	    NetPresenceEvent, NetStreamInfo, PRESENCE_CONNECTED, PRESENCE_DISCONNECTED,
45	};
46
47	/// The reserved [`crate::effect::EffectKey`] session namespace for net-scoped
48	/// effects (a dial has no PTY session). Broker session ids are minted from 1
49	/// (`Broker::next_id` starts at 1), so `0` can never collide with a real session.
50	pub const NET_EFFECT_SESSION: u64 = 0;
51
52	/// Everything needed to stand the broker's endpoint up: the node's identity
53	/// (the endpoint *is* the node's name — EndpointId == pubkey), the relay stance
54	/// (from `daemon.json` in production; [`RelayPolicy::Disabled`] in hermetic
55	/// tests), and whether mDNS LAN discovery rides along.
56	pub struct NetConfig {
57	    /// The node identity the endpoint binds as (seed → same ed25519 keypair).
58	    pub identity: Identity,
59	    /// Relay stance (ADR-0002): n0 default / self-host / disabled.
60	    pub relay: RelayPolicy,
61	    /// mDNS LAN discovery on/off (off in hermetic tests).
62	    pub discovery: LocalDiscovery,
63	    /// Socket bind scope: [`BindScope::All`] in production;
64	    /// [`BindScope::Loopback`] in hermetic tests (no real interface touched,
65	    /// no per-test-binary Windows firewall prompt).
66	    pub scope: BindScope,
67	}
68
69	/// Default bounded stream-read ring depth (chunks) — same sizing rationale as
70	/// the PTY `OutputLog`: large enough that a brain-restart window never evicts
71	/// unreplayed chunks in practice; bounded so an unsubscribed stream can't
72	/// buffer without limit.
73	const DEFAULT_STREAM_RING_CHUNKS: usize = 4096;
74
75	/// Max bytes pulled per stream read (one ring chunk).
76	const STREAM_READ_CHUNK: usize = 64 * 1024;
77
78	/// One live QUIC connection the broker holds.
79	struct ConnEntry {
80	    /// The connection itself — kept alive by ownership here; dropping it closes
81	    /// the QUIC connection. Cloned (cheap handle) for stream opens.
82	    conn: Connection,
83	    /// The remote node's stable name (hex pubkey), proven by the QUIC handshake.
84	    remote_id_hex: String,
85	}
86
87	/// A bounded, sequenced read log for one QUIC stream — the PTY `OutputLog`
88	/// discipline applied to the network (D4b): the stream's single read pump is
89	/// the only producer; a brain connection attaches as the subscriber; every
90	/// subscriber send happens under the enclosing `Mutex`, so live chunks and a
91	/// replay can never interleave out of `seq` order. Chunks produced while no
92	/// brain is attached sit in the ring and replay on the next subscribe — a
93	/// brain restart loses nothing (Spike #3 invariant B/D).
94	// [impl->REQ-DAEMON-2]
95	struct StreamLog {
96	    stream_id: u64,
97	    next_seq: u64,
98	    ring: VecDeque<(u64, Vec<u8>)>,
99	    cap_chunks: usize,
100	    subscriber: Option<SharedSend>,
101	    /// Read side finished cleanly (peer called finish / conn ended).
102	    finished: bool,
103	}
104
105	impl StreamLog {
106	    fn new(stream_id: u64, cap_chunks: usize) -> Self {
107	        StreamLog {
108	            stream_id,
109	            next_seq: 0,
110	            ring: VecDeque::new(),
111	            cap_chunks,
112	            subscriber: None,
113	            finished: false,
114	        }
115	    }
116
117	    /// Append a freshly-read chunk: assign the next `seq`, retain it in the
118	    /// bounded ring, live-send to the current subscriber. Read-pump only.
119	    fn append(&mut self, bytes: &[u8]) {
120	        let seq = self.next_seq;
121	        self.next_seq += 1;
122	        self.ring.push_back((seq, bytes.to_vec()));
123	        while self.ring.len() > self.cap_chunks {
124	            self.ring.pop_front();
125	        }
126	        if let Some(sub) = &self.subscriber {
127	            let frame = net_stream_data_envelope(self.stream_id, seq, bytes);
128	            if let Ok(mut s) = sub.lock() {
129	                let _ = write_frame(&mut *s, &frame);
130	            }
131	        }
132	    }
133
134	    /// Mark the read side finished and tell the subscriber (with the final
135	    /// cursor, so it can verify completeness).
136	    fn finish(&mut self) {
137	        self.finished = true;
138	        if let Some(sub) = &self.subscriber {
139	            let frame = net_stream_eof_envelope(self.stream_id, self.next_seq);
140	            if let Ok(mut s) = sub.lock() {
141	                let _ = write_frame(&mut *s, &frame);
142	            }
143	        }
144	    }
145
146	    /// Attach `sub` as the subscriber, replaying every retained chunk with
147	    /// `seq >= from_seq` (then eof, if already finished) before live streaming.
148	    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
149	        self.subscriber = Some(Arc::clone(&sub));
150	        for (seq, bytes) in self.ring.iter().filter(|(s, _)| *s >= from_seq) {
151	            let frame = net_stream_data_envelope(self.stream_id, *seq, bytes);
152	            if let Ok(mut s) = sub.lock() {
153	                let _ = write_frame(&mut *s, &frame);
154	            }
155	        }
156	        if self.finished {
157	            let frame = net_stream_eof_envelope(self.stream_id, self.next_seq);
158	            if let Ok(mut s) = sub.lock() {
159	                let _ = write_frame(&mut *s, &frame);
160	            }
161	        }
162	    }
163
164	    /// Detach `sub` iff it is the current subscriber (a late disconnect must
165	    /// not unsubscribe a newer brain).
166	    fn detach_if(&mut self, sub: &SharedSend) {
167	        if let Some(cur) = &self.subscriber {
168	            if Arc::ptr_eq(cur, sub) {
169	                self.subscriber = None;
170	            }
171	        }
172	    }
173	}
174
175	/// A bounded, sequenced log of connection liveness events — the broker-owned
176	/// PresenceChannel seam (D4c, REQ-EP-4): the StreamLog ring/cursor discipline
177	/// applied to the conn table itself. `register_conn` appends `connected`; each
178	/// connection's closed-watcher appends `disconnected` after removing the row.
179	/// A brain subscribes with a cursor and replays events produced while it was
180	/// down, so presence survives a brain restart exactly like stream reads do.
181	// [impl->REQ-EP-4]
182	struct PresenceLog {
183	    next_seq: u64,
184	    ring: VecDeque<NetPresenceEvent>,
185	    cap_events: usize,
186	    subscriber: Option<SharedSend>,
187	}
188
189	impl PresenceLog {
190	    fn new(cap_events: usize) -> Self {
191	        PresenceLog {
192	            next_seq: 0,
193	            ring: VecDeque::new(),
194	            cap_events,
195	            subscriber: None,
196	        }
197	    }
198
199	    /// Append a liveness event: assign the next `seq`, retain it in the bounded
200	    /// ring, live-send to the current subscriber. Producers: `register_conn`
201	    /// (connected) and the per-conn closed-watchers (disconnected) — every
202	    /// append serializes on the enclosing `Mutex`, so subscriber frames arrive
203	    /// in strict `seq` order.
204	    fn append(&mut self, kind: &str, conn_id: u64, remote_id_hex: &str) {
205	        let ev = NetPresenceEvent {
206	            seq: self.next_seq,
207	            kind: kind.to_string(),
208	            conn_id,
209	            remote_id_hex: remote_id_hex.to_string(),
210	        };
211	        self.next_seq += 1;
212	        self.ring.push_back(ev.clone());
213	        while self.ring.len() > self.cap_events {
214	            self.ring.pop_front();
215	        }
216	        if let Some(sub) = &self.subscriber {
217	            let frame = net_presence_event_envelope(&ev);
218	            if let Ok(mut s) = sub.lock() {
219	                let _ = write_frame(&mut *s, &frame);
220	            }
221	        }
222	    }
223
224	    /// Attach `sub` as the subscriber, replaying every retained event with
225	    /// `seq >= from_seq` before live streaming (the gapless-resume attach).
226	    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
227	        self.subscriber = Some(Arc::clone(&sub));
228	        for ev in self.ring.iter().filter(|e| e.seq >= from_seq) {
229	            let frame = net_presence_event_envelope(ev);
230	            if let Ok(mut s) = sub.lock() {
231	                let _ = write_frame(&mut *s, &frame);
232	            }
233	        }
234	    }
235
236	    /// Detach `sub` iff it is the current subscriber (a late disconnect must
237	    /// not unsubscribe a newer brain).
238	    fn detach_if(&mut self, sub: &SharedSend) {
239	        if let Some(cur) = &self.subscriber {
240	            if Arc::ptr_eq(cur, sub) {
241	                self.subscriber = None;
242	            }
243	        }
244	    }
245	}
246
247	/// One live QUIC stream the broker holds: the send half (brain writes ride it
248	/// over IPC) + the sequenced read log.
249	struct StreamEntry {
250	    conn_id: u64,
251	    remote_id_hex: String,
252	    /// Whether THIS node opened the stream (`open_stream`) vs the peer
253	    /// (accept loop). The inbound dispatcher (D9-1) claims only
254	    /// peer-initiated streams; without this fact the table is ambiguous —
255	    /// it holds locally-opened outbound streams too (gotcha: a dispatcher
256	    /// that served its own requester stream would deadlock on itself).
257	    initiated_locally: bool,
258	    /// The send half, serialized behind an async mutex (writes happen on the
259	    /// host's runtime via `block_on`).
260	    send: tokio::sync::Mutex<SendStream>,
261	    log: Arc<Mutex<StreamLog>>,
262	}
263
264	/// Tables shared between the host's sync surface and its runtime tasks
265	/// (accept loop, per-conn stream acceptors, per-stream read pumps).
266	struct NetShared {
267	    next_conn_id: AtomicU64,
268	    conns: Mutex<HashMap<u64, ConnEntry>>,
269	    next_stream_id: AtomicU64,
270	    streams: Mutex<HashMap<u64, Arc<StreamEntry>>>,
271	    ring_cap: usize,
272	    /// The conn-liveness event log (D4c) — one per host, all conns interleaved.
273	    presence: Mutex<PresenceLog>,
274	}
275
276	impl NetShared {
277	    fn new() -> Self {
278	        NetShared {
279	            next_conn_id: AtomicU64::new(1),
280	            conns: Mutex::new(HashMap::new()),
281	            next_stream_id: AtomicU64::new(1),
282	            streams: Mutex::new(HashMap::new()),
283	            ring_cap: DEFAULT_STREAM_RING_CHUNKS,
284	            presence: Mutex::new(PresenceLog::new(DEFAULT_STREAM_RING_CHUNKS)),
285	        }
286	    }
287	}
288
289	/// Register a live connection: mint its broker id and spawn its inbound-stream
290	/// acceptor. Must run inside the host's runtime context (the accept loop, or a
291	/// `block_on` future) — it spawns tasks.
292	fn register_conn(shared: &Arc<NetShared>, conn: Connection) -> (u64, String) {
293	    let remote_id_hex = PublicKey::from_bytes(conn.remote_id().as_bytes())
294	        .expect("an iroh EndpointId is always a valid ed25519 public key")
295	        .to_hex();
296	    let id = shared.next_conn_id.fetch_add(1, Ordering::Relaxed);
297	    shared.conns.lock().unwrap().insert(
298	        id,
299	        ConnEntry {
300	            conn: conn.clone(),
301	            remote_id_hex: remote_id_hex.clone(),
302	        },
303	    );
304
305	    // Presence: announce the row BEFORE spawning the closed-watcher, so the
306	    // `connected` event can never race behind its own `disconnected` (D4c).
307	    shared
308	        .presence
309	        .lock()
310	        .unwrap()
311	        .append(PRESENCE_CONNECTED, id, &remote_id_hex);
312
313	    // Closed-watcher: the row's exit path. Awaits the connection's end (clean
314	    // close, conn loss, or our own endpoint close), removes the row — conns
315	    // finally LEAVE the table — then appends the `disconnected` event (row
316	    // gone before the event is observable).
317	    // [impl->REQ-EP-4]
318	    let sh_close = Arc::clone(shared);
319	    let conn_close = conn.clone();
320	    let hex_close = remote_id_hex.clone();
321	    tokio::spawn(async move {
322	        conn_close.closed().await;
323	        sh_close.conns.lock().unwrap().remove(&id);
324	        sh_close
325	            .presence
326	            .lock()
327	            .unwrap()
328	            .append(PRESENCE_DISCONNECTED, id, &hex_close);
329	    });
330
331	    // Inbound-stream acceptor: every peer-opened bidi stream joins the table
332	    // and gets a read pump. (QUIC streams are lazy — the peer's open becomes
333	    // visible here on its first write.) Ends when the connection does.
334	    let sh = Arc::clone(shared);
335	    let hex = remote_id_hex.clone();
336	    tokio::spawn(async move {
337	        while let Ok((send, recv)) = conn.accept_bi().await {
338	            register_stream(&sh, id, &hex, send, recv, false);
339	        }
340	    });
341
342	    (id, remote_id_hex)
343	}
344
345	/// Register a live stream (opened or accepted): mint its broker id and spawn
346	/// its read pump. Must run inside the host's runtime context.
347	/// `initiated_locally` records the direction fact for the table (true only
348	/// from [`NetHost::open_stream`]).
349	fn register_stream(
350	    shared: &Arc<NetShared>,
351	    conn_id: u64,
352	    remote_id_hex: &str,
353	    send: SendStream,
354	    recv: RecvStream,
355	    initiated_locally: bool,
356	) -> u64 {
357	    let id = shared.next_stream_id.fetch_add(1, Ordering::Relaxed);
358	    let log = Arc::new(Mutex::new(StreamLog::new(id, shared.ring_cap)));
359	    shared.streams.lock().unwrap().insert(
360	        id,
361	        Arc::new(StreamEntry {
362	            conn_id,
363	            remote_id_hex: remote_id_hex.to_string(),
364	            initiated_locally,
365	            send: tokio::sync::Mutex::new(send),
366	            log: Arc::clone(&log),
367	        }),
368	    );
369
370	    // The read pump — the stream's single producer. A clean end (peer finish)
371	    // and a torn end (conn lost) both finish the log; the buffered chunks stay
372	    // replayable either way.
373	    let mut recv = recv;
374	    tokio::spawn(async move {
375	        loop {
376	            match recv.read_chunk(STREAM_READ_CHUNK).await {
377	                Ok(Some(chunk)) => log.lock().unwrap().append(&chunk.bytes),
378	                Ok(None) | Err(_) => {
379	                    log.lock().unwrap().finish();
380	                    break;
381	                }
382	            }
383	        }
384	    });
385
386	    id
387	}
388
389	/// The broker-owned net endpoint + live connection/stream tables (see module
390	/// docs).
391	pub struct NetHost {
392	    /// The dedicated tokio runtime hosting the endpoint and its accept loop.
393	    /// Dropping the host drops the runtime, stopping every net task with it.
394	    runtime: tokio::runtime::Runtime,
395	    endpoint: Arc<NetEndpoint>,
396	    shared: Arc<NetShared>,
397	    /// `op_id → conn_id` for exactly-once dial replies: a deduped replay of a
398	    /// journaled dial is answered with the connection the first apply opened.
399	    dial_ops: Mutex<HashMap<u64, u64>>,
400	    /// `op_id → stream_id` for exactly-once stream-open replies (D4b), same
401	    /// discipline as `dial_ops`. Shares the one net op-id namespace.
402	    stream_ops: Mutex<HashMap<u64, u64>>,
403	}
404
405	impl NetHost {
406	    /// Bind the broker's endpoint on a fresh dedicated runtime and start the
407	    /// accept loop. The endpoint speaks TWO ALPNs (M7 D3, REQ-SUBNET-2): the
408	    /// trusted [`SPT_NET_ALPN`] (connections join the table as before) and
409	    /// the pre-trust pairing ALPN — routed to the always-on responder
410	    /// ([`crate::pairhost::respond`]), never registered in the stream table.
411	    /// One endpoint for both is deliberate: the pairing transcript binds and
412	    /// pins the REAL node pubkey, and the relay can only route one endpoint
413	    /// per key — the ALPN is the pre-trust/trusted boundary (ADR-0005).
414	    /// The meet-rotation listener loop rides the same runtime.
415	    // [impl->REQ-DAEMON-2]
416	    // [impl->REQ-SUBNET-2]
417	    pub fn start(cfg: NetConfig) -> io::Result<NetHost> {
418	        // Hermeticity guard (REQ-PAIR-8): a loopback-scoped endpoint is the
419	        // "no network reached" promise — the ceremony clock's lazy NTP query
420	        // must honor it (tests must never probe a real NTP server). Real
421	        // scopes keep the decision-18 offset correction.
422	        spt_net::net::pairing::ntp::set_enabled(!matches!(cfg.scope, BindScope::Loopback));
423	        let runtime = tokio::runtime::Builder::new_multi_thread()
424	            .worker_threads(2)
425	            .thread_name("spt-broker-net")
426	            .enable_all()
427	            .build()?;
428	        let endpoint = runtime
429	            .block_on(NetEndpoint::bind_scoped(
430	                &cfg.identity,
431	                cfg.relay.clone(),
432	                cfg.discovery,
433	                vec![
434	                    SPT_NET_ALPN.to_vec(),
435	                    spt_net::net::pairing::wire::SPT_PAIR_ALPN.to_vec(),
436	                ],
437	                cfg.scope,
438	            ))
439	            .map_err(|e| io::Error::other(e.to_string()))?;
440	        let endpoint = Arc::new(endpoint);
441	        let shared = Arc::new(NetShared::new());
442	        let local_pub = cfg.identity.public_key();
443	        let pair_rate = Arc::new(tokio::sync::Mutex::new(
444	            spt_net::net::pairing::ratelimit::PairingRateLimiter::new(),
445	        ));
446
447	        // Accept loop: lives for the endpoint's lifetime on the host's runtime.
448	        // Each handshake completes on its own task so a slow peer can't stall
449	        // the loop; a failed handshake is simply not registered. The pairing
450	        // ALPN routes to the responder; everything else is the trusted table.
451	        let ep = Arc::clone(&endpoint);
452	        let sh = Arc::clone(&shared);
453	        let rate = Arc::clone(&pair_rate);
454	        runtime.spawn(async move {
455	            while let Some(incoming) = ep.accept().await {
456	                let sh = Arc::clone(&sh);
457	                let rate = Arc::clone(&rate);
458	                tokio::spawn(async move {
459	                    let Ok(connecting) = incoming.accept() else {
460	                        return;
461	                    };
462	                    let Ok(conn) = connecting.await else { return };
463	                    if conn.alpn() == spt_net::net::pairing::wire::SPT_PAIR_ALPN {
464	                        crate::pairhost::respond(conn, local_pub, rate).await;
465	                    } else {
466	                        register_conn(&sh, conn);
467	                    }
468	                });
469	            }
470	        });
471
472	        // The meet-rotation loop (M7 D3): how a joiner that knows only
473	        // (subnet name, code) finds this responder. Idles with no subnets.
474	        crate::pairhost::spawn_meet_rotation(
475	            runtime.handle(),
476	            Arc::clone(&endpoint),
477	            cfg.relay,
478	            cfg.discovery,
479	            cfg.scope,
480	        );
481
482	        // The limiter Arc lives on in the accept loop; the host keeps no
483	        // handle (per-ceremony state is the responder's, not the table's).
484	        drop(pair_rate);
485
486	        Ok(NetHost {
487	            runtime,
488	            endpoint,
489	            shared,
490	            dial_ops: Mutex::new(HashMap::new()),
491	            stream_ops: Mutex::new(HashMap::new()),
492	        })
493	    }
494
495	    /// Run the joiner leg of the pairing ceremony (M7 D3, REQ-SUBNET-2) on
496	    /// the host's runtime — the broker IPC `pair-join` arm. The ceremony
497	    /// must run HERE because it binds and pins this endpoint's real node
498	    /// pubkey. Returns the rendered trust outcome, or the classified failure.
499	    // [impl->REQ-SUBNET-2]
500	    pub fn pair_join(
501	        &self,
502	        subnet: &str,
503	        code: &str,
504	        responder_addr: Option<EndpointAddr>,
505	    ) -> Result<String, crate::pairhost::JoinFail> {
506	        let endpoint = Arc::clone(&self.endpoint);
507	        self.runtime.handle().block_on(async move {
508	            crate::pairhost::join_ceremony(&endpoint, subnet, code, responder_addr).await
509	        })
510	    }
511
512	    /// The node's stable name (lowercase-hex pubkey == iroh EndpointId).
513	    pub fn node_id_hex(&self) -> String {
514	        self.endpoint.node_id_hex()
515	    }
516
517	    /// The endpoint's dialable address (id + currently-known direct/relay paths).
518	    pub fn addr(&self) -> EndpointAddr {
519	        self.endpoint.addr()
520	    }
521
522	    /// [`Self::addr`] as the opaque JSON the IPC status reply carries.
523	    pub fn addr_json(&self) -> serde_json::Value {
524	        serde_json::to_value(self.endpoint.addr()).unwrap_or(serde_json::Value::Null)
525	    }
526
527	    /// Live connections (accepted + dialed) currently held.
528	    pub fn conn_count(&self) -> u64 {
529	        self.shared.conns.lock().unwrap().len() as u64
530	    }
531
532	    /// Dial a peer over `SPT_NET_ALPN`, registering the connection in the table.
533	    /// Bridges sync→async via the host's own runtime (the broker's dispatch
534	    /// threads block here; the future runs on the net worker threads).
535	    // [impl->REQ-DAEMON-2]
536	    pub fn dial(&self, addr: EndpointAddr) -> io::Result<(u64, String)> {
537	        let endpoint = Arc::clone(&self.endpoint);
538	        let shared = Arc::clone(&self.shared);
539	        self.runtime.handle().block_on(async move {
540	            let conn = endpoint
541	                .connect(addr)
542	                .await
543	                .map_err(|e| io::Error::other(e.to_string()))?;
544	            Ok(register_conn(&shared, conn))
545	        })
546	    }
547
548	    /// Record that journaled dial `op_id` opened `conn_id` (called inside the
549	    /// `apply_once` closure, so a concurrent deduped replay always finds it).
550	    pub fn record_dial_op(&self, op_id: u64, conn_id: u64) {
551	        self.dial_ops.lock().unwrap().insert(op_id, conn_id);
552	    }
553
554	    /// The connection a journaled dial `op_id` opened, if this broker process
555	    /// still holds it (a broker restart drops connections; the journal alone
556	    /// survives — see `Broker::dispatch_net_dial`).
557	    pub fn dial_op_conn(&self, op_id: u64) -> Option<u64> {
558	        self.dial_ops.lock().unwrap().get(&op_id).copied()
559	    }
560
561	    /// The remote node id (hex) of a held connection.
562	    pub fn conn_remote_hex(&self, conn_id: u64) -> Option<String> {
563	        self.shared
564	            .conns
565	            .lock()
566	            .unwrap()
567	            .get(&conn_id)
568	            .map(|e| e.remote_id_hex.clone())
569	    }
570
571	    /// The OBSERVED peer address of a held connection (remote id + the
572	    /// connection's current path addresses) as opaque JSON — the address-
573	    /// seeding write-back the `net-dialed` reply carries (M8 decision 20,
574	    /// REQ-CONV-1). `null` when the conn is gone, path-less, or fails to
575	    /// serialize: the seed is an optimization, never an error path.
576	    // [impl->REQ-CONV-1]
577	    pub fn conn_remote_addr_json(&self, conn_id: u64) -> serde_json::Value {
578	        let conn = {
579	            let conns = self.shared.conns.lock().unwrap();
580	            match conns.get(&conn_id) {
581	                Some(e) => e.conn.clone(),
582	                None => return serde_json::Value::Null,
583	            }
584	        };
585	        let addr = spt_net::net::endpoint::conn_remote_addr(&conn);
586	        if addr.is_empty() {
587	            return serde_json::Value::Null; // id-only adds nothing over discovery
588	        }
589	        serde_json::to_value(addr).unwrap_or(serde_json::Value::Null)
590	    }
591
592	    /// Open a bidi stream on a held connection, registering it (with its read
593	    /// pump) in the stream table (D4b). Note QUIC streams are lazy: the peer
594	    /// sees the stream on its first write.
595	    // [impl->REQ-DAEMON-2]
596	    pub fn open_stream(&self, conn_id: u64) -> io::Result<u64> {
597	        let (conn, remote_id_hex) = {
598	            let conns = self.shared.conns.lock().unwrap();
599	            let e = conns.get(&conn_id).ok_or_else(|| {
600	                io::Error::new(io::ErrorKind::NotFound, format!("no such conn {conn_id}"))
601	            })?;
602	            (e.conn.clone(), e.remote_id_hex.clone())
603	        };
604	        let shared = Arc::clone(&self.shared);
605	        self.runtime.handle().block_on(async move {
606	            let (send, recv) = conn
607	                .open_bi()
608	                .await
609	                .map_err(|e| io::Error::other(e.to_string()))?;
610	            Ok(register_stream(
611	                &shared,
612	                conn_id,
613	                &remote_id_hex,
614	                send,
615	                recv,
616	                true,
617	            ))
618	        })
619	    }
620
621	    /// Record that journaled open `op_id` minted `stream_id` (inside the
622	    /// `apply_once` closure — same discipline as [`Self::record_dial_op`]).
623	    pub fn record_stream_op(&self, op_id: u64, stream_id: u64) {
624	        self.stream_ops.lock().unwrap().insert(op_id, stream_id);
625	    }
626
627	    /// The stream a journaled open `op_id` minted, if still held.
628	    pub fn stream_op_id(&self, op_id: u64) -> Option<u64> {
629	        self.stream_ops.lock().unwrap().get(&op_id).copied()
630	    }
631
632	    /// A held stream's `(conn_id, remote_id_hex)`.
633	    pub fn stream_conn(&self, stream_id: u64) -> Option<(u64, String)> {
634	        self.shared
635	            .streams
636	            .lock()
637	            .unwrap()
638	            .get(&stream_id)
639	            .map(|e| (e.conn_id, e.remote_id_hex.clone()))
640	    }
641
642	    /// Write `bytes` to a held stream's send side; `finish` closes the send
643	    /// side after the write (the peer's read pump then sees a clean end).
644	    // [impl->REQ-DAEMON-2]
645	    pub fn send_stream(&self, stream_id: u64, bytes: &[u8], finish: bool) -> io::Result<()> {
646	        let entry = {
647	            let streams = self.shared.streams.lock().unwrap();
648	            Arc::clone(streams.get(&stream_id).ok_or_else(|| {
649	                io::Error::new(
650	                    io::ErrorKind::NotFound,
651	                    format!("no such stream {stream_id}"),
652	                )
653	            })?)
654	        };
655	        let bytes = bytes.to_vec();
656	        self.runtime.handle().block_on(async move {
657	            let mut send = entry.send.lock().await;
658	            if !bytes.is_empty() {
659	                send.write_all(&bytes)
660	                    .await
661	                    .map_err(|e| io::Error::other(e.to_string()))?;
662	            }
663	            if finish {
664	                send.finish().map_err(|e| io::Error::other(e.to_string()))?;
665	            }
666	            Ok(())
667	        })
668	    }
669
670	    /// Every held stream's info — the query a re-attaching brain uses to find
671	    /// streams (including peer-initiated ones that arrived while it was down).
672	    pub fn stream_infos(&self) -> Vec<NetStreamInfo> {
673	        let streams = self.shared.streams.lock().unwrap();
674	        streams
675	            .iter()
676	            .map(|(id, e)| {
677	                let log = e.log.lock().unwrap();
678	                NetStreamInfo {
679	                    stream_id: *id,
680	                    conn_id: e.conn_id,
681	                    remote_id_hex: e.remote_id_hex.clone(),
682	                    next_seq: log.next_seq,
683	                    finished: log.finished,
684	                    initiated_locally: e.initiated_locally,
685	                }
686	            })
687	            .collect()
688	    }
689
690	    /// Attach `sub` as a stream's read subscriber, replaying from `from_seq`
691	    /// (gapless resume — the PTY `subscribe` discipline on a QUIC stream).
692	    // [impl->REQ-DAEMON-2]
693	    pub(crate) fn subscribe_stream(
694	        &self,
695	        stream_id: u64,
696	        sub: SharedSend,
697	        from_seq: u64,
698	    ) -> io::Result<()> {
699	        let log = {
700	            let streams = self.shared.streams.lock().unwrap();
701	            let e = streams.get(&stream_id).ok_or_else(|| {
702	                io::Error::new(
703	                    io::ErrorKind::NotFound,
704	                    format!("no such stream {stream_id}"),
705	                )
706	            })?;
707	            Arc::clone(&e.log)
708	        };
709	        log.lock().unwrap().attach(sub, from_seq);
710	        Ok(())
711	    }
712
713	    /// Detach `sub` from a stream iff it is the current subscriber (brain
714	    /// disconnect cleanup; the stream + its ring persist).
715	    pub(crate) fn detach_stream_sub(&self, stream_id: u64, sub: &SharedSend) {
716	        let log = {
717	            let streams = self.shared.streams.lock().unwrap();
718	            streams.get(&stream_id).map(|e| Arc::clone(&e.log))
719	        };
720	        if let Some(log) = log {
721	            log.lock().unwrap().detach_if(sub);
722	        }
723	    }
724
725	    /// Attach `sub` as the presence subscriber, replaying liveness events from
726	    /// `from_seq` (D4c) — the stream `subscribe` discipline on the conn table.
727	    // [impl->REQ-EP-4]
728	    pub(crate) fn subscribe_presence(&self, sub: SharedSend, from_seq: u64) {
729	        self.shared.presence.lock().unwrap().attach(sub, from_seq);
730	    }
731
732	    /// Detach `sub` from presence iff it is the current subscriber (brain
733	    /// disconnect cleanup; the log + its ring persist).
734	    pub(crate) fn detach_presence_sub(&self, sub: &SharedSend) {
735	        self.shared.presence.lock().unwrap().detach_if(sub);
736	    }
737
738	    /// Gracefully close the endpoint: every held connection flushes its QUIC
739	    /// CLOSE so peers' closed-watchers fire promptly (a bare host drop kills
740	    /// the runtime first and leaves peers to an idle timeout).
741	    pub fn close(&self) {
742	        let endpoint = Arc::clone(&self.endpoint);
743	        self.runtime.handle().block_on(async move {
744	            endpoint.close().await;
745	        });
746	    }
747	}
748
749	#[cfg(test)]
750	mod tests {
751	    use super::*;
752
753	    fn hermetic(identity: &Identity) -> NetConfig {
754	        NetConfig {
755	            identity: Identity::from_seed(&identity.seed()),
756	            relay: RelayPolicy::Disabled,
757	            discovery: LocalDiscovery::Off,
758	            scope: BindScope::Loopback,
759	        }
760	    }
761
762	    // [unit->REQ-DAEMON-2] the endpoint is constructed broker-side (inside the
763	    // host's dedicated runtime) with relays disabled, bound to the node's own
764	    // identity, with an empty conn table.
765	    #[test]
766	    fn host_binds_endpoint_to_node_identity() {
767	        let id = Identity::generate();
768	        let host = NetHost::start(hermetic(&id)).expect("host start");
769	        assert_eq!(host.node_id_hex(), id.public_key().to_hex());
770	        assert_eq!(host.conn_count(), 0);
771	        assert!(!host.addr_json().is_null(), "dialable address serializes");
772	    }
773
774	    // [unit->REQ-DAEMON-2] a broker-owned host dials a second in-process host
775	    // over hermetic loopback QUIC; both sides register the connection — the
776	    // dialer in its table, the acceptor via its accept loop.
777	    #[test]
778	    fn two_hosts_dial_over_loopback() {
779	        let a = NetHost::start(hermetic(&Identity::generate())).expect("host a");
780	        let b_id = Identity::generate();
781	        let b = NetHost::start(hermetic(&b_id)).expect("host b");
782
783	        let (conn_id, remote) = a.dial(b.addr()).expect("dial");
784	        assert_eq!(conn_id, 1, "first broker-minted conn id");
785	        assert_eq!(
786	            remote,
787	            b_id.public_key().to_hex(),
788	            "handshake proves the peer"
789	        );
790	        assert_eq!(a.conn_count(), 1);
791
792	        // The acceptor's loop registers the inbound connection (async — poll).
793	        for _ in 0..400 {
794	            if b.conn_count() == 1 {
795	                break;
796	            }
797	            std::thread::sleep(std::time::Duration::from_millis(5));
798	        }
799	        assert_eq!(b.conn_count(), 1, "accept loop registered the inbound conn");
800	        assert_eq!(
801	            b.conn_remote_hex(1).as_deref(),
802	            Some(a.node_id_hex().as_str()),
803	            "acceptor sees the dialer's identity"
804	        );
805	    }
806	}
807

//! The broker-owned network host (D4a) — ADR-0004 §B made real for the wire.
//!
//! [`NetHost`] is the broker's ownership of the node's live network presence:
//! the iroh endpoint ([`spt_net::net::endpoint::NetEndpoint`]) and every
//! accepted/dialed QUIC connection. A live cross-node connection is exactly the
//! kind of resource ADR-0004 §B assigns to the broker — a consumer would lose
//! continuity on brain restart — so the endpoint is constructed *here*, in the
//! stable kernel, never in the brain. The brain drives net operations over the
//! versioned IPC ([`crate::msg::KIND_NET_STATUS`] / [`crate::msg::KIND_NET_DIAL`])
//! and may detach/reattach freely; connections persist in the broker's table.
//!
//! ## Runtime placement (M4-D4-PLAN locked decision)
//!
//! The broker is otherwise sync/thread-based; iroh requires tokio. So the host
//! owns a **dedicated multi-thread tokio runtime** on named worker threads
//! (`spt-broker-net`): QUIC objects live in structures only the runtime's tasks
//! and the host's own `block_on` bridges touch, and the runtime drops with the
//! host (broker teardown stops all net tasks). Spike #3's broker binary proved
//! exactly this shape — a broker-owned endpoint surviving brain kill/restart.
//!
//! ## Ownership closes two §B rows by construction
//!
//! `MdnsAddressLookup` and the relay session are both constructed *inside* the
//! iroh endpoint, so broker ownership of the endpoint closes the ADR-0004 §B
//! "mDNS socket / relay session" row with no extra machinery: there is no
//! separate socket to own.
// [impl->REQ-DAEMON-2]

use std::collections::{HashMap, VecDeque};
use std::io;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};

use spt_net::net::endpoint::{
    BindScope, Connection, EndpointAddr, LocalDiscovery, NetEndpoint, RecvStream, RelayPolicy,
    SendStream, SPT_NET_ALPN,
};
use spt_proto::identity::{Identity, PublicKey};

use crate::broker::SharedSend;
use crate::codec::write_frame;
use crate::msg::{
    net_presence_event_envelope, net_stream_data_envelope, net_stream_eof_envelope,
    NetPresenceEvent, NetStreamInfo, PRESENCE_CONNECTED, PRESENCE_DISCONNECTED,
};

/// The reserved [`crate::effect::EffectKey`] session namespace for net-scoped
/// effects (a dial has no PTY session). Broker session ids are minted from 1
/// (`Broker::next_id` starts at 1), so `0` can never collide with a real session.
pub const NET_EFFECT_SESSION: u64 = 0;

/// Everything needed to stand the broker's endpoint up: the node's identity
/// (the endpoint *is* the node's name — EndpointId == pubkey), the relay stance
/// (from `daemon.json` in production; [`RelayPolicy::Disabled`] in hermetic
/// tests), and whether mDNS LAN discovery rides along.
pub struct NetConfig {
    /// The node identity the endpoint binds as (seed → same ed25519 keypair).
    pub identity: Identity,
    /// Relay stance (ADR-0002): n0 default / self-host / disabled.
    pub relay: RelayPolicy,
    /// mDNS LAN discovery on/off (off in hermetic tests).
    pub discovery: LocalDiscovery,
    /// Socket bind scope: [`BindScope::All`] in production;
    /// [`BindScope::Loopback`] in hermetic tests (no real interface touched,
    /// no per-test-binary Windows firewall prompt).
    pub scope: BindScope,
}

/// Default bounded stream-read ring depth (chunks) — same sizing rationale as
/// the PTY `OutputLog`: large enough that a brain-restart window never evicts
/// unreplayed chunks in practice; bounded so an unsubscribed stream can't
/// buffer without limit.
const DEFAULT_STREAM_RING_CHUNKS: usize = 4096;

/// Max bytes pulled per stream read (one ring chunk).
const STREAM_READ_CHUNK: usize = 64 * 1024;

/// One live QUIC connection the broker holds.
struct ConnEntry {
    /// The connection itself — kept alive by ownership here; dropping it closes
    /// the QUIC connection. Cloned (cheap handle) for stream opens.
    conn: Connection,
    /// The remote node's stable name (hex pubkey), proven by the QUIC handshake.
    remote_id_hex: String,
}

/// A bounded, sequenced read log for one QUIC stream — the PTY `OutputLog`
/// discipline applied to the network (D4b): the stream's single read pump is
/// the only producer; a brain connection attaches as the subscriber; every
/// subscriber send happens under the enclosing `Mutex`, so live chunks and a
/// replay can never interleave out of `seq` order. Chunks produced while no
/// brain is attached sit in the ring and replay on the next subscribe — a
/// brain restart loses nothing (Spike #3 invariant B/D).
// [impl->REQ-DAEMON-2]
struct StreamLog {
    stream_id: u64,
    next_seq: u64,
    ring: VecDeque<(u64, Vec<u8>)>,
    cap_chunks: usize,
    subscriber: Option<SharedSend>,
    /// Read side finished cleanly (peer called finish / conn ended).
    finished: bool,
}

impl StreamLog {
    fn new(stream_id: u64, cap_chunks: usize) -> Self {
        StreamLog {
            stream_id,
            next_seq: 0,
            ring: VecDeque::new(),
            cap_chunks,
            subscriber: None,
            finished: false,
        }
    }

    /// Append a freshly-read chunk: assign the next `seq`, retain it in the
    /// bounded ring, live-send to the current subscriber. Read-pump only.
    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();
        }
        if let Some(sub) = &self.subscriber {
            let frame = net_stream_data_envelope(self.stream_id, seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Mark the read side finished and tell the subscriber (with the final
    /// cursor, so it can verify completeness).
    fn finish(&mut self) {
        self.finished = true;
        if let Some(sub) = &self.subscriber {
            let frame = net_stream_eof_envelope(self.stream_id, self.next_seq);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Attach `sub` as the subscriber, replaying every retained chunk with
    /// `seq >= from_seq` (then eof, if already finished) before live streaming.
    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
        self.subscriber = Some(Arc::clone(&sub));
        for (seq, bytes) in self.ring.iter().filter(|(s, _)| *s >= from_seq) {
            let frame = net_stream_data_envelope(self.stream_id, *seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
        if self.finished {
            let frame = net_stream_eof_envelope(self.stream_id, self.next_seq);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Detach `sub` iff it is the current subscriber (a late disconnect must
    /// not unsubscribe a newer brain).
    fn detach_if(&mut self, sub: &SharedSend) {
        if let Some(cur) = &self.subscriber {
            if Arc::ptr_eq(cur, sub) {
                self.subscriber = None;
            }
        }
    }
}

/// A bounded, sequenced log of connection liveness events — the broker-owned
/// PresenceChannel seam (D4c, REQ-EP-4): the StreamLog ring/cursor discipline
/// applied to the conn table itself. `register_conn` appends `connected`; each
/// connection's closed-watcher appends `disconnected` after removing the row.
/// A brain subscribes with a cursor and replays events produced while it was
/// down, so presence survives a brain restart exactly like stream reads do.
// [impl->REQ-EP-4]
struct PresenceLog {
    next_seq: u64,
    ring: VecDeque<NetPresenceEvent>,
    cap_events: usize,
    subscriber: Option<SharedSend>,
}

impl PresenceLog {
    fn new(cap_events: usize) -> Self {
        PresenceLog {
            next_seq: 0,
            ring: VecDeque::new(),
            cap_events,
            subscriber: None,
        }
    }

    /// Append a liveness event: assign the next `seq`, retain it in the bounded
    /// ring, live-send to the current subscriber. Producers: `register_conn`
    /// (connected) and the per-conn closed-watchers (disconnected) — every
    /// append serializes on the enclosing `Mutex`, so subscriber frames arrive
    /// in strict `seq` order.
    fn append(&mut self, kind: &str, conn_id: u64, remote_id_hex: &str) {
        let ev = NetPresenceEvent {
            seq: self.next_seq,
            kind: kind.to_string(),
            conn_id,
            remote_id_hex: remote_id_hex.to_string(),
        };
        self.next_seq += 1;
        self.ring.push_back(ev.clone());
        while self.ring.len() > self.cap_events {
            self.ring.pop_front();
        }
        if let Some(sub) = &self.subscriber {
            let frame = net_presence_event_envelope(&ev);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Attach `sub` as the subscriber, replaying every retained event with
    /// `seq >= from_seq` before live streaming (the gapless-resume attach).
    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
        self.subscriber = Some(Arc::clone(&sub));
        for ev in self.ring.iter().filter(|e| e.seq >= from_seq) {
            let frame = net_presence_event_envelope(ev);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Detach `sub` iff it is the current subscriber (a late disconnect must
    /// not unsubscribe a newer brain).
    fn detach_if(&mut self, sub: &SharedSend) {
        if let Some(cur) = &self.subscriber {
            if Arc::ptr_eq(cur, sub) {
                self.subscriber = None;
            }
        }
    }
}

/// One live QUIC stream the broker holds: the send half (brain writes ride it
/// over IPC) + the sequenced read log.
struct StreamEntry {
    conn_id: u64,
    remote_id_hex: String,
    /// Whether THIS node opened the stream (`open_stream`) vs the peer
    /// (accept loop). The inbound dispatcher (D9-1) claims only
    /// peer-initiated streams; without this fact the table is ambiguous —
    /// it holds locally-opened outbound streams too (gotcha: a dispatcher
    /// that served its own requester stream would deadlock on itself).
    initiated_locally: bool,
    /// The send half, serialized behind an async mutex (writes happen on the
    /// host's runtime via `block_on`).
    send: tokio::sync::Mutex<SendStream>,
    log: Arc<Mutex<StreamLog>>,
}

/// Tables shared between the host's sync surface and its runtime tasks
/// (accept loop, per-conn stream acceptors, per-stream read pumps).
struct NetShared {
    next_conn_id: AtomicU64,
    conns: Mutex<HashMap<u64, ConnEntry>>,
    next_stream_id: AtomicU64,
    streams: Mutex<HashMap<u64, Arc<StreamEntry>>>,
    ring_cap: usize,
    /// The conn-liveness event log (D4c) — one per host, all conns interleaved.
    presence: Mutex<PresenceLog>,
}

impl NetShared {
    fn new() -> Self {
        NetShared {
            next_conn_id: AtomicU64::new(1),
            conns: Mutex::new(HashMap::new()),
            next_stream_id: AtomicU64::new(1),
            streams: Mutex::new(HashMap::new()),
            ring_cap: DEFAULT_STREAM_RING_CHUNKS,
            presence: Mutex::new(PresenceLog::new(DEFAULT_STREAM_RING_CHUNKS)),
        }
    }
}

/// Register a live connection: mint its broker id and spawn its inbound-stream
/// acceptor. Must run inside the host's runtime context (the accept loop, or a
/// `block_on` future) — it spawns tasks.
fn register_conn(shared: &Arc<NetShared>, conn: Connection) -> (u64, String) {
    let remote_id_hex = PublicKey::from_bytes(conn.remote_id().as_bytes())
        .expect("an iroh EndpointId is always a valid ed25519 public key")
        .to_hex();
    let id = shared.next_conn_id.fetch_add(1, Ordering::Relaxed);
    shared.conns.lock().unwrap().insert(
        id,
        ConnEntry {
            conn: conn.clone(),
            remote_id_hex: remote_id_hex.clone(),
        },
    );

    // Presence: announce the row BEFORE spawning the closed-watcher, so the
    // `connected` event can never race behind its own `disconnected` (D4c).
    shared
        .presence
        .lock()
        .unwrap()
        .append(PRESENCE_CONNECTED, id, &remote_id_hex);

    // Closed-watcher: the row's exit path. Awaits the connection's end (clean
    // close, conn loss, or our own endpoint close), removes the row — conns
    // finally LEAVE the table — then appends the `disconnected` event (row
    // gone before the event is observable).
    // [impl->REQ-EP-4]
    let sh_close = Arc::clone(shared);
    let conn_close = conn.clone();
    let hex_close = remote_id_hex.clone();
    tokio::spawn(async move {
        conn_close.closed().await;
        sh_close.conns.lock().unwrap().remove(&id);
        sh_close
            .presence
            .lock()
            .unwrap()
            .append(PRESENCE_DISCONNECTED, id, &hex_close);
    });

    // Inbound-stream acceptor: every peer-opened bidi stream joins the table
    // and gets a read pump. (QUIC streams are lazy — the peer's open becomes
    // visible here on its first write.) Ends when the connection does.
    let sh = Arc::clone(shared);
    let hex = remote_id_hex.clone();
    tokio::spawn(async move {
        while let Ok((send, recv)) = conn.accept_bi().await {
            register_stream(&sh, id, &hex, send, recv, false);
        }
    });

    (id, remote_id_hex)
}

/// Register a live stream (opened or accepted): mint its broker id and spawn
/// its read pump. Must run inside the host's runtime context.
/// `initiated_locally` records the direction fact for the table (true only
/// from [`NetHost::open_stream`]).
fn register_stream(
    shared: &Arc<NetShared>,
    conn_id: u64,
    remote_id_hex: &str,
    send: SendStream,
    recv: RecvStream,
    initiated_locally: bool,
) -> u64 {
    let id = shared.next_stream_id.fetch_add(1, Ordering::Relaxed);
    let log = Arc::new(Mutex::new(StreamLog::new(id, shared.ring_cap)));
    shared.streams.lock().unwrap().insert(
        id,
        Arc::new(StreamEntry {
            conn_id,
            remote_id_hex: remote_id_hex.to_string(),
            initiated_locally,
            send: tokio::sync::Mutex::new(send),
            log: Arc::clone(&log),
        }),
    );

    // The read pump — the stream's single producer. A clean end (peer finish)
    // and a torn end (conn lost) both finish the log; the buffered chunks stay
    // replayable either way.
    let mut recv = recv;
    tokio::spawn(async move {
        loop {
            match recv.read_chunk(STREAM_READ_CHUNK).await {
                Ok(Some(chunk)) => log.lock().unwrap().append(&chunk.bytes),
                Ok(None) | Err(_) => {
                    log.lock().unwrap().finish();
                    break;
                }
            }
        }
    });

    id
}

/// The broker-owned net endpoint + live connection/stream tables (see module
/// docs).
pub struct NetHost {
    /// The dedicated tokio runtime hosting the endpoint and its accept loop.
    /// Dropping the host drops the runtime, stopping every net task with it.
    runtime: tokio::runtime::Runtime,
    endpoint: Arc<NetEndpoint>,
    shared: Arc<NetShared>,
    /// `op_id → conn_id` for exactly-once dial replies: a deduped replay of a
    /// journaled dial is answered with the connection the first apply opened.
    dial_ops: Mutex<HashMap<u64, u64>>,
    /// `op_id → stream_id` for exactly-once stream-open replies (D4b), same
    /// discipline as `dial_ops`. Shares the one net op-id namespace.
    stream_ops: Mutex<HashMap<u64, u64>>,
}

impl NetHost {
    /// Bind the broker's endpoint on a fresh dedicated runtime and start the
    /// accept loop. The endpoint speaks TWO ALPNs (M7 D3, REQ-SUBNET-2): the
    /// trusted [`SPT_NET_ALPN`] (connections join the table as before) and
    /// the pre-trust pairing ALPN — routed to the always-on responder
    /// ([`crate::pairhost::respond`]), never registered in the stream table.
    /// One endpoint for both is deliberate: the pairing transcript binds and
    /// pins the REAL node pubkey, and the relay can only route one endpoint
    /// per key — the ALPN is the pre-trust/trusted boundary (ADR-0005).
    /// The meet-rotation listener loop rides the same runtime.
    // [impl->REQ-DAEMON-2]
    // [impl->REQ-SUBNET-2]
    pub fn start(cfg: NetConfig) -> io::Result<NetHost> {
        // Hermeticity guard (REQ-PAIR-8): a loopback-scoped endpoint is the
        // "no network reached" promise — the ceremony clock's lazy NTP query
        // must honor it (tests must never probe a real NTP server). Real
        // scopes keep the decision-18 offset correction.
        spt_net::net::pairing::ntp::set_enabled(!matches!(cfg.scope, BindScope::Loopback));
        let runtime = tokio::runtime::Builder::new_multi_thread()
            .worker_threads(2)
            .thread_name("spt-broker-net")
            .enable_all()
            .build()?;
        let endpoint = runtime
            .block_on(NetEndpoint::bind_scoped(
                &cfg.identity,
                cfg.relay.clone(),
                cfg.discovery,
                vec![
                    SPT_NET_ALPN.to_vec(),
                    spt_net::net::pairing::wire::SPT_PAIR_ALPN.to_vec(),
                ],
                cfg.scope,
            ))
            .map_err(|e| io::Error::other(e.to_string()))?;
        let endpoint = Arc::new(endpoint);
        let shared = Arc::new(NetShared::new());
        let local_pub = cfg.identity.public_key();
        let pair_rate = Arc::new(tokio::sync::Mutex::new(
            spt_net::net::pairing::ratelimit::PairingRateLimiter::new(),
        ));

        // Accept loop: lives for the endpoint's lifetime on the host's runtime.
        // Each handshake completes on its own task so a slow peer can't stall
        // the loop; a failed handshake is simply not registered. The pairing
        // ALPN routes to the responder; everything else is the trusted table.
        let ep = Arc::clone(&endpoint);
        let sh = Arc::clone(&shared);
        let rate = Arc::clone(&pair_rate);
        runtime.spawn(async move {
            while let Some(incoming) = ep.accept().await {
                let sh = Arc::clone(&sh);
                let rate = Arc::clone(&rate);
                tokio::spawn(async move {
                    let Ok(connecting) = incoming.accept() else {
                        return;
                    };
                    let Ok(conn) = connecting.await else { return };
                    if conn.alpn() == spt_net::net::pairing::wire::SPT_PAIR_ALPN {
                        crate::pairhost::respond(conn, local_pub, rate).await;
                    } else {
                        register_conn(&sh, conn);
                    }
                });
            }
        });

        // The meet-rotation loop (M7 D3): how a joiner that knows only
        // (subnet name, code) finds this responder. Idles with no subnets.
        crate::pairhost::spawn_meet_rotation(
            runtime.handle(),
            Arc::clone(&endpoint),
            cfg.relay,
            cfg.discovery,
            cfg.scope,
        );

        // The limiter Arc lives on in the accept loop; the host keeps no
        // handle (per-ceremony state is the responder's, not the table's).
        drop(pair_rate);

        Ok(NetHost {
            runtime,
            endpoint,
            shared,
            dial_ops: Mutex::new(HashMap::new()),
            stream_ops: Mutex::new(HashMap::new()),
        })
    }

    /// Run the joiner leg of the pairing ceremony (M7 D3, REQ-SUBNET-2) on
    /// the host's runtime — the broker IPC `pair-join` arm. The ceremony
    /// must run HERE because it binds and pins this endpoint's real node
    /// pubkey. Returns the rendered trust outcome, or the classified failure.
    // [impl->REQ-SUBNET-2]
    pub fn pair_join(
        &self,
        subnet: &str,
        code: &str,
        responder_addr: Option<EndpointAddr>,
    ) -> Result<String, crate::pairhost::JoinFail> {
        let endpoint = Arc::clone(&self.endpoint);
        self.runtime.handle().block_on(async move {
            crate::pairhost::join_ceremony(&endpoint, subnet, code, responder_addr).await
        })
    }

    /// The node's stable name (lowercase-hex pubkey == iroh EndpointId).
    pub fn node_id_hex(&self) -> String {
        self.endpoint.node_id_hex()
    }

    /// The endpoint's dialable address (id + currently-known direct/relay paths).
    pub fn addr(&self) -> EndpointAddr {
        self.endpoint.addr()
    }

    /// [`Self::addr`] as the opaque JSON the IPC status reply carries.
    pub fn addr_json(&self) -> serde_json::Value {
        serde_json::to_value(self.endpoint.addr()).unwrap_or(serde_json::Value::Null)
    }

    /// Live connections (accepted + dialed) currently held.
    pub fn conn_count(&self) -> u64 {
        self.shared.conns.lock().unwrap().len() as u64
    }

    /// Dial a peer over `SPT_NET_ALPN`, registering the connection in the table.
    /// Bridges sync→async via the host's own runtime (the broker's dispatch
    /// threads block here; the future runs on the net worker threads).
    // [impl->REQ-DAEMON-2]
    pub fn dial(&self, addr: EndpointAddr) -> io::Result<(u64, String)> {
        let endpoint = Arc::clone(&self.endpoint);
        let shared = Arc::clone(&self.shared);
        self.runtime.handle().block_on(async move {
            let conn = endpoint
                .connect(addr)
                .await
                .map_err(|e| io::Error::other(e.to_string()))?;
            Ok(register_conn(&shared, conn))
        })
    }

    /// Record that journaled dial `op_id` opened `conn_id` (called inside the
    /// `apply_once` closure, so a concurrent deduped replay always finds it).
    pub fn record_dial_op(&self, op_id: u64, conn_id: u64) {
        self.dial_ops.lock().unwrap().insert(op_id, conn_id);
    }

    /// The connection a journaled dial `op_id` opened, if this broker process
    /// still holds it (a broker restart drops connections; the journal alone
    /// survives — see `Broker::dispatch_net_dial`).
    pub fn dial_op_conn(&self, op_id: u64) -> Option<u64> {
        self.dial_ops.lock().unwrap().get(&op_id).copied()
    }

    /// The remote node id (hex) of a held connection.
    pub fn conn_remote_hex(&self, conn_id: u64) -> Option<String> {
        self.shared
            .conns
            .lock()
            .unwrap()
            .get(&conn_id)
            .map(|e| e.remote_id_hex.clone())
    }

    /// The OBSERVED peer address of a held connection (remote id + the
    /// connection's current path addresses) as opaque JSON — the address-
    /// seeding write-back the `net-dialed` reply carries (M8 decision 20,
    /// REQ-CONV-1). `null` when the conn is gone, path-less, or fails to
    /// serialize: the seed is an optimization, never an error path.
    // [impl->REQ-CONV-1]
    pub fn conn_remote_addr_json(&self, conn_id: u64) -> serde_json::Value {
        let conn = {
            let conns = self.shared.conns.lock().unwrap();
            match conns.get(&conn_id) {
                Some(e) => e.conn.clone(),
                None => return serde_json::Value::Null,
            }
        };
        let addr = spt_net::net::endpoint::conn_remote_addr(&conn);
        if addr.is_empty() {
            return serde_json::Value::Null; // id-only adds nothing over discovery
        }
        serde_json::to_value(addr).unwrap_or(serde_json::Value::Null)
    }

    /// Open a bidi stream on a held connection, registering it (with its read
    /// pump) in the stream table (D4b). Note QUIC streams are lazy: the peer
    /// sees the stream on its first write.
    // [impl->REQ-DAEMON-2]
    pub fn open_stream(&self, conn_id: u64) -> io::Result<u64> {
        let (conn, remote_id_hex) = {
            let conns = self.shared.conns.lock().unwrap();
            let e = conns.get(&conn_id).ok_or_else(|| {
                io::Error::new(io::ErrorKind::NotFound, format!("no such conn {conn_id}"))
            })?;
            (e.conn.clone(), e.remote_id_hex.clone())
        };
        let shared = Arc::clone(&self.shared);
        self.runtime.handle().block_on(async move {
            let (send, recv) = conn
                .open_bi()
                .await
                .map_err(|e| io::Error::other(e.to_string()))?;
            Ok(register_stream(
                &shared,
                conn_id,
                &remote_id_hex,
                send,
                recv,
                true,
            ))
        })
    }

    /// Record that journaled open `op_id` minted `stream_id` (inside the
    /// `apply_once` closure — same discipline as [`Self::record_dial_op`]).
    pub fn record_stream_op(&self, op_id: u64, stream_id: u64) {
        self.stream_ops.lock().unwrap().insert(op_id, stream_id);
    }

    /// The stream a journaled open `op_id` minted, if still held.
    pub fn stream_op_id(&self, op_id: u64) -> Option<u64> {
        self.stream_ops.lock().unwrap().get(&op_id).copied()
    }

    /// A held stream's `(conn_id, remote_id_hex)`.
    pub fn stream_conn(&self, stream_id: u64) -> Option<(u64, String)> {
        self.shared
            .streams
            .lock()
            .unwrap()
            .get(&stream_id)
            .map(|e| (e.conn_id, e.remote_id_hex.clone()))
    }

    /// Write `bytes` to a held stream's send side; `finish` closes the send
    /// side after the write (the peer's read pump then sees a clean end).
    // [impl->REQ-DAEMON-2]
    pub fn send_stream(&self, stream_id: u64, bytes: &[u8], finish: bool) -> io::Result<()> {
        let entry = {
            let streams = self.shared.streams.lock().unwrap();
            Arc::clone(streams.get(&stream_id).ok_or_else(|| {
                io::Error::new(
                    io::ErrorKind::NotFound,
                    format!("no such stream {stream_id}"),
                )
            })?)
        };
        let bytes = bytes.to_vec();
        self.runtime.handle().block_on(async move {
            let mut send = entry.send.lock().await;
            if !bytes.is_empty() {
                send.write_all(&bytes)
                    .await
                    .map_err(|e| io::Error::other(e.to_string()))?;
            }
            if finish {
                send.finish().map_err(|e| io::Error::other(e.to_string()))?;
            }
            Ok(())
        })
    }

    /// Every held stream's info — the query a re-attaching brain uses to find
    /// streams (including peer-initiated ones that arrived while it was down).
    pub fn stream_infos(&self) -> Vec<NetStreamInfo> {
        let streams = self.shared.streams.lock().unwrap();
        streams
            .iter()
            .map(|(id, e)| {
                let log = e.log.lock().unwrap();
                NetStreamInfo {
                    stream_id: *id,
                    conn_id: e.conn_id,
                    remote_id_hex: e.remote_id_hex.clone(),
                    next_seq: log.next_seq,
                    finished: log.finished,
                    initiated_locally: e.initiated_locally,
                }
            })
            .collect()
    }

    /// Attach `sub` as a stream's read subscriber, replaying from `from_seq`
    /// (gapless resume — the PTY `subscribe` discipline on a QUIC stream).
    // [impl->REQ-DAEMON-2]
    pub(crate) fn subscribe_stream(
        &self,
        stream_id: u64,
        sub: SharedSend,
        from_seq: u64,
    ) -> io::Result<()> {
        let log = {
            let streams = self.shared.streams.lock().unwrap();
            let e = streams.get(&stream_id).ok_or_else(|| {
                io::Error::new(
                    io::ErrorKind::NotFound,
                    format!("no such stream {stream_id}"),
                )
            })?;
            Arc::clone(&e.log)
        };
        log.lock().unwrap().attach(sub, from_seq);
        Ok(())
    }

    /// Detach `sub` from a stream iff it is the current subscriber (brain
    /// disconnect cleanup; the stream + its ring persist).
    pub(crate) fn detach_stream_sub(&self, stream_id: u64, sub: &SharedSend) {
        let log = {
            let streams = self.shared.streams.lock().unwrap();
            streams.get(&stream_id).map(|e| Arc::clone(&e.log))
        };
        if let Some(log) = log {
            log.lock().unwrap().detach_if(sub);
        }
    }

    /// Attach `sub` as the presence subscriber, replaying liveness events from
    /// `from_seq` (D4c) — the stream `subscribe` discipline on the conn table.
    // [impl->REQ-EP-4]
    pub(crate) fn subscribe_presence(&self, sub: SharedSend, from_seq: u64) {
        self.shared.presence.lock().unwrap().attach(sub, from_seq);
    }

    /// Detach `sub` from presence iff it is the current subscriber (brain
    /// disconnect cleanup; the log + its ring persist).
    pub(crate) fn detach_presence_sub(&self, sub: &SharedSend) {
        self.shared.presence.lock().unwrap().detach_if(sub);
    }

    /// Gracefully close the endpoint: every held connection flushes its QUIC
    /// CLOSE so peers' closed-watchers fire promptly (a bare host drop kills
    /// the runtime first and leaves peers to an idle timeout).
    pub fn close(&self) {
        let endpoint = Arc::clone(&self.endpoint);
        self.runtime.handle().block_on(async move {
            endpoint.close().await;
        });
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn hermetic(identity: &Identity) -> NetConfig {
        NetConfig {
            identity: Identity::from_seed(&identity.seed()),
            relay: RelayPolicy::Disabled,
            discovery: LocalDiscovery::Off,
            scope: BindScope::Loopback,
        }
    }

    // [unit->REQ-DAEMON-2] the endpoint is constructed broker-side (inside the
    // host's dedicated runtime) with relays disabled, bound to the node's own
    // identity, with an empty conn table.
    #[test]
    fn host_binds_endpoint_to_node_identity() {
        let id = Identity::generate();
        let host = NetHost::start(hermetic(&id)).expect("host start");
        assert_eq!(host.node_id_hex(), id.public_key().to_hex());
        assert_eq!(host.conn_count(), 0);
        assert!(!host.addr_json().is_null(), "dialable address serializes");
    }

    // [unit->REQ-DAEMON-2] a broker-owned host dials a second in-process host
    // over hermetic loopback QUIC; both sides register the connection — the
    // dialer in its table, the acceptor via its accept loop.
    #[test]
    fn two_hosts_dial_over_loopback() {
        let a = NetHost::start(hermetic(&Identity::generate())).expect("host a");
        let b_id = Identity::generate();
        let b = NetHost::start(hermetic(&b_id)).expect("host b");

        let (conn_id, remote) = a.dial(b.addr()).expect("dial");
        assert_eq!(conn_id, 1, "first broker-minted conn id");
        assert_eq!(
            remote,
            b_id.public_key().to_hex(),
            "handshake proves the peer"
        );
        assert_eq!(a.conn_count(), 1);

        // The acceptor's loop registers the inbound connection (async — poll).
        for _ in 0..400 {
            if b.conn_count() == 1 {
                break;
            }
            std::thread::sleep(std::time::Duration::from_millis(5));
        }
        assert_eq!(b.conn_count(), 1, "accept loop registered the inbound conn");
        assert_eq!(
            b.conn_remote_hex(1).as_deref(),
            Some(a.node_id_hex().as_str()),
            "acceptor sees the dialer's identity"
        );
    }
}


Now nethost is clear: `register_conn` is the choke point (called from both `dial` line 544 and accept loop line 466). D2 inserts mutual seed-proof before/within registration. Need: keep-alive config in net endpoint, and where seed/subnet/epoch live.