wire/daemon_stream.rs
1//! Daemon-side SSE stream subscriber (R1 phase 2, v0.5.6).
2//!
3//! Opens a long-lived `GET /v1/events/:slot_id/stream` connection to the
4//! relay using the operator's own slot_token, parses SSE `data:` lines as
5//! they arrive, and pings a wake-channel for each event. The daemon's main
6//! loop replaces `std::thread::sleep(interval)` with `recv_timeout(interval)`
7//! against this channel, so a posted event traverses sender → relay →
8//! subscriber → local inbox in ~10-50ms instead of waiting for the next
9//! ~5s poll tick.
10//!
11//! Failure model: if the stream errors or disconnects, the subscriber
12//! reconnects with exponential backoff (1s → 2s → 4s → 8s → 30s cap). The
13//! daemon's regular polling loop is unaffected and continues as a safety
14//! net — stream-down does NOT mean events-down. Operator running
15//! `wire daemon` with no relay reachability sees both signals (stream
16//! reconnect retries + poll errors) and can diagnose.
17//!
18//! Design note: this is a one-way wake signal, not the data path. The
19//! actual `run_sync_pull` re-fetches via `list_events` so we get
20//! signature verification, dedup, and inbox write through the exact same
21//! code path as polling. The stream only changes WHEN pull runs, not HOW.
22
23use anyhow::Result;
24use std::io::{BufRead, BufReader};
25use std::sync::mpsc::Sender;
26use std::time::{Duration, Instant};
27
28/// Stream-state file written by `run_subscriber` on every state
29/// transition. Surfaced via `tool_status` so an operator can tell
30/// "stream alive" (live monitor will fire on inbound) from
31/// "polling-only" (daemon up, monitor will wait until next poll). The
32/// file is best-effort; missing/unreadable counts as "unknown" and the
33/// reader degrades gracefully.
34fn stream_state_path() -> Option<std::path::PathBuf> {
35 crate::config::state_dir()
36 .ok()
37 .map(|d| d.join("stream_state.json"))
38}
39
40/// Write the current stream-state snapshot. Best-effort; an unwritable
41/// state-dir does not block the subscriber loop. Schema-versioned so
42/// future fields (per-event count, reconnect attempt index) can land
43/// additively without breaking older readers.
44fn write_stream_state(state: &str, last_event_at: Option<&str>, reconnects: u64) {
45 if let Some(path) = stream_state_path() {
46 if let Some(parent) = path.parent() {
47 let _ = std::fs::create_dir_all(parent);
48 }
49 let ts = time::OffsetDateTime::now_utc()
50 .format(&time::format_description::well_known::Rfc3339)
51 .unwrap_or_default();
52 let body = serde_json::json!({
53 "schema": "wire-daemon-stream-state-v1",
54 "ts": ts,
55 "state": state,
56 "last_event_at": last_event_at,
57 "reconnect_count": reconnects,
58 });
59 let _ = std::fs::write(&path, serde_json::to_vec_pretty(&body).unwrap_or_default());
60 }
61}
62
63/// Spawn the stream-subscriber thread. Returns immediately; the thread
64/// runs until process exit. `wake_tx` is signaled on every received SSE
65/// `data:` line (any event, no parsing of body). Errors during connect or
66/// stream-read trigger reconnect-with-backoff, never panic.
67pub fn spawn_stream_subscriber(wake_tx: Sender<()>) {
68 std::thread::Builder::new()
69 .name("wire-stream-sub".into())
70 .spawn(move || run_subscriber(wake_tx))
71 .expect("spawn wire-stream-sub thread");
72}
73
74/// A clean-closed SSE stream that stayed open at least this long is treated as
75/// a healthy long-lived stream → reconnect immediately. Shorter than that and
76/// the close is "instant EOF" (a saturated relay accepting then dropping the
77/// body), which gets the exponential backoff instead of a zero-delay re-spin.
78/// Well under the 30s server keepalive so genuine streams always clear it.
79const STREAM_HEALTHY_SECS: u64 = 10;
80
81fn run_subscriber(wake_tx: Sender<()>) {
82 let mut backoff_secs = 1u64;
83 let mut reconnects: u64 = 0;
84 let mut last_event_at: Option<String> = None;
85 write_stream_state("connecting", last_event_at.as_deref(), reconnects);
86 loop {
87 // We wrap a closure so the connect-and-read inner can stamp
88 // last_event_at into our outer scope on every wake without
89 // restructuring the existing signature. The Vec<String> carries
90 // at most one timestamp (latest); polled by reference below.
91 let mut latest_event_ts: Vec<String> = Vec::new();
92 // v0.14.3 (coral dogfood 2026-06-01): pass accumulated
93 // `last_event_at` + `reconnects` so the "connected" write
94 // inside connect_and_read preserves them. Pre-fix, every
95 // successful reconnect overwrote stream_state.json with
96 // `last_event_at:null, reconnect_count:0` even after
97 // events had arrived + previous reconnects had occurred.
98 // Operator surface always read "last event never" on
99 // long-running daemons.
100 let connected_at = Instant::now();
101 let outcome = connect_and_read(
102 &wake_tx,
103 &mut latest_event_ts,
104 last_event_at.as_deref(),
105 reconnects,
106 );
107 let stayed_open = connected_at.elapsed();
108 if let Some(ts) = latest_event_ts.into_iter().last() {
109 last_event_at = Some(ts);
110 }
111 match outcome {
112 Ok(()) => {
113 reconnects += 1;
114 // A long-lived stream closing (server reload) → reconnect fast.
115 // But a relay that ACCEPTS the connection (HTTP 200) then drops
116 // the body immediately — exactly what a concurrency-saturated
117 // instance does — returns Ok(()) instantly, and resetting backoff
118 // to 1 with no sleep span-loops the relay (amplifying the very
119 // saturation that caused the close). Only fast-reconnect if the
120 // stream actually stayed open; otherwise floor it with the same
121 // backoff as the error path.
122 if stayed_open >= Duration::from_secs(STREAM_HEALTHY_SECS) {
123 backoff_secs = 1;
124 eprintln!("daemon-stream: connection closed cleanly, reconnecting");
125 write_stream_state("reconnecting", last_event_at.as_deref(), reconnects);
126 } else {
127 eprintln!(
128 "daemon-stream: stream closed after {stayed_open:?} (instant EOF — relay may be saturated); reconnecting in {backoff_secs}s"
129 );
130 write_stream_state("reconnecting", last_event_at.as_deref(), reconnects);
131 std::thread::sleep(Duration::from_secs(backoff_secs));
132 backoff_secs = (backoff_secs * 2).min(30);
133 }
134 }
135 Err(e) => {
136 reconnects += 1;
137 // A stream that stayed healthy for a while and then died DIRTY
138 // (mid-stream reset / relay restart surfacing as Err, not a clean
139 // EOF) shouldn't carry backoff accrued from earlier instant
140 // failures — otherwise repeated healthy-then-Err cycles ratchet
141 // toward the 30s cap despite each connection being fine. Reset
142 // first, mirroring the clean-close healthy path above.
143 if stayed_open >= Duration::from_secs(STREAM_HEALTHY_SECS) {
144 backoff_secs = 1;
145 }
146 eprintln!("daemon-stream: error {e:#}; reconnecting in {backoff_secs}s");
147 write_stream_state("error", last_event_at.as_deref(), reconnects);
148 std::thread::sleep(Duration::from_secs(backoff_secs));
149 backoff_secs = (backoff_secs * 2).min(30);
150 }
151 }
152 }
153}
154
155fn connect_and_read(
156 wake_tx: &Sender<()>,
157 last_event_ts: &mut Vec<String>,
158 accumulated_last_event_at: Option<&str>,
159 accumulated_reconnects: u64,
160) -> Result<()> {
161 // Re-read relay-state on each reconnect so a fresh slot allocation /
162 // rotation picks up automatically without daemon restart.
163 let state = crate::config::read_relay_state()?;
164 let self_state = state
165 .get("self")
166 .cloned()
167 .unwrap_or(serde_json::Value::Null);
168 let url = self_state
169 .get("relay_url")
170 .and_then(|v| v.as_str())
171 .unwrap_or("");
172 let slot_id = self_state
173 .get("slot_id")
174 .and_then(|v| v.as_str())
175 .unwrap_or("");
176 let slot_token = self_state
177 .get("slot_token")
178 .and_then(|v| v.as_str())
179 .unwrap_or("");
180 if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
181 return Err(anyhow::anyhow!(
182 "stream-sub: relay-state missing self.{{relay_url,slot_id,slot_token}} — sleep until next reconnect"
183 ));
184 }
185
186 let stream_url = format!("{url}/v1/events/{slot_id}/stream");
187 // v0.5.13: honor WIRE_INSECURE_SKIP_TLS_VERIFY on the stream sub too,
188 // matching the rest of the wire HTTPS surface (issue #6).
189 let client = {
190 let cfg = crate::tls::shared_client_config();
191 let mut b = reqwest::blocking::Client::builder()
192 // v0.14.2 #177: same dual-roots config the rest of wire's
193 // HTTPS surface uses. SSE used to build its own bare
194 // client which inherited reqwest's default root source
195 // (webpki only under #176's feature flag); now both
196 // surfaces share `tls::shared_client_config`.
197 .use_preconfigured_tls((*cfg).clone())
198 // No total timeout: stream is expected to stay open indefinitely.
199 // TCP keepalive catches a hung connection (server crashed, network
200 // black hole) — the BufReader::lines loop returns Err and the
201 // outer reconnect-with-backoff kicks in.
202 // v0.14.2 (#162 fix #7): tightened TCP keepalive from 60s to
203 // 30s so the kernel-level dead-connection check kicks in sooner
204 // when the SSE upstream goes silent. reqwest's blocking client
205 // doesn't expose a per-read body timeout (the obvious shape for
206 // this) — `Client::timeout` is a total-request timeout, the
207 // wrong primitive for a long-lived stream. A more surgical
208 // per-read timeout via the underlying socket needs a custom
209 // reader and is deferred to v0.15; tightening keepalive is the
210 // observable improvement we can ship today. Honey-pine field
211 // guide failure-mode #2 ("daemon alive but stream wedged") is
212 // also surfaced via the new `stream_state.json` so callers can
213 // detect the polling-only degradation without waiting for the
214 // wedge to clear.
215 .tcp_keepalive(Some(Duration::from_secs(30)));
216 if std::env::var(crate::relay_client::INSECURE_SKIP_TLS_ENV)
217 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
218 .unwrap_or(false)
219 {
220 b = b.danger_accept_invalid_certs(true);
221 }
222 b.build()?
223 };
224
225 let resp = client
226 .get(&stream_url)
227 .header("Accept", "text/event-stream")
228 .bearer_auth(slot_token)
229 .send()?;
230
231 if !resp.status().is_success() {
232 return Err(anyhow::anyhow!(
233 "stream-sub: server returned {} on connect",
234 resp.status()
235 ));
236 }
237
238 // v0.14.2 (#162 fix #7): mark the stream "connected" once the
239 // server has accepted the slot_token + the body read starts. The
240 // outer loop transitions to "reconnecting" on clean close or
241 // "error" on failure; this is the only place we can confidently
242 // claim "stream is live and pulling events for monitor".
243 //
244 // v0.14.3 (coral dogfood 2026-06-01): preserve accumulated
245 // last_event_at + reconnect counter instead of writing null/0
246 // and clobbering history every reconnect.
247 write_stream_state(
248 "connected",
249 accumulated_last_event_at,
250 accumulated_reconnects,
251 );
252 let reader = BufReader::new(resp);
253 for line in reader.lines() {
254 let line = line?;
255 // SSE protocol: each event is one or more `field: value` lines
256 // followed by a blank line. We only care about `data:` lines —
257 // every event the relay sends is a `data: <json>` line. Any other
258 // field (comments via `:keepalive`, etc.) is ignored. Empty line
259 // is the event separator; benign to ignore.
260 if line.starts_with("data:") {
261 // Fire wake signal. If the main loop is busy, the channel
262 // backs up to a small buffer; we don't block — drop on full
263 // since multiple wakes coalesce into a single pull anyway.
264 let _ = wake_tx.send(());
265 // v0.14.2 (#162 fix #7): stamp the most-recent event-arrival
266 // timestamp for `stream_state.json`. Push, don't replace;
267 // outer loop reads .last() so we only keep the latest. Best-
268 // effort format; failure here = no stamp this cycle.
269 let now = time::OffsetDateTime::now_utc()
270 .format(&time::format_description::well_known::Rfc3339)
271 .unwrap_or_default();
272 if !now.is_empty() {
273 last_event_ts.push(now);
274 }
275 }
276 }
277 Ok(())
278}