agentmux_launcher/host_pipe/mod.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// CPD-2 — launcher → host pipe wrapper.
5//
6// This module wraps the writer half of the launcher's named-pipe
7// connection to the host process and exposes a stable interface for
8// the rest of the launcher to push **Commands** (saga-issued, future
9// CPD-3 wiring) and **Events** (existing reducer fanout) down to the
10// host as `HostFrame` envelopes.
11//
12// CPD-2 is **infrastructure-only**: the saga coordinator's
13// `apply_action` for `IssueCmd::Host` STAYS log-only. CPD-3 replaces
14// the log with a `HostPipe::send_command` call.
15//
16// See `docs/specs/SPEC_CROSS_PROCESS_DISPATCH_2026-05-01.md` §3.5 +
17// §3.9 for the design + spec acceptance criteria.
18//
19// ## Lifecycle / connection model
20//
21// The IPC server's per-connection fanout task hands the host's writer
22// half to `HostPipe::set_writer` once the connecting client has
23// registered as `ClientKind::Host`. On disconnect (read EOF or write
24// failure), the same task calls `HostPipe::clear_writer`, which arms
25// the disconnect timer (30s — see §3.9). Reconnect re-arms via
26// `set_writer` again.
27//
28// While disconnected, `send_command` BUFFERS into a bounded
29// `pending_buffer` (cap 64 frames). On reconnect, frames drain in
30// FIFO order. On overflow OR after 30s of disconnection, the
31// pending buffer is flushed and **`Event::SagaActionFailed` is
32// emitted on the broadcast bus for every Command frame whose saga
33// got dropped.** Event frames are silently dropped on overflow —
34// they're already broadcast to other clients via the per-connection
35// fanout, so the host missing one isn't a saga-correctness issue.
36//
37// Until CPD-1 (schema PR) lands, `Event::SagaActionFailed` does not
38// exist on the agentmux-common Event enum. To avoid a hard dep on
39// CPD-1's merge order, this module emits failures via the existing
40// `Event::SagaFailed { saga_id, reason, version }` event with a
41// `reason` prefix of `"host pipe ..."` so a downstream observer can
42// filter. CPD-3 + CPD-1 will tighten the variant surface.
43//
44// ## HostFrame envelope
45//
46// Per spec §3.1, the launcher → host wire eventually carries a
47// tagged union of Event vs Command. Until CPD-1 lands the envelope
48// in agentmux-common, we declare `HostFrame` here locally. Both
49// CPD-1 and CPD-2 can land independently; whichever lands first
50// owns the envelope, the second PR migrates over.
51
52use std::collections::VecDeque;
53use std::io;
54use std::sync::Arc;
55use std::time::{Duration, Instant};
56
57use agentmux_common::ipc::{Command, Event};
58pub use agentmux_common::ipc::HostFrame;
59use tokio::io::{AsyncWrite, AsyncWriteExt};
60use tokio::sync::Mutex;
61
62mod connection;
63#[cfg(test)]
64mod tests;
65
66/// Maximum number of frames buffered while the host is disconnected.
67/// On overflow, the oldest frame is dropped; if it was a Command,
68/// `Event::SagaFailed` is emitted for the saga whose dispatch was
69/// lost. See spec §3.9.
70pub const PENDING_BUFFER_CAP: usize = 64;
71
72/// Maximum disconnect duration before the pending buffer is flushed.
73/// After this, every buffered Command's saga is failed with
74/// `"host unreachable"`. See spec §3.9.
75pub const DISCONNECT_TIMEOUT: Duration = Duration::from_secs(30);
76
77/// Reasons `send_command` / `send_event` can fail synchronously.
78///
79/// `HostNotConnected` is reserved for cases where the caller wants to
80/// **fail-fast** instead of buffering (CPD-3 will choose whichever
81/// fits per command kind). The default `send_command` code path
82/// buffers; see `try_send_command_no_buffer` for the fail-fast variant.
83#[derive(Debug)]
84pub enum HostPipeError {
85 /// No host is currently connected and the caller asked to fail
86 /// rather than buffer (`try_send_command_no_buffer`; first caller is
87 /// the UI-liveness prober, SPEC_LAUNCHER_TEARDOWN_BACKSTOP Phase 1).
88 HostNotConnected,
89 /// Underlying write to the pipe failed.
90 WriteFailed(io::Error),
91 /// Frame failed to serialize. Should be impossible for well-
92 /// formed Command / Event variants but kept as a result variant
93 /// for defense-in-depth.
94 #[allow(dead_code)] // defense-in-depth; serde_json::to_vec on owned types should not fail
95 Serialize(serde_json::Error),
96 /// Pending buffer is full and we couldn't drop the oldest entry
97 /// to make room (e.g. caller asked for no-overflow semantics).
98 /// Default `send_command` does drop-oldest + emit-failed, so
99 /// callers won't see this from the public API today.
100 #[allow(dead_code)] // reserved for stricter callers
101 BufferOverflow,
102}
103
104impl std::fmt::Display for HostPipeError {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 match self {
107 HostPipeError::HostNotConnected => write!(f, "host not connected"),
108 HostPipeError::WriteFailed(e) => write!(f, "write to host pipe failed: {}", e),
109 HostPipeError::Serialize(e) => write!(f, "frame serialize failed: {}", e),
110 HostPipeError::BufferOverflow => write!(f, "host pipe pending buffer overflow"),
111 }
112 }
113}
114
115impl std::error::Error for HostPipeError {}
116
117/// Trait-object writer reference the HostPipe holds when connected.
118///
119/// `Arc<Mutex<Box<dyn AsyncWrite + Unpin + Send>>>` so the same
120/// physical write half is reachable both by HostPipe (event fanout +
121/// saga command dispatch) AND by the IPC server's per-connection
122/// handler (connection-private error replies — Event::Error on parse
123/// failures + register-first violations). The mutex serializes
124/// writes so frames can't interleave on the wire.
125///
126/// On set, HostPipe stores a clone of the same Arc the per-connection
127/// handler uses for its main loop. On clear, HostPipe drops its
128/// clone; the handler still has its own Arc so error replies (if
129/// any) keep working through the disconnect path.
130pub type SharedWriter = Arc<Mutex<BoxedWriter>>;
131
132/// Test-only convenience: own a Box directly. Tests construct one
133/// via `make_shared_writer(Box::new(duplex_a))`.
134pub type BoxedWriter = Box<dyn AsyncWrite + Unpin + Send>;
135
136/// Wrap a boxed writer into a SharedWriter. Used by tests +
137/// `ipc::server` to coerce a concrete WriteHalf into the trait
138/// object the HostPipe stores.
139///
140/// Implementation note: we go through an intermediate
141/// `Arc<Mutex<Box<dyn AsyncWrite + ...>>>` and then move the inner
142/// Box out at write time. This avoids relying on
143/// `Arc<Mutex<Concrete>>`-to-`Arc<Mutex<dyn>>` unsizing coercion,
144/// which is unstable across MSRVs for `tokio::sync::Mutex`.
145pub fn make_shared_writer(writer: BoxedWriter) -> SharedWriter {
146 Arc::new(Mutex::new(writer))
147}
148
149/// One frame waiting to be written to the host. Tracks the saga_id
150/// when known so we can emit a meaningful `SagaFailed` if the frame
151/// is dropped on overflow / timeout.
152#[derive(Debug)]
153struct PendingFrame {
154 frame: HostFrame,
155 /// `Some(id)` for Command frames whose Command variant carries
156 /// a saga_id. `None` for Event frames (the host is not the only
157 /// subscriber for events — they also flow to the renderer / Tool
158 /// clients via the broadcast bus, so a missed event-frame is not
159 /// a saga-level failure) and for Command frames whose variant
160 /// has no saga_id field yet (the schema additions land in CPD-1).
161 saga_id: Option<u64>,
162}
163
164struct HostPipeInner {
165 writer: Option<SharedWriter>,
166 pending_buffer: VecDeque<PendingFrame>,
167 /// Set when the writer transitions from Some -> None. Cleared on
168 /// reconnect. Used to enforce the 30s disconnect timeout.
169 host_disconnected_at: Option<Instant>,
170 /// Monotonic generation incremented on every `set_writer` call.
171 /// Prevents a stale fanout (from an old host connection that
172 /// hasn't fully torn down yet) from writing into a freshly-
173 /// registered host's writer. (codex P1 PR #642 round 2.)
174 host_session_id: u64,
175}
176
177impl HostPipeInner {
178 fn new() -> Self {
179 Self {
180 writer: None,
181 pending_buffer: VecDeque::with_capacity(PENDING_BUFFER_CAP),
182 host_disconnected_at: None,
183 host_session_id: 0,
184 }
185 }
186}
187
188/// Public wrapper around the launcher → host pipe.
189///
190/// Cheap to clone (one `Arc`); shared by the IPC server's per-
191/// connection task (set/clear writer) and the saga coordinator
192/// (send_command, CPD-3) and the per-host fanout task (send_event,
193/// refactored in this PR).
194///
195/// Manual `Debug` (rather than derive) so we don't have to require
196/// `Debug` on the boxed writer trait object.
197#[derive(Clone)]
198pub struct HostPipe {
199 inner: Arc<Mutex<HostPipeInner>>,
200 /// Broadcast bus reference — used to emit `Event::SagaFailed`
201 /// when a buffered Command is dropped (overflow or 30s timeout).
202 events_tx: tokio::sync::broadcast::Sender<Event>,
203 /// Reference to the launcher's reducer state for `bump_version`
204 /// when emitting saga lifecycle events. Mirrors the saga
205 /// coordinator's pattern so emitted events get a fresh,
206 /// monotonic global version.
207 state: Arc<Mutex<crate::state::State>>,
208}
209
210impl std::fmt::Debug for HostPipe {
211 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212 f.debug_struct("HostPipe").finish_non_exhaustive()
213 }
214}
215
216impl HostPipe {
217 pub fn new(
218 events_tx: tokio::sync::broadcast::Sender<Event>,
219 state: Arc<Mutex<crate::state::State>>,
220 ) -> Self {
221 Self {
222 inner: Arc::new(Mutex::new(HostPipeInner::new())),
223 events_tx,
224 state,
225 }
226 }
227
228 /// Hand the host's writer half to the pipe. Called by the IPC
229 /// server's per-connection task once the connecting client
230 /// registers as `ClientKind::Host`. Drains any pending frames
231 /// in FIFO order before returning.
232 ///
233 /// If a writer is already set (a prior host registered and this
234 /// is a second), the prior writer is replaced. The drain logic
235 /// still runs against the new writer.
236 ///
237 /// **Locking:** the inner mutex is held across the per-frame
238 /// `write_all().await`. That's intentional — concurrent
239 /// `send_frame` calls during drain would race FIFO ordering
240 /// against the freshly-installed writer. Holding the lock keeps
241 /// the drain-then-install transition atomic from the caller's
242 /// perspective. Hold time is bounded by `pending_buffer` size
243 /// (cap 64) × per-frame write latency (microseconds on a healthy
244 /// pipe), well under the existing `state.lock()` discipline.
245 /// Returns the new `host_session_id`.
246 pub async fn set_writer(&self, writer: SharedWriter) -> u64 {
247 let mut inner = self.inner.lock().await;
248 inner.host_disconnected_at = None;
249 inner.host_session_id = inner.host_session_id.wrapping_add(1);
250 let session_id = inner.host_session_id;
251 // Drain pending frames against the new writer in FIFO order.
252 while let Some(p) = inner.pending_buffer.pop_front() {
253 let mut wlock = writer.lock().await;
254 let res = write_frame_async(&mut **wlock, &p.frame).await;
255 drop(wlock);
256 if let Err(e) = res {
257 crate::log(&format!(
258 "[host_pipe] drain failed mid-flight: {} — re-buffering rest",
259 e
260 ));
261 inner.pending_buffer.push_front(p);
262 inner.host_disconnected_at = Some(Instant::now());
263 return session_id;
264 }
265 }
266 inner.writer = Some(writer);
267 session_id
268 }
269
270 /// Mark the host as disconnected. Arms the 30s timer; subsequent
271 /// `send_command` calls buffer up to `PENDING_BUFFER_CAP`.
272 pub async fn clear_writer(&self) {
273 let mut inner = self.inner.lock().await;
274 if inner.writer.is_some() {
275 inner.writer = None;
276 inner.host_disconnected_at = Some(Instant::now());
277 crate::log("[host_pipe] host disconnected — buffering subsequent frames (30s budget)");
278 }
279 }
280
281 /// Remove every buffered frame whose `saga_id` matches `saga_id`.
282 /// Returns the count of removed frames.
283 ///
284 /// Called from `SagaCoordinator::claim_terminal` when a saga
285 /// terminates (Done / Failed / timeout / eviction / etc.) — the
286 /// terminal event closes the saga bracket, so any frames the
287 /// saga left in the pending buffer would, if drained on reconnect,
288 /// produce orphan side effects after the bracket is already
289 /// closed (and risk a second `SagaFailed` via the 30s expiry
290 /// path's `emit_drop_failure`). Purging here keeps the saga's
291 /// terminal slot atomic across both the bus and the wire.
292 /// (codex + reagent P1 PR #644 round 7.)
293 pub async fn cancel_saga(&self, saga_id: u64) -> usize {
294 let mut inner = self.inner.lock().await;
295 let before = inner.pending_buffer.len();
296 inner.pending_buffer.retain(|f| f.saga_id != Some(saga_id));
297 let removed = before - inner.pending_buffer.len();
298 if removed > 0 {
299 crate::log(&format!(
300 "[host_pipe] cancel_saga(saga_id={}) purged {} buffered frame(s)",
301 saga_id, removed
302 ));
303 }
304 removed
305 }
306
307 /// Drain `pending_buffer` through the given writer in FIFO order.
308 /// Used by `send_frame`'s ptr_eq-mismatch path to flush a frame
309 /// that landed in the buffer after a writer-replacement race
310 /// (codex P1 PR #642 round 4). Symmetric with `set_writer`'s
311 /// drain logic but operates on an existing writer rather than
312 /// installing a new one. On per-frame failure, re-buffers the
313 /// remaining frames at the front and arms the disconnect timer.
314 async fn drain_pending_to(&self, writer: &SharedWriter) {
315 let mut inner = self.inner.lock().await;
316 while let Some(p) = inner.pending_buffer.pop_front() {
317 let mut wlock = writer.lock().await;
318 let res = write_frame_async(&mut **wlock, &p.frame).await;
319 drop(wlock);
320 if let Err(e) = res {
321 crate::log(&format!(
322 "[host_pipe] mid-flight drain failed: {} — re-buffering rest",
323 e
324 ));
325 inner.pending_buffer.push_front(p);
326 // Best-effort: clear the writer slot if it's still ours
327 // (caller is expected to have already raced past clear)
328 // and arm the disconnect timer so the next reconnect
329 // resumes the drain.
330 let same_writer = inner
331 .writer
332 .as_ref()
333 .is_some_and(|cur| Arc::ptr_eq(cur, writer));
334 if same_writer {
335 inner.writer = None;
336 inner.host_disconnected_at = Some(Instant::now());
337 }
338 return;
339 }
340 }
341 }
342
343 /// Push a Command down to the host. On disconnect, buffers up to
344 /// `PENDING_BUFFER_CAP`; on overflow or 30s timeout, drops + emits
345 /// `Event::SagaFailed` for the dropped Command's saga.
346 ///
347 /// CPD-3 — called from the saga coordinator's `apply_action` when
348 /// dispatching `IssueCmd::Host` actions.
349 pub async fn send_command(&self, cmd: &Command) -> Result<(), HostPipeError> {
350 let saga_id = saga_id_of(cmd);
351 let frame = HostFrame::Command(cmd.clone());
352 self.send_frame(frame, saga_id).await
353 }
354
355 /// Fail-fast variant of `send_command`: when no host writer is
356 /// installed, returns `HostNotConnected` immediately instead of
357 /// buffering (the variant the `HostPipeError` docs reserved for
358 /// exactly this).
359 ///
360 /// For frames where DELAYED delivery is meaningless or misleading —
361 /// the UI-liveness probe (SPEC_LAUNCHER_TEARDOWN_BACKSTOP Phase 1) is
362 /// the first caller: a probe buffered while the host is down would
363 /// either expire silently in the pending buffer (probes carry no
364 /// saga_id, so the drop paths can't even report the loss) or be
365 /// delivered to a FUTURE host, and in both cases the caller's
366 /// outstanding-probe bookkeeping would age into a false "UI thread
367 /// did not pump" miss (reagent P1, PR #2106 round 2 — the routine
368 /// disconnected-send during every crash-restart gap hit this).
369 ///
370 /// Residual race, accepted and bounded: a disconnect landing between
371 /// this check and the delegated write can still buffer the frame —
372 /// that window is hair-width (same lock, adjacent awaits) versus the
373 /// routine seconds-long disconnected spans this closes, and if it ever
374 /// fires the resulting one-tick miss coincides with the pipe
375 /// supervision's own loud disconnect logs for the same instant, so
376 /// the telemetry stays attributable.
377 pub async fn try_send_command_no_buffer(
378 &self,
379 cmd: &Command,
380 ) -> Result<(), HostPipeError> {
381 {
382 let inner = self.inner.lock().await;
383 if inner.writer.is_none() {
384 return Err(HostPipeError::HostNotConnected);
385 }
386 }
387 let res = self.send_command(cmd).await;
388 if res.is_err() {
389 // reagent P1 (PR #2106 round 3): a WRITE failure on an installed
390 // writer — the routine first detection of a dead connection —
391 // takes `send_frame`'s error branch, which pushes the frame into
392 // `pending_buffer` as part of its clear-writer handling even
393 // though it returns Err. That would break this method's contract
394 // via a second path (the stale probe drains to a future host on
395 // reconnect). Enforce the contract post-hoc: scrub any probe
396 // frames the delegated send may have parked. Probe-specific by
397 // design — one outstanding probe exists at a time, so ANY
398 // buffered ProbeUiThread frame is stale; a future non-probe
399 // no-buffer caller extends this per command kind.
400 self.purge_pending_probe_frames().await;
401 }
402 res
403 }
404
405 /// Remove every buffered `ProbeUiThread` frame. See
406 /// `try_send_command_no_buffer`'s error branch for why these can appear
407 /// despite the fail-fast contract, and why any such frame is stale by
408 /// construction.
409 async fn purge_pending_probe_frames(&self) {
410 let mut inner = self.inner.lock().await;
411 inner.pending_buffer.retain(|p| {
412 !matches!(
413 p.frame,
414 HostFrame::Command(Command::ProbeUiThread { .. })
415 )
416 });
417 }
418
419 /// Send an event without a session check — only safe in tests
420 /// or single-session contexts.
421 /// Writes RAW `Event` JSON (no `HostFrame` envelope). The host's
422 /// parser (`crates/cef/src/launcher_ipc/mod.rs`) tries a `HostFrame`
423 /// first and still accepts a raw `Event` line as a legacy fallback.
424 #[cfg(test)]
425 pub async fn send_event(&self, event: &Event) -> Result<(), HostPipeError> {
426 let writer = {
427 let inner = self.inner.lock().await;
428 match inner.writer.as_ref() {
429 Some(w) => Arc::clone(w),
430 None => return Ok(()),
431 }
432 };
433 let mut bytes =
434 serde_json::to_vec(event).map_err(HostPipeError::Serialize)?;
435 bytes.push(b'\n');
436 let mut wlock = writer.lock().await;
437 wlock
438 .write_all(&bytes)
439 .await
440 .map_err(HostPipeError::WriteFailed)?;
441 wlock
442 .flush()
443 .await
444 .map_err(HostPipeError::WriteFailed)?;
445 Ok(())
446 }
447
448 async fn send_frame(
449 &self,
450 frame: HostFrame,
451 saga_id: Option<u64>,
452 ) -> Result<(), HostPipeError> {
453 // First, check the disconnect timer. If we've been disconnected
454 // > 30s, drain the pending buffer + emit failures BEFORE we
455 // queue a fresh frame on top.
456 self.expire_pending_if_timed_out().await;
457
458 // Atomic decision: under the inner lock, either pull a writer
459 // clone (then release lock and write) OR push to pending buffer
460 // (lock held to keep the buffer-vs-writer state consistent).
461 // Splitting this into two lock acquisitions creates the TOCTOU
462 // window where set_writer can land between "see None" and
463 // "push to buffer", causing frames to wedge in the buffer
464 // while the pipe is actually connected. (codex P1 + reagent P1
465 // PR #642 round 3.)
466 enum Decision {
467 Write(SharedWriter),
468 Buffered { dropped: Option<PendingFrame> },
469 }
470 let decision = {
471 let mut inner = self.inner.lock().await;
472 match inner.writer.as_ref() {
473 Some(w) => Decision::Write(Arc::clone(w)),
474 None => {
475 let dropped = if inner.pending_buffer.len() >= PENDING_BUFFER_CAP {
476 inner.pending_buffer.pop_front()
477 } else {
478 None
479 };
480 inner
481 .pending_buffer
482 .push_back(PendingFrame { frame: frame.clone(), saga_id });
483 Decision::Buffered { dropped }
484 }
485 }
486 };
487
488 match decision {
489 Decision::Write(writer) => {
490 let mut wlock = writer.lock().await;
491 match write_frame_async(&mut **wlock, &frame).await {
492 Ok(()) => Ok(()),
493 Err(e) => {
494 drop(wlock);
495 crate::log(&format!(
496 "[host_pipe] direct write failed: {} — clearing writer + buffering",
497 e
498 ));
499 // Arc::ptr_eq guards against clobbering a freshly-
500 // installed writer if set_writer raced in between
501 // the failed write and this clear. (reagent P1
502 // PR #642 round 3.)
503 let mut inner = self.inner.lock().await;
504 let same_writer = inner
505 .writer
506 .as_ref()
507 .is_some_and(|cur| Arc::ptr_eq(cur, &writer));
508 if same_writer {
509 inner.writer = None;
510 inner.host_disconnected_at = Some(Instant::now());
511 inner
512 .pending_buffer
513 .push_back(PendingFrame { frame, saga_id });
514 Err(HostPipeError::WriteFailed(e))
515 } else {
516 // Writer was concurrently replaced (set_writer
517 // raced and already drained whatever buffer
518 // existed at install time). Push to the new
519 // writer's buffer + immediately retry through
520 // the new writer; otherwise this frame sits
521 // until the next reconnect, even though the
522 // pipe is healthy. (codex P1 PR #642 round 4.)
523 let new_writer = inner.writer.as_ref().map(Arc::clone);
524 // Push the failed frame to buffer FIRST so
525 // the drain below picks it up in FIFO order
526 // alongside any other in-flight pending.
527 inner
528 .pending_buffer
529 .push_back(PendingFrame { frame, saga_id });
530 drop(inner);
531 if let Some(new_w) = new_writer {
532 self.drain_pending_to(&new_w).await;
533 }
534 // We still surface the original write failure
535 // to the caller — they asked for THIS frame
536 // to be sent on the pipe they had at call
537 // time. The drain attempt is best-effort
538 // recovery; saga-level retry should kick in
539 // if the drain fails too.
540 Err(HostPipeError::WriteFailed(e))
541 }
542 }
543 }
544 }
545 Decision::Buffered { dropped } => {
546 if let Some(dropped) = dropped {
547 self.emit_drop_failure(dropped, "host pipe backpressure overflow")
548 .await;
549 }
550 Ok(())
551 }
552 }
553 }
554
555 /// If the disconnect timer has elapsed, flush the pending buffer
556 /// and emit `Event::SagaFailed` for every buffered Command.
557 async fn expire_pending_if_timed_out(&self) {
558 let now = Instant::now();
559 let drained: Vec<PendingFrame> = {
560 let mut inner = self.inner.lock().await;
561 let Some(start) = inner.host_disconnected_at else {
562 return;
563 };
564 if now.duration_since(start) < DISCONNECT_TIMEOUT {
565 return;
566 }
567 // Reset the timer so we don't re-drain on the next call;
568 // the next disconnect transition rearms it.
569 inner.host_disconnected_at = None;
570 inner.pending_buffer.drain(..).collect()
571 };
572 if !drained.is_empty() {
573 crate::log(&format!(
574 "[host_pipe] disconnect exceeded {:?}; dropping {} buffered frames",
575 DISCONNECT_TIMEOUT,
576 drained.len()
577 ));
578 }
579 for f in drained {
580 self.emit_drop_failure(f, "host unreachable").await;
581 }
582 }
583
584 async fn emit_drop_failure(&self, dropped: PendingFrame, reason: &str) {
585 let Some(saga_id) = dropped.saga_id else {
586 // Event frames + Command frames without a saga_id are
587 // silently dropped — they don't gate any saga's progress.
588 return;
589 };
590 let v = {
591 let mut state = self.state.lock().await;
592 state.bump_version()
593 };
594 let evt = Event::SagaFailed {
595 saga_id,
596 reason: reason.to_string(),
597 version: v,
598 };
599 let _ = self.events_tx.send(evt);
600 }
601
602 /// Test-only inspection of the current pending buffer length.
603 #[cfg(test)]
604 pub async fn pending_len(&self) -> usize {
605 self.inner.lock().await.pending_buffer.len()
606 }
607
608 /// Whether a writer is currently set — i.e. a CEF host has called
609 /// `set_writer` (registered as `ClientKind::Host` over the IPC pipe)
610 /// and hasn't since disconnected. Originally test-only inspection;
611 /// also used in production by the splash's delayed "first run can
612 /// take longer" hint (`supervisor/windows.rs`) to distinguish the
613 /// pre-registration gap (host process exists, zero code executed —
614 /// where that message belongs) from ordinary post-registration
615 /// CEF-init/first-paint time (where it would be misleading). reagent
616 /// P2, PR #2967 — was a separate `has_registered_host` method with an
617 /// identical body; merged to avoid keeping two copies in sync.
618 #[cfg(any(target_os = "windows", test))]
619 pub async fn is_connected(&self) -> bool {
620 self.inner.lock().await.writer.is_some()
621 }
622
623 /// Test-only: force the disconnect timer back by `delta` so the
624 /// 30s timeout fires without sleeping in tests.
625 #[cfg(test)]
626 pub async fn rewind_disconnect_timer(&self, delta: Duration) {
627 let mut inner = self.inner.lock().await;
628 if let Some(t) = inner.host_disconnected_at.as_mut() {
629 *t = t.checked_sub(delta).unwrap_or(*t);
630 }
631 }
632}
633
634/// Pull the saga_id off a Command if its variant carries one.
635///
636/// Used by `send_frame`'s pending-buffer machinery: when a buffered
637/// command is dropped (overflow or 30s host-disconnect timeout),
638/// `emit_drop_failure` emits `Event::SagaFailed { saga_id }` so the
639/// saga's bracket closes cleanly instead of waiting forever.
640/// CPD-1 added `saga_id` fields on host-bound variants; CPD-3 made
641/// `send_command` live from the saga coordinator, so the drop
642/// machinery is now saga-correctness-relevant.
643fn saga_id_of(cmd: &Command) -> Option<u64> {
644 // CPD-1 added saga_id fields to host-bound Commands; CPD-3 made
645 // send_command live from the saga coordinator. Returning the real
646 // id here means HostPipe's drop-on-overflow + 30s-timeout-flush
647 // paths can emit `Event::SagaFailed` for the right saga, instead
648 // of orphaning the buffered command. (reagent P1 PR #644 round 5.)
649 match cmd {
650 Command::SpawnPoolWindow { saga_id } => Some(*saga_id),
651 Command::ReapPanes { saga_id, .. } => Some(*saga_id),
652 Command::DrainPoolIfLast { saga_id, .. } => Some(*saga_id),
653 _ => None,
654 }
655}
656
657/// Serialize a `HostFrame` as newline-delimited JSON and write it
658/// to a writer. Used both for direct writes (connected) and for
659/// pending-buffer drain (post-reconnect).
660async fn write_frame_async<W: AsyncWrite + Unpin + ?Sized>(
661 writer: &mut W,
662 frame: &HostFrame,
663) -> io::Result<()> {
664 let mut buf = serde_json::to_vec(frame)
665 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
666 buf.push(b'\n');
667 writer.write_all(&buf).await?;
668 writer.flush().await?;
669 Ok(())
670}