agentmux_launcher\ipc/server.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Named-pipe IPC server — accept loop + per-connection handler.
5//
6// Phase B.3: every Command goes through the pure reducer
7// (`crate::reducer::update`) which mutates the shared State and
8// returns a Vec<Event>. The handler then writes those events back
9// over the connection. State is held inside Arc<Mutex<State>> and
10// the mutex is acquired only for the duration of the reducer call —
11// never across an await.
12//
13// What this commit does NOT do:
14// * Per-subscriber broadcast routing (B.4 splits replies vs broadcasts;
15// today every event goes back over the originating connection).
16// * Server-initiated events (no spontaneous emissions yet — only
17// reducer outputs).
18// * Persisted client_id (still per-launcher-run).
19//
20// Connection lifecycle: each accepted pipe instance handles one
21// client connection end-to-end. When the client drops, the per-
22// connection task ends and the accept loop continues with a fresh
23// instance.
24
25use std::sync::Arc;
26
27use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
28use tokio::sync::Mutex;
29
30#[cfg(target_os = "windows")]
31use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
32
33use agentmux_common::ipc::{ClientKind, Command, ErrorCode, Event};
34
35use crate::host_pipe::HostPipe;
36use crate::reducer;
37use crate::state::State;
38
39/// State the IPC server shares across connections. Carries the
40/// launcher's identity (for patching into Registered events the
41/// reducer emits with sentinel values) plus the canonical State.
42#[derive(Debug)]
43pub struct ServerCtx {
44 pub launcher_pid: u32,
45 pub launcher_version: String,
46 /// Canonical state owned by the server. Mutex held only during
47 /// reducer dispatch — sub-millisecond.
48 ///
49 /// Phase E.1a — moved from `Mutex<State>` to `Arc<Mutex<State>>`
50 /// so the saga coordinator can share access. The coordinator
51 /// needs `bump_version` when emitting saga lifecycle events and
52 /// will (in E.5) inspect state during saga decisions. Sharing
53 /// via `Arc` keeps the existing single-writer-mutex discipline.
54 pub state: std::sync::Arc<Mutex<State>>,
55 /// Phase B.8 — broadcast bus for reducer-emitted events. Every
56 /// reducer event from `reducer::update` is published here; each
57 /// connection subscribes and writes received events to its own
58 /// pipe. Lets observability clients (`--diag wrr`, future Tools)
59 /// see cross-process activity, not just replies to their own
60 /// commands. Per-connection direct sends (Error replies for
61 /// parse failures, register-first violations) bypass the bus —
62 /// they're response-to-this-client-only by intent. (codex P1
63 /// PR #605.)
64 pub events_tx: tokio::sync::broadcast::Sender<Event>,
65 /// Phase D.2 — event log: in-memory ring of recent reducer
66 /// events (replay source for D.3's `GetEvents`) + disk
67 /// persistence stream for crash forensics. Server appends to
68 /// the in-memory ring synchronously after each reducer
69 /// dispatch; the disk writer is a separate task spawned in
70 /// main.rs that subscribes to the broadcast bus.
71 pub event_log: std::sync::Arc<crate::event_log::EventLog>,
72 /// CPD-2 — launcher → host pipe wrapper. The per-connection
73 /// handler hands the host's writer half to `HostPipe::set_writer`
74 /// once the connecting client registers as `ClientKind::Host`,
75 /// and clears it on disconnect. The host's per-connection event
76 /// fanout task routes events through `HostPipe::send_event`
77 /// instead of `send_event` direct (so frames carry the
78 /// `HostFrame` envelope and traverse the pending-buffer path
79 /// when the host reconnects).
80 pub host_pipe: std::sync::Arc<HostPipe>,
81}
82
83/// Bind the first named-pipe instance synchronously.
84///
85/// Phase B.6: the bind is the single-instance signal. Splitting it
86/// out of `run_ipc_server` lets the caller (main.rs) detect a
87/// collision BEFORE spawning srv/host and surface a user-visible
88/// error ("AgentMux is already running"). `ServerOptions::create`
89/// requires a Tokio runtime context for IOCP registration, so this
90/// must be called from inside `#[tokio::main]` (or any task on the
91/// runtime) — not from a plain sync entrypoint.
92///
93/// On Windows, a second launcher hitting the same pipe gets
94/// `ERROR_ACCESS_DENIED` (raw OS error 5); other errors mean the
95/// pipe namespace itself is misconfigured.
96#[cfg(target_os = "windows")]
97pub fn bind_first_pipe_instance(pipe_name: &str) -> std::io::Result<NamedPipeServer> {
98 ServerOptions::new()
99 .first_pipe_instance(true)
100 .create(pipe_name)
101}
102
103/// Run the named-pipe IPC server until cancelled (or task panics).
104///
105/// Returns a JoinHandle the caller (main.rs) holds for the life of
106/// the launcher. The server keeps accepting until the launcher's
107/// Tokio runtime shuts down.
108///
109/// The first pipe instance is passed in pre-bound by the caller (see
110/// `bind_first_pipe_instance`) so a collision can be surfaced
111/// synchronously before any children are spawned (Phase B.6).
112///
113/// Each accepted connection becomes a new tokio task running
114/// `handle_connection`. The accept loop creates a fresh
115/// `NamedPipeServer` instance for the next client BEFORE spawning
116/// the handler — without this, a slow handler could starve the next
117/// connect. Standard Win32 named-pipe pattern.
118#[cfg(target_os = "windows")]
119pub fn run_ipc_server(
120 pipe_name: String,
121 first: NamedPipeServer,
122 ctx: ServerCtx,
123) -> tokio::task::JoinHandle<()> {
124 tokio::spawn(async move {
125 let ctx = Arc::new(ctx);
126 crate::log(&format!("[ipc] server starting on {}", pipe_name));
127
128 let mut current = first;
129
130 loop {
131 // Wait for a client to connect to the existing instance.
132 // On error: log + recreate the instance + retry. Without
133 // the explicit `continue`, the failed (un-connected) pipe
134 // instance below would be moved into `accepted` and
135 // spawned in a handler that immediately fails to read,
136 // wasting a per-connection task slot. (reagent P1 + codex
137 // P1 PR #573 round-1.)
138 if let Err(e) = current.connect().await {
139 crate::log(&format!("[ipc] connect failed: {} — recreating instance", e));
140 current = match ServerOptions::new().create(&pipe_name) {
141 Ok(s) => s,
142 Err(create_err) => {
143 crate::log(&format!(
144 "[ipc] FATAL: failed to recreate pipe after connect error: {} (server stopping)",
145 create_err
146 ));
147 return;
148 }
149 };
150 continue;
151 }
152
153 // Hand the accepted instance to a handler task, then
154 // create the NEXT server instance so the next client
155 // doesn't have to wait for the handler to finish.
156 let accepted = current;
157 current = match ServerOptions::new().create(&pipe_name) {
158 Ok(s) => s,
159 Err(e) => {
160 crate::log(&format!(
161 "[ipc] FATAL: failed to create next pipe instance: {} (server stopping)",
162 e
163 ));
164 // Drain the accepted client, then bail.
165 tokio::spawn(handle_connection(accepted, Arc::clone(&ctx)));
166 return;
167 }
168 };
169
170 tokio::spawn(handle_connection(accepted, Arc::clone(&ctx)));
171 }
172 })
173}
174
175/// Unix counterpart of `bind_first_pipe_instance`: bind a Unix
176/// domain socket. The bind is the single-instance signal — a second
177/// launcher pointing at the same socket path gets `EADDRINUSE`. Caller
178/// is responsible for unlinking a stale socket file first (see the
179/// `connect → ECONNREFUSED → unlink → bind` pattern in `main.rs::run_unix`).
180///
181/// A1.1 of SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_2026_06_05.
182#[cfg(unix)]
183pub fn bind_first_unix_socket(socket_path: &str) -> std::io::Result<tokio::net::UnixListener> {
184 tokio::net::UnixListener::bind(socket_path)
185}
186
187/// Run the Unix-domain-socket IPC server until cancelled (or task panics).
188///
189/// Mirrors the Windows accept loop above. The protocol on the wire is
190/// identical (newline-delimited JSON `Command` / `Event`) so
191/// `handle_connection` is shared between platforms via generics.
192///
193/// The listener is passed in pre-bound by the caller so a collision
194/// (a second launcher pointing at the same data dir) can be surfaced
195/// synchronously before any children spawn. (Same Phase B.6 contract
196/// as the Windows path.)
197#[cfg(unix)]
198pub fn run_ipc_server(
199 socket_path: String,
200 listener: tokio::net::UnixListener,
201 ctx: ServerCtx,
202) -> tokio::task::JoinHandle<()> {
203 tokio::spawn(async move {
204 let ctx = Arc::new(ctx);
205 crate::log(&format!("[ipc] server starting on {}", socket_path));
206
207 // Backoff state — guard against a persistent accept error
208 // (e.g. EMFILE/ENFILE under fd exhaustion, EBADF if the
209 // listener fd is closed out from under us) spinning a hot
210 // loop and flooding the launcher log. Reagent P2 on PR #1288.
211 const MAX_CONSECUTIVE_ACCEPT_ERRORS: u32 = 32;
212 let mut consecutive_errors: u32 = 0;
213
214 loop {
215 match listener.accept().await {
216 Ok((stream, _addr)) => {
217 consecutive_errors = 0;
218 tokio::spawn(handle_connection(stream, Arc::clone(&ctx)));
219 }
220 Err(e) => {
221 consecutive_errors = consecutive_errors.saturating_add(1);
222 if consecutive_errors >= MAX_CONSECUTIVE_ACCEPT_ERRORS {
223 crate::log(&format!(
224 "[ipc] FATAL: {} consecutive accept errors (last: {}); listener appears permanently broken — stopping IPC server",
225 consecutive_errors, e
226 ));
227 return;
228 }
229 // Exponential backoff capped at 1s: 1ms → 2ms → 4ms
230 // → … → 1024ms → 1024ms. Keeps EMFILE/ENFILE
231 // recovery responsive (most descriptor pressure
232 // clears in microseconds) without hot-spinning a
233 // CPU core on a truly broken listener.
234 let backoff_ms = 1u64 << consecutive_errors.min(10);
235 let backoff = std::time::Duration::from_millis(backoff_ms.min(1024));
236 crate::log(&format!(
237 "[ipc] accept error #{}: {} — backing off {:?}",
238 consecutive_errors, e, backoff
239 ));
240 tokio::time::sleep(backoff).await;
241 }
242 }
243 }
244 })
245}
246
247/// Drive one connection: read newline-delimited JSON Commands,
248/// write back JSON Events. First message MUST be `Register`.
249///
250/// Phase B.3: every Command goes through `reducer::update`. The
251/// reducer is sync; we hold the state mutex only while it runs.
252/// Events come back from the reducer as Vec<Event>; we patch sentinel
253/// fields (Registered.launcher_pid / launcher_version — the reducer
254/// can't read those) and publish them on the broadcast bus.
255///
256/// Phase B.8 — events from the reducer flow through the broadcast
257/// bus (`ctx.events_tx`); a per-connection fanout task subscribes
258/// and writes events to this connection's pipe. Per-connection
259/// direct writes are reserved for "response-to-this-client-only"
260/// errors (parse failure, register-first violation). (codex P1
261/// PR #605.)
262// (gate removed — platform-neutral body, accessible from cfg(unix) too — A1.1)
263//
264// Generic over the duplex stream type so the same body serves Windows
265// `NamedPipeServer` and Unix `tokio::net::UnixStream` without code
266// duplication. Bounds are what `tokio::io::split` + `Box<dyn AsyncWrite>`
267// need: AsyncRead + AsyncWrite + Unpin + Send + 'static.
268async fn handle_connection<S>(stream: S, ctx: Arc<ServerCtx>)
269where
270 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
271{
272 let (read_half, write_half) = tokio::io::split(stream);
273 // CPD-2 — wrap the writer in the HostPipe-compatible
274 // `Arc<Mutex<Box<dyn AsyncWrite + Unpin + Send>>>` shape so the
275 // SAME writer is reachable by:
276 // 1. The per-connection main loop (connection-private error
277 // replies via `send_event_shared`).
278 // 2. The per-connection fanout task (broadcast-bus events).
279 // 3. (For the host connection only) `HostPipe::send_command` /
280 // `HostPipe::send_event` after the host registers.
281 // The Mutex serializes writes, so frames can't interleave.
282 let boxed: crate::host_pipe::BoxedWriter = Box::new(write_half);
283 let writer: crate::host_pipe::SharedWriter = crate::host_pipe::make_shared_writer(boxed);
284 let reader = BufReader::new(read_half);
285 let mut lines = reader.lines();
286
287 // Per-connection state the server (not reducer) tracks: have we
288 // seen a Register yet? Reducer-level dedup is keyed by PID across
289 // all connections; this is the per-connection enforcement so a
290 // single connection can't send Ping before Register.
291 let mut registered_kind: Option<ClientKind> = None;
292 let mut registered_pid: Option<u32> = None;
293 // Connection ID is server-allocated (not state-allocated) and
294 // exists only for log correlation; the reducer-allocated
295 // client_id (returned in Registered) is the wire-visible one.
296 let conn_id = NEXT_CONN_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
297
298 // Phase B.8 — fanout task: subscribe to the server-wide event
299 // bus and write each event to THIS connection's pipe. Started
300 // before any commands are processed so a registered client can
301 // start receiving events from concurrent activity immediately.
302 // Aborted when the connection's read loop returns (below).
303 //
304 // CPD-2 design (round 4): events are written DIRECTLY to this
305 // connection's per-connection writer — NOT routed through
306 // `HostPipe`. HostPipe exists for *commands* (saga-issued
307 // launcher → host actions, which CPD-3 will wire). Events stay
308 // on the existing direct-write path because:
309 // 1. Wire format compat — host's parser expects raw `Event`
310 // JSON, not `HostFrame::Event` envelopes (codex P1 round 3).
311 // CPD-3 will update host's parser AND swap the fanout to
312 // HostFrame envelopes together.
313 // 2. No need for HostPipe's pending-buffer / 30s-timeout
314 // semantics on events: events are broadcast-driven, every
315 // subscriber gets them, and stale events post-reconnect
316 // would be wrong anyway.
317 // 3. Each per-connection fanout writes to its OWN writer —
318 // no global-writer race / stale-fanout issue (which is
319 // what `host_session_id` would have guarded against, but
320 // isn't needed when fanouts are connection-local).
321 let fanout_handle = {
322 let writer = Arc::clone(&writer);
323 let ctx = Arc::clone(&ctx);
324 let mut events_rx = ctx.events_tx.subscribe();
325 tokio::spawn(async move {
326 loop {
327 match events_rx.recv().await {
328 Ok(event) => {
329 // Identity is patched at the publisher (before
330 // log + bus). No need to re-patch here.
331 // Errors here mean the pipe is closed; the
332 // read loop will detect EOF on the next
333 // iteration. Swallow + continue to drain
334 // any remaining buffered events so we don't
335 // accidentally hold onto channel slots.
336 if send_event_shared(&writer, event).await.is_err() {
337 return;
338 }
339 }
340 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
341 // Slow client missed `n` events. Phase D's
342 // GetSnapshot resync covers this case
343 // properly; for now the client has to
344 // reconnect to recover. Log so operators
345 // can see when this happens.
346 crate::log(&format!(
347 "[ipc] conn_id={} lagged event bus, missed {} events",
348 conn_id, n
349 ));
350 }
351 Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
352 }
353 }
354 })
355 };
356
357 loop {
358 let line = match lines.next_line().await {
359 Ok(Some(l)) => l,
360 Ok(None) => {
361 crate::log(&format!(
362 "[ipc] connection conn_id={} closed (kind={:?}, pid={:?})",
363 conn_id, registered_kind, registered_pid
364 ));
365 // CPD-2 — clear HostPipe writer if this was the host
366 // connection so subsequent saga commands buffer
367 // (up to 64) until the host reconnects (or fail at
368 // the 30s timeout). Idempotent if not the host.
369 if matches!(registered_kind, Some(ClientKind::Host)) {
370 ctx.host_pipe.clear_writer().await;
371 }
372 // Phase E.1b — synthetic Goodbye on ungraceful
373 // disconnect so the reducer marks the PID Exited;
374 // otherwise reconnect-from-same-PID hits
375 // AlreadyRegistered. (codex P1 #610.)
376 dispatch_synthetic_goodbye(&ctx, conn_id, registered_pid).await;
377 fanout_handle.abort();
378 return;
379 }
380 Err(e) => {
381 crate::log(&format!(
382 "[ipc] read error conn_id={}: {}",
383 conn_id, e
384 ));
385 if matches!(registered_kind, Some(ClientKind::Host)) {
386 ctx.host_pipe.clear_writer().await;
387 }
388 dispatch_synthetic_goodbye(&ctx, conn_id, registered_pid).await;
389 fanout_handle.abort();
390 return;
391 }
392 };
393
394 if line.trim().is_empty() {
395 continue;
396 }
397
398 let cmd = match serde_json::from_str::<Command>(&line) {
399 Ok(c) => c,
400 Err(e) => {
401 // Phase E.1b — parse errors are connection-private
402 // (sent only to the offender, not broadcast, not
403 // appended to the event log). Don't bump the global
404 // event_version: other subscribers would see version
405 // gaps and treat them as missed events. Use 0 as a
406 // sentinel for "not part of the ordered stream."
407 // (codex P2 #610.)
408 let _ = send_event_shared(
409 &writer,
410 Event::Error {
411 code: ErrorCode::InvalidCommand,
412 message: format!("parse failed: {}", e),
413 fatal: false,
414 version: 0,
415 },
416 )
417 .await;
418 continue;
419 }
420 };
421
422 // Per-connection invariants: enforce here so reducer doesn't
423 // need to know about connection identity. Honor the `fatal`
424 // bit — Ping-before-Register is non-fatal (clients can
425 // recover by sending Register next), Goodbye-before-Register
426 // is fatal (can't recover from a closed-by-them connection).
427 // (reagent P1 + codex P1 PR #574 round-1.)
428 if let Some(reply) = enforce_register_first(&cmd, ®istered_kind).await {
429 let close = matches!(&reply, Event::Error { fatal: true, .. });
430 let _ = send_event_shared(&writer, reply).await;
431 if close {
432 fanout_handle.abort();
433 return;
434 }
435 continue;
436 }
437 if let Command::Register { .. } = &cmd {
438 if registered_kind.is_some() {
439 // Phase E.1b — connection-private error; same
440 // version-sentinel rationale as parse-error path
441 // above. (codex P2 #610.)
442 let _ = send_event_shared(
443 &writer,
444 Event::Error {
445 code: ErrorCode::AlreadyRegistered,
446 message: "Register sent twice on the same connection".into(),
447 fatal: false,
448 version: 0,
449 },
450 )
451 .await;
452 continue;
453 }
454 }
455
456 // Track local registration before dispatch so we can update
457 // our per-connection state if the reducer accepts it. The
458 // reducer's PID-uniqueness check might reject the Register;
459 // we re-check the events for that case below.
460 let pre_register = if let Command::Register { kind, pid, .. } = &cmd {
461 Some((*kind, *pid))
462 } else {
463 None
464 };
465
466 // Phase D.3 — `GetEvents { since }` is handled here, not in
467 // the reducer. The reducer is pure (no I/O); querying the
468 // event log is a non-mutating read against the in-memory
469 // ring + disk fallback.
470 //
471 // Phase E.1b — the reply (`Event::EventList`) is sent
472 // DIRECTLY to the requesting connection, NOT broadcast on
473 // the shared bus. EventList is request/response, not a
474 // state transition; broadcasting it would force every
475 // subscriber to process foreign replay payloads
476 // (potentially treating them as their own catch-up data,
477 // duplicating state application). (codex P1 #610.)
478 if let Command::GetEvents { since } = &cmd {
479 // Phase E.1b — read the current version WITHOUT bumping
480 // (codex P2 #610). EventList is connection-private; the
481 // version it carries is the "as-of" point for the
482 // requester's next resync, not a new state-transition
483 // marker. Bumping would create a global gap that other
484 // subscribers see as missed events.
485 let v = {
486 let state = ctx.state.lock().await;
487 state.event_version
488 };
489 let replay = ctx.event_log.events_since(*since);
490 // Don't log truncation as an error — it's a valid
491 // "subscriber missed events that have already been
492 // evicted" signal; the subscriber's resync logic
493 // handles it (re-fetch a fresh snapshot).
494 if ctx.event_log.replay_truncated(*since) {
495 crate::log(&format!(
496 "[ipc] conn_id={} GetEvents since={} truncated (oldest retained event > since+1)",
497 conn_id, since
498 ));
499 }
500 let _ = send_event_shared(
501 &writer,
502 Event::EventList {
503 events: replay,
504 version: v,
505 },
506 )
507 .await;
508 continue;
509 }
510
511 // Dispatch through the reducer. Mutex held briefly — compute
512 // the timestamp BEFORE acquiring so syscalls + string
513 // formatting don't show up in lock-hold time. (gemini
514 // MEDIUM @ server.rs:259, PR #574 round-1.)
515 let now_rfc3339 = chrono::Utc::now().to_rfc3339();
516 // Phase B.9.1 — monotonic ms since launcher start. Used by
517 // the WRR arm for per-window observability ages.
518 // `LAUNCHER_START_INSTANT` is a once-init `Instant`; the
519 // first request seeds it, subsequent ones read its delta.
520 let now_ms = launcher_start_ms();
521 let events = {
522 let mut state = ctx.state.lock().await;
523 let rctx = reducer::Ctx {
524 now_rfc3339,
525 conn_id,
526 registered_pid,
527 now_ms,
528 };
529 reducer::update(&mut state, cmd.clone(), &rctx)
530 };
531
532 // If the reducer accepted the Register (no AlreadyRegistered
533 // error in the output), commit the local connection state.
534 if let Some((kind, pid)) = pre_register {
535 let rejected = events
536 .iter()
537 .any(|e| matches!(e, Event::Error { code: ErrorCode::AlreadyRegistered, .. }));
538 if !rejected {
539 registered_kind = Some(kind);
540 registered_pid = Some(pid);
541 // CPD-2 — host registration: install this connection's
542 // writer into the launcher's HostPipe wrapper and flip
543 // the fanout task to route through it. Subsequent
544 // events for this connection traverse
545 // `HostPipe::send_event` (HostFrame::Event envelope +
546 // pending-buffer-on-disconnect semantics) instead of
547 // the legacy direct write. Drains any frames buffered
548 // since the prior host disconnect (FIFO).
549 if kind == ClientKind::Host {
550 // Install the host's writer half into HostPipe so
551 // saga-issued commands (CPD-3+) can be transmitted
552 // and so any pending Command frames buffered during
553 // a prior host disconnect drain in FIFO order.
554 // Returns a session_id we don't need today (events
555 // bypass HostPipe — see fanout task above), but the
556 // counter is in place for future code that does.
557 let session = ctx
558 .host_pipe
559 .set_writer(std::sync::Arc::clone(&writer))
560 .await;
561 crate::log(&format!(
562 "[ipc] conn_id={} host registered (session={}) — HostPipe writer installed",
563 conn_id, session
564 ));
565 }
566 }
567 }
568
569 // Phase B.8 — publish reducer events on the broadcast bus
570 // instead of writing them directly to this connection. The
571 // per-connection fanout task (spawned above) subscribes and
572 // writes them to its own pipe — including this connection,
573 // which sees its own events back. Drift events still log at
574 // the launcher level so operators see them regardless of
575 // subscriber wiring. (codex P1 PR #605.)
576 let goodbye = matches!(cmd, Command::Goodbye);
577 for event in events {
578 if let Event::DriftDetected {
579 kind,
580 host_count,
581 mirror_count,
582 ..
583 } = &event
584 {
585 crate::log(&format!(
586 "[ipc] DRIFT {:?}: host={} mirror={} (conn_id={})",
587 kind, host_count, mirror_count, conn_id
588 ));
589 }
590 if let Event::HwndDriftDetected {
591 kind,
592 label,
593 hwnd,
594 detail,
595 severity,
596 ..
597 } = &event
598 {
599 crate::log(&format!(
600 "[ipc] WRR-DRIFT [{:?}] {:?} label={:?} hwnd={:?}: {} (conn_id={})",
601 severity, kind, label, hwnd, detail, conn_id
602 ));
603 }
604 // Phase E.1a (codex P2 #608) — patch sentinel identity
605 // BEFORE appending to the log. Reducer emits
606 // `Event::Registered { launcher_pid: 0, launcher_version: "" }`
607 // because it doesn't know the launcher's identity; the
608 // server fills it in. Pre-fix, the patch happened only at
609 // per-connection write, so `GetEvents` replay returned
610 // stored sentinels, inconsistent with live broadcast.
611 let event = patch_launcher_identity(event, &ctx);
612
613 // Phase D.2 — append to the in-memory ring BEFORE
614 // broadcasting so a connection's GetEvents query that
615 // races a just-published event sees consistent
616 // results. Disk persistence (separate task) is best-
617 // effort and may lag. Snapshot / EventList variants
618 // are NOT appended — they're meta-events about the
619 // event stream itself; including them would create
620 // recursive replay (an EventList containing EventLists
621 // is meaningless). Errors are also skipped — they're
622 // per-client diagnostics, not state transitions.
623 if !matches!(event, Event::Snapshot { .. } | Event::EventList { .. } | Event::Error { .. }) {
624 ctx.event_log.append(event.clone());
625 }
626 // Send may fail when no receivers exist (e.g., during
627 // shutdown). That's fine — events are advisory in that
628 // window and the per-connection fanout tasks own retry
629 // semantics via subscribe().
630 let _ = ctx.events_tx.send(event);
631 }
632 if goodbye {
633 crate::log(&format!(
634 "[ipc] goodbye from conn_id={} kind={:?} pid={:?}",
635 conn_id, registered_kind, registered_pid
636 ));
637 // CPD-2 — clear HostPipe writer on graceful goodbye too
638 // so a host that re-registers via a fresh connection can
639 // re-install cleanly. Idempotent if not host.
640 if matches!(registered_kind, Some(ClientKind::Host)) {
641 ctx.host_pipe.clear_writer().await;
642 }
643 fanout_handle.abort();
644 return;
645 }
646 }
647}
648
649/// Per-connection counter for log-correlation IDs (NOT the wire
650/// client_id — that comes from the reducer). Allocated even for
651/// pre-Register failures so log lines can be correlated.
652static NEXT_CONN_ID: std::sync::atomic::AtomicU64 =
653 std::sync::atomic::AtomicU64::new(1);
654
655/// Phase E.1b — synthetic Goodbye dispatch for ungraceful disconnects
656/// (EOF / read error before the client sent an explicit Goodbye).
657/// Without this, the reducer's process record stays Running and a
658/// reconnect from the same live PID hits AlreadyRegistered.
659/// (codex P1 #610.)
660// (gate removed — platform-neutral body, accessible from cfg(unix) too — A1.1)
661async fn dispatch_synthetic_goodbye(
662 ctx: &Arc<ServerCtx>,
663 conn_id: u64,
664 registered_pid: Option<u32>,
665) {
666 let Some(pid) = registered_pid else {
667 return;
668 };
669 let now_rfc3339 = chrono::Utc::now().to_rfc3339();
670 let now_ms = launcher_start_ms();
671 let events = {
672 let mut state = ctx.state.lock().await;
673 let rctx = reducer::Ctx {
674 now_rfc3339,
675 conn_id,
676 registered_pid: Some(pid),
677 now_ms,
678 };
679 reducer::update(&mut state, Command::Goodbye, &rctx)
680 };
681 for event in events {
682 let event = patch_launcher_identity(event, ctx);
683 if !matches!(
684 event,
685 Event::Snapshot { .. } | Event::EventList { .. } | Event::Error { .. }
686 ) {
687 ctx.event_log.append(event.clone());
688 }
689 let _ = ctx.events_tx.send(event);
690 }
691}
692
693/// Phase B.9.1 — milliseconds since the launcher's IPC server
694/// started (first call seeds the epoch). Used as the monotonic
695/// clock for WRR observability timestamps in `reducer::Ctx::now_ms`.
696/// Distinct from `chrono::Utc::now()` because the WRR arm wants
697/// elapsed time, not wall clock — and we don't want clock-skew
698/// jitter (NTP adjustment, DST) showing up as drift.
699fn launcher_start_ms() -> u64 {
700 static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
701 let start = START.get_or_init(std::time::Instant::now);
702 start.elapsed().as_millis() as u64
703}
704
705/// Enforce the "first message must be Register" invariant. Returns
706/// `Some(Event::Error)` if the command violates the contract; the
707/// caller sends it and closes the connection.
708// (gate removed — platform-neutral body, accessible from cfg(unix) too — A1.1)
709async fn enforce_register_first(
710 cmd: &Command,
711 registered_kind: &Option<ClientKind>,
712) -> Option<Event> {
713 // F.7 cleanup audit: prior signature accepted `ctx: &Arc<ServerCtx>`
714 // for symmetry with neighboring helpers, but no body site read it.
715 // Dropped to silence the unused-variable warning without an allow.
716 if registered_kind.is_some() {
717 return None;
718 }
719 let (msg, fatal) = match cmd {
720 Command::Register { .. } => return None,
721 Command::Ping { .. } => ("Ping before Register".to_string(), false),
722 Command::Goodbye => ("Goodbye before Register".to_string(), true),
723 Command::ReportWindowOpened { .. } => {
724 ("ReportWindowOpened before Register".to_string(), true)
725 }
726 Command::ReportWindowClosed { .. } => {
727 ("ReportWindowClosed before Register".to_string(), true)
728 }
729 Command::ReportPoolWindowAdded { .. } => {
730 ("ReportPoolWindowAdded before Register".to_string(), true)
731 }
732 Command::ReportPoolWindowRemoved { .. } => {
733 ("ReportPoolWindowRemoved before Register".to_string(), true)
734 }
735 // Phase F.5 — host-only report; gate matches the other pool-
736 // mirror reports above.
737 Command::ReportPoolWindowPromoted { .. } => {
738 ("ReportPoolWindowPromoted before Register".to_string(), true)
739 }
740 // Phase F.5 — `SpawnPoolWindow` is a launcher→host direction
741 // command. Sent to the launcher pipe before Register: same
742 // soft-error treatment as the srv-pipe misroutes (the client
743 // can recover by registering or routing correctly).
744 Command::SpawnPoolWindow { .. } => {
745 ("SpawnPoolWindow is a launcher→host command; sent to launcher pipe by mistake".to_string(), false)
746 }
747 // Phase F.6 — host-only reports; same fatal-before-Register
748 // treatment as the other window-mirror reports above.
749 Command::ReportPanesReaped { .. } => {
750 ("ReportPanesReaped before Register".to_string(), true)
751 }
752 Command::ReportPoolDrainDecision { .. } => {
753 ("ReportPoolDrainDecision before Register".to_string(), true)
754 }
755 // Phase F.6 — launcher→host direction commands. Same
756 // soft-error treatment as `SpawnPoolWindow` above.
757 Command::ReapPanes { .. } => {
758 ("ReapPanes is a launcher→host command; sent to launcher pipe by mistake".to_string(), false)
759 }
760 Command::DrainPoolIfLast { .. } => {
761 ("DrainPoolIfLast is a launcher→host command; sent to launcher pipe by mistake".to_string(), false)
762 }
763 // Phase CPD-1 — host-only saga-action-failed report. Same
764 // fatal-before-Register treatment as the other Report*
765 // commands above.
766 Command::ReportSagaActionFailed { .. } => {
767 ("ReportSagaActionFailed before Register".to_string(), true)
768 }
769 Command::ReportHostCounts { .. } => {
770 ("ReportHostCounts before Register".to_string(), true)
771 }
772 Command::ReportHostPoolCount { .. } => {
773 ("ReportHostPoolCount before Register".to_string(), true)
774 }
775 Command::ReportBackendWindowIdRegistered { .. } => {
776 (
777 "ReportBackendWindowIdRegistered before Register".to_string(),
778 true,
779 )
780 }
781 Command::ReportBackendWindowIdUnregistered { .. } => {
782 (
783 "ReportBackendWindowIdUnregistered before Register".to_string(),
784 true,
785 )
786 }
787 // Phase B.9.1 (WRR) — host-only Win32 reality reports.
788 Command::ReportHwndOpened { .. } => {
789 ("ReportHwndOpened before Register".to_string(), true)
790 }
791 Command::ReportHwndDestroyed { .. } => {
792 ("ReportHwndDestroyed before Register".to_string(), true)
793 }
794 Command::ReportHwndVisibilityChanged { .. } => {
795 (
796 "ReportHwndVisibilityChanged before Register".to_string(),
797 true,
798 )
799 }
800 Command::ReportHwndForegroundChanged { .. } => {
801 (
802 "ReportHwndForegroundChanged before Register".to_string(),
803 true,
804 )
805 }
806 Command::ReportHwndIconicChanged { .. } => {
807 ("ReportHwndIconicChanged before Register".to_string(), true)
808 }
809 Command::ReportHwndPositionChanged { .. } => {
810 (
811 "ReportHwndPositionChanged before Register".to_string(),
812 true,
813 )
814 }
815 Command::ReportMonitorTopologyChanged { .. } => {
816 (
817 "ReportMonitorTopologyChanged before Register".to_string(),
818 true,
819 )
820 }
821 // Phase D.1 — GetSnapshot before Register is non-fatal: any
822 // sane diagnostic client can fix it by retrying with Register
823 // first. (Same Ping-before-Register precedent — soft error.)
824 Command::GetSnapshot => ("GetSnapshot before Register".to_string(), false),
825 // Phase D.3 — GetEvents before Register: same non-fatal
826 // semantics as GetSnapshot.
827 Command::GetEvents { .. } => ("GetEvents before Register".to_string(), false),
828 // Phase E.1b — GetSrvSnapshot is a srv-pipe command; if a
829 // client sends it to the launcher pipe by mistake, soft
830 // error: not the launcher's command.
831 Command::GetSrvSnapshot => (
832 "GetSrvSnapshot is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
833 false,
834 ),
835 // Phase E.2 — srv-pipe commands sent to launcher pipe by
836 // mistake. Soft error — clients can recover by routing to
837 // the right pipe.
838 Command::CreateWorkspace { .. } => (
839 "CreateWorkspace is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
840 false,
841 ),
842 Command::DeleteWorkspace { .. } => (
843 "DeleteWorkspace is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
844 false,
845 ),
846 // Phase E.2b — Tab arms are also srv-pipe commands.
847 Command::CreateTab { .. } => (
848 "CreateTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
849 false,
850 ),
851 Command::DeleteTab { .. } => (
852 "DeleteTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
853 false,
854 ),
855 Command::SetActiveTab { .. } => (
856 "SetActiveTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
857 false,
858 ),
859 Command::ReorderTab { .. } => (
860 "ReorderTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
861 false,
862 ),
863 // Phase E.5 — window↔workspace mapping commands are srv-pipe.
864 Command::CreateWindow { .. } => (
865 "CreateWindow is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
866 false,
867 ),
868 Command::CloseWindowInternal { .. } => (
869 "CloseWindowInternal is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
870 false,
871 ),
872 Command::SwitchWorkspace { .. } => (
873 "SwitchWorkspace is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
874 false,
875 ),
876 // Phase E.5.3 — atomic single-step domain commands are srv-pipe.
877 Command::ReorderTabsBulk { .. } => (
878 "ReorderTabsBulk is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
879 false,
880 ),
881 Command::RenameWorkspace { .. } => (
882 "RenameWorkspace is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
883 false,
884 ),
885 Command::RenameTab { .. } => (
886 "RenameTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
887 false,
888 ),
889 Command::UpdateWorkspaceMeta { .. } => (
890 "UpdateWorkspaceMeta is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
891 false,
892 ),
893 Command::UpdateTabMeta { .. } => (
894 "UpdateTabMeta is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
895 false,
896 ),
897 Command::UpdateBlockMeta { .. } => (
898 "UpdateBlockMeta is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
899 false,
900 ),
901 // Phase E.3 — Block arms are also srv-pipe commands.
902 Command::CreateBlock { .. } => (
903 "CreateBlock is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
904 false,
905 ),
906 Command::DeleteBlock { .. } => (
907 "DeleteBlock is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
908 false,
909 ),
910 // Phase E.5.5 — saga-driven move commands are srv-pipe.
911 Command::MoveTab { .. } => (
912 "MoveTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
913 false,
914 ),
915 Command::MoveBlock { .. } => (
916 "MoveBlock is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
917 false,
918 ),
919 // Phase E.4 (Option A) — layout focused/magnified setters are srv-pipe.
920 Command::SetFocusedNode { .. } => (
921 "SetFocusedNode is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
922 false,
923 ),
924 Command::SetMagnifiedNode { .. } => (
925 "SetMagnifiedNode is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
926 false,
927 ),
928 // Phase E.4.B — all layout-tree commands are srv-pipe only.
929 Command::LayoutInsertNode { .. }
930 | Command::LayoutInsertNodeAtIndex { .. }
931 | Command::LayoutDeleteNode { .. }
932 | Command::LayoutMoveNode { .. }
933 | Command::LayoutSwapNodes { .. }
934 | Command::LayoutResizeNodes { .. }
935 | Command::LayoutReplaceNode { .. }
936 | Command::LayoutSplitHorizontal { .. }
937 | Command::LayoutSplitVertical { .. }
938 | Command::LayoutClear { .. }
939 | Command::LayoutSetTree { .. } => (
940 "Layout command is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
941 false,
942 ),
943 Command::UpdateWindowMeta { .. } => (
944 "UpdateWindowMeta is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
945 false,
946 ),
947 };
948 // Phase E.1b — connection-private error; sentinel version=0
949 // (codex P2 #610). See parse-error path for rationale.
950 let v = 0;
951 Some(Event::Error {
952 code: ErrorCode::NotRegistered,
953 message: msg,
954 fatal,
955 version: v,
956 })
957}
958
959/// Patch `launcher_pid` + `launcher_version` into `Event::Registered`.
960/// The reducer leaves these as sentinels (it can't read env without
961/// breaking determinism) — the server fills them in here, just
962/// before serializing to the wire.
963fn patch_launcher_identity(event: Event, ctx: &Arc<ServerCtx>) -> Event {
964 if let Event::Registered {
965 client_id, version, ..
966 } = event
967 {
968 Event::Registered {
969 client_id,
970 launcher_pid: ctx.launcher_pid,
971 launcher_version: ctx.launcher_version.clone(),
972 version,
973 }
974 } else {
975 event
976 }
977}
978
979/// Serialize an Event as one JSON line + `\n` and write atomically
980/// (under the per-connection writer mutex). Returns Err if the
981/// connection died mid-write.
982///
983/// CPD-2 — generalized from `Arc<Mutex<WriteHalf<NamedPipeServer>>>`
984/// to `crate::host_pipe::SharedWriter` so the launcher's IPC server
985/// + the host_pipe wrapper share one writer-handle representation.
986/// Wire shape is unchanged: a non-host client sees a raw `Event` JSON
987/// line (legacy schema), a host client sees a `HostFrame::Event`
988/// envelope only when the frame goes through `HostPipe::send_event`.
989/// Connection-private error replies in this file still emit raw
990/// `Event` JSON to preserve backwards compat with existing host
991/// versions that haven't adopted the envelope yet — CPD-1 lands the
992/// host-side schema migration.
993// (gate removed — platform-neutral body, accessible from cfg(unix) too — A1.1)
994async fn send_event_shared(
995 writer: &crate::host_pipe::SharedWriter,
996 event: Event,
997) -> std::io::Result<()> {
998 let mut buf = serde_json::to_vec(&event).map_err(|e| {
999 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
1000 })?;
1001 buf.push(b'\n');
1002 let mut w = writer.lock().await;
1003 w.write_all(&buf).await?;
1004 w.flush().await?;
1005 Ok(())
1006}