Skip to main content

pitchfork_cli/ipc/
mod.rs

1use crate::Result;
2use crate::daemon::{Daemon, RunOptions};
3use crate::daemon_id::DaemonId;
4use crate::env;
5#[cfg(unix)]
6use crate::error::IpcError;
7use interprocess::local_socket::Name;
8#[cfg(unix)]
9use interprocess::local_socket::{GenericFilePath, ToFsName};
10#[cfg(windows)]
11use interprocess::local_socket::{GenericNamespaced, ToNsName};
12use miette::{Context, IntoDiagnostic};
13#[cfg(unix)]
14use std::path::Path;
15use std::path::PathBuf;
16
17pub(crate) mod batch;
18pub(crate) mod client;
19pub(crate) mod server;
20
21// #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, strum::Display, strum::EnumIs)]
22// pub enum IpcMessage {
23//     Connect(String),
24//     ConnectOK,
25//     Run(String, Vec<String>),
26//     Stop(String),
27//     DaemonAlreadyRunning(String),
28//     DaemonAlreadyStopped(String),
29//     DaemonStart(Daemon),
30//     DaemonStop { name: String },
31//     DaemonFailed { name: String, error: String },
32//     Response(String),
33// }
34
35#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, strum::Display, strum::EnumIs)]
36#[allow(clippy::large_enum_variant)]
37pub enum IpcRequest {
38    Connect,
39    /// Versioned connect handshake (v2): client sends its version so the supervisor can
40    /// detect mismatches. Kept as a separate variant so the wire format of `Connect`
41    /// (unit variant) stays unchanged for backward compatibility with older supervisors.
42    ConnectV2 {
43        version: String,
44    },
45    Clean,
46    Stop {
47        id: DaemonId,
48    },
49    GetActiveDaemons,
50    GetDisabledDaemons,
51    /// Shut the supervisor down gracefully, as on SIGTERM, for
52    /// `supervisor stop`. Answered with `ShuttingDown` once new starts are
53    /// frozen; an older supervisor answers `Error`, and is sent the signal.
54    Shutdown,
55    Run(RunOptions),
56    Enable {
57        id: DaemonId,
58    },
59    Disable {
60        id: DaemonId,
61    },
62    UpdateShellDir {
63        shell_pid: u32,
64        dir: PathBuf,
65    },
66    GetNotifications,
67    /// Notify the supervisor that the slug registry has changed (e.g. `proxy add/remove`).
68    /// The supervisor should re-read slugs and update mDNS records accordingly.
69    SyncMdns,
70    /// Notify the supervisor that settings have changed.
71    /// The supervisor should reload settings from config files.
72    ReloadConfig,
73    /// Enter or replace a project session for a host PID in a directory.
74    ProjectEnter {
75        pid: u32,
76        dir: PathBuf,
77    },
78    /// Leave a project session for a host PID in a directory.
79    ProjectLeave {
80        pid: u32,
81        dir: PathBuf,
82    },
83    /// List all tracked project sessions with live liveness status filled in
84    /// by the supervisor.
85    GetProjectSessions,
86    /// A daemon's log sink reporting a line the supervisor needs to act on:
87    /// one matching the readiness pattern, one that should fire the
88    /// `on_output` hook, or both.
89    ///
90    /// The sink owns the daemon's output stream, so it does the matching and
91    /// tells the supervisor rather than the other way around. Sent by
92    /// `pitchfork log-sink`, not by any user-facing command.
93    SinkOutputLine {
94        id: DaemonId,
95        /// Identifies the start attempt this sink belongs to, so a report from
96        /// a sink still draining a previous attempt cannot satisfy a retry.
97        token: u64,
98        /// Whether this line passed the `on_output` hook's filter and debounce.
99        /// A line reported only because it matched the readiness pattern must
100        /// not fire a hook that filters for something else.
101        fires_hook: bool,
102        line: String,
103    },
104    /// Ask the supervisor for the URL of the web UI, if it is running.
105    /// Reflects the actual bound address, not static config.
106    GetWebUrl,
107    /// Remove stopped daemon registrations matching the supplied filters.
108    /// Appended to preserve the wire indexes of existing variants.
109    CleanFiltered {
110        namespaces: Vec<String>,
111        daemons: Vec<DaemonId>,
112        prune: bool,
113    },
114    /// These daemons are being started explicitly: none of them is to be
115    /// stopped for inactivity from now on, even if the proxy started it.
116    /// Sent before the start itself, and answered once any idle stop already
117    /// under way for one of them has finished. Appended to preserve the wire
118    /// indexes of existing variants.
119    ClaimDaemons {
120        ids: Vec<DaemonId>,
121    },
122    /// The supervisor's records of these daemons, whatever their status.
123    /// Missing ids are left out. Appended to preserve the wire indexes of
124    /// existing variants.
125    GetDaemons {
126        ids: Vec<DaemonId>,
127    },
128    /// Invalid request (failed to deserialize)
129    #[serde(skip)]
130    Invalid {
131        error: String,
132    },
133}
134
135/// A snapshot of a single project session, returned by `GetProjectSessions`.
136///
137/// `liveness_title` is the title recorded at enter time. `alive` and
138/// `current_title` are filled in by the supervisor from its `PROCS` singleton
139/// so the client does not need process introspection.
140#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
141pub struct ProjectSessionInfo {
142    pub pid: u32,
143    pub directory: PathBuf,
144    #[serde(skip_serializing_if = "Option::is_none", default)]
145    pub liveness_title: Option<String>,
146    pub alive: bool,
147    #[serde(skip_serializing_if = "Option::is_none", default)]
148    pub current_title: Option<String>,
149}
150
151#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, strum::Display, strum::EnumIs)]
152pub enum IpcResponse {
153    Ok,
154    /// Successful connect handshake, includes supervisor version for mismatch detection
155    ConnectOk {
156        version: String,
157    },
158    Yes,
159    No,
160    Error(String),
161    Notifications(Vec<(log::LevelFilter, String)>),
162    ActiveDaemons(Vec<Daemon>),
163    DisabledDaemons(Vec<DaemonId>),
164    /// The supervisor is stopping its daemons and will exit; `budget_ms` is
165    /// how long that may take, after which it may be killed.
166    ShuttingDown {
167        budget_ms: u64,
168    },
169    DaemonAlreadyRunning,
170    DaemonStart {
171        daemon: Daemon,
172    },
173    DaemonFailed {
174        error: String,
175    },
176    /// Port conflict detected with detailed process information
177    PortConflict {
178        port: u16,
179        process: String,
180        pid: u32,
181        /// The local address whose bind found the port taken. Absent from a
182        /// supervisor that predates it.
183        #[serde(default, skip_serializing_if = "Option::is_none")]
184        address: Option<String>,
185    },
186    /// No available ports found after exhausting auto-bump attempts
187    NoAvailablePort {
188        start_port: u16,
189        attempts: u32,
190    },
191    DaemonReady {
192        daemon: Daemon,
193    },
194    DaemonFailedWithCode {
195        exit_code: Option<i32>,
196        /// Ports resolved by the failed attempt, so in-process retry hooks
197        /// observe the attempt's actual (post-bump) ports.
198        #[serde(default)]
199        resolved_ports: Vec<u16>,
200    },
201    /// Process was not running but had a PID record (unexpected exit)
202    DaemonWasNotRunning,
203    /// mDNS sync completed (or was a no-op if LAN mode is disabled)
204    MdnsSynced,
205    /// Settings reloaded from config files
206    ConfigReloaded,
207    /// URL of the running web UI, or `None` if the web UI is not running
208    WebUrl {
209        url: Option<String>,
210    },
211    /// Failed to kill the process (still running)
212    DaemonStopFailed {
213        error: String,
214    },
215    /// Daemon exists but is not running (no PID)
216    DaemonNotRunning,
217    DaemonNotFound,
218    /// Snapshot of all project sessions (response to `GetProjectSessions`).
219    ProjectSessions(Vec<ProjectSessionInfo>),
220    /// Number of daemon registrations removed by `CleanFiltered`.
221    Cleaned {
222        count: u64,
223    },
224    /// Records answering `GetDaemons`.
225    Daemons(Vec<Daemon>),
226}
227
228/// Bytes a socket path may occupy in `sockaddr_un.sun_path` on this platform
229/// (108 on Linux, 104 on macOS and the BSDs). Matches the check `interprocess`
230/// makes when it builds the address, so an over-long path is caught here first.
231#[cfg(unix)]
232const SOCKET_PATH_CAPACITY: usize = {
233    // SAFETY: `sockaddr_un` is plain data, for which all-zero bytes are valid.
234    let sun = unsafe { std::mem::zeroed::<libc::sockaddr_un>() };
235    sun.sun_path.len()
236};
237
238/// Fail with an actionable error when a socket path cannot fit in `sun_path`.
239///
240/// Without this the connection attempt fails, five retries later, with
241/// `local socket name length exceeds capacity of sun_path of sockaddr_un`,
242/// which names neither the path nor what to change.
243#[cfg(unix)]
244fn check_socket_path(path: &Path, capacity: usize) -> Result<()> {
245    use std::os::unix::ffi::OsStrExt;
246    let len = path.as_os_str().as_bytes().len();
247    if len <= capacity {
248        return Ok(());
249    }
250    let over = len - capacity;
251    let help = format!(
252        "the socket is {path} and is {over} byte(s) over the limit.\n\
253         Set PITCHFORK_STATE_DIR (or XDG_STATE_HOME) to a shorter directory, for example \
254         PITCHFORK_STATE_DIR=/tmp/pitchfork, so that <state dir>/sock/main.sock is at most \
255         {capacity} bytes.",
256        path = path.display()
257    );
258    Err(IpcError::SocketPathTooLong {
259        path: path.to_path_buf(),
260        len,
261        limit: capacity,
262        help,
263    }
264    .into())
265}
266
267fn fs_name(name: &str) -> Result<Name<'_>> {
268    // Unix: use a filesystem path for the AF_UNIX socket.
269    #[cfg(unix)]
270    {
271        let path = env::IPC_SOCK_DIR.join(name).with_extension("sock");
272        check_socket_path(&path, SOCKET_PATH_CAPACITY)?;
273        let fs_name = path.to_fs_name::<GenericFilePath>().into_diagnostic()?;
274        Ok(fs_name)
275    }
276    // Windows: named pipes use a flat namespace (\\.\pipe\<name>) that
277    // cannot contain path separators. Derive a unique pipe name from the
278    // state directory to preserve test isolation when multiple supervisors
279    // run concurrently with different PITCHFORK_STATE_DIR values.
280    //
281    // Use a hash of the state directory path rather than character replacement
282    // to guarantee injectivity: `C:\a.b` and `C:\a\b` would both flatten to
283    // `C--a-b` with the old approach, causing pipe name collisions.
284    #[cfg(windows)]
285    {
286        let state_dir = env::PITCHFORK_STATE_DIR.to_string_lossy();
287        // FNV-1a hash: deterministic, stable across Rust versions.
288        // DefaultHasher's algorithm is not guaranteed stable, which would
289        // break IPC if the CLI and supervisor were ever compiled with
290        // different toolchains.
291        let mut hash: u64 = 0xcbf29ce484222325;
292        for byte in state_dir.bytes() {
293            hash ^= byte as u64;
294            hash = hash.wrapping_mul(0x100000001b3);
295        }
296        let pipe_name = format!("pitchfork-{hash:016x}-{name}");
297        pipe_name
298            .to_ns_name::<GenericNamespaced>()
299            .into_diagnostic()
300    }
301}
302
303/// Human-readable location of the supervisor's IPC endpoint, for messages.
304pub(crate) fn socket_display() -> String {
305    #[cfg(unix)]
306    {
307        env::IPC_SOCK_MAIN.display().to_string()
308    }
309    #[cfg(windows)]
310    {
311        "the supervisor named pipe".to_string()
312    }
313}
314
315/// Whether a supervisor is accepting connections on the IPC socket right now.
316///
317/// This is the ground truth for "is a supervisor running": the state-file
318/// record can be lost or rewritten while the supervisor keeps serving, and a
319/// supervisor started on that basis would take the socket over and leave the
320/// original running but unreachable. A stale socket file left by a crashed
321/// supervisor refuses connections, so it does not count.
322pub(crate) async fn supervisor_listening() -> bool {
323    use interprocess::local_socket::traits::tokio::Stream as _;
324    let Ok(name) = fs_name("main") else {
325        return false;
326    };
327    let connect = interprocess::local_socket::tokio::Stream::connect(name);
328    // A live supervisor accepts at once (the kernel queues the connection);
329    // the timeout only guards against a platform where connecting blocks.
330    match tokio::time::timeout(std::time::Duration::from_secs(1), connect).await {
331        Ok(Ok(_)) => true,
332        Ok(Err(err)) => {
333            trace!("no supervisor listening on the IPC socket: {err}");
334            false
335        }
336        Err(_) => {
337            debug!("timed out probing the IPC socket; treating it as not listening");
338            false
339        }
340    }
341}
342
343/// Encode an IPC message as JSON.
344///
345/// Messages are framed by a trailing NUL byte, which JSON text never contains.
346/// A binary encoding would, so it cannot use this framing.
347fn serialize<T: serde::Serialize>(msg: &T) -> Result<Vec<u8>> {
348    serde_json::to_vec(msg)
349        .into_diagnostic()
350        .wrap_err("failed to serialize IPC message as JSON")
351}
352
353fn deserialize<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T> {
354    let mut bytes = bytes.to_vec();
355    bytes.pop();
356    let preview = std::str::from_utf8(&bytes).unwrap_or("<binary>");
357    trace!("msg: {preview:?}");
358    serde_json::from_slice(&bytes)
359        .into_diagnostic()
360        .wrap_err("failed to deserialize IPC JSON response")
361}
362
363#[cfg(test)]
364mod tests {
365    #[test]
366    fn port_conflict_from_an_older_supervisor_has_no_address() {
367        let rsp: IpcResponse =
368            serde_json::from_str(r#"{"PortConflict":{"port":3000,"process":"node","pid":42}}"#)
369                .unwrap();
370        assert!(matches!(
371            rsp,
372            IpcResponse::PortConflict {
373                port: 3000,
374                pid: 42,
375                address: None,
376                ..
377            }
378        ));
379    }
380
381    use super::*;
382
383    #[cfg(unix)]
384    #[test]
385    fn socket_path_over_sun_path_capacity_names_path_length_and_fix() {
386        let fits = PathBuf::from(format!("/{}", "a".repeat(SOCKET_PATH_CAPACITY - 1)));
387        assert_eq!(fits.as_os_str().len(), SOCKET_PATH_CAPACITY);
388        assert!(check_socket_path(&fits, SOCKET_PATH_CAPACITY).is_ok());
389
390        let long = PathBuf::from(format!(
391            "/{}/sock/main.sock",
392            "a".repeat(SOCKET_PATH_CAPACITY)
393        ));
394        let err = check_socket_path(&long, SOCKET_PATH_CAPACITY).unwrap_err();
395        let len = long.as_os_str().len();
396        let message = err.to_string();
397        assert!(message.contains(&format!("{len} bytes")), "{message}");
398        assert!(
399            message.contains(&format!("allows {SOCKET_PATH_CAPACITY}")),
400            "{message}"
401        );
402        let help = err.help().expect("help text").to_string();
403        assert!(help.contains(&long.display().to_string()), "{help}");
404        assert!(help.contains("PITCHFORK_STATE_DIR"), "{help}");
405        assert!(help.contains("XDG_STATE_HOME"), "{help}");
406        assert!(
407            matches!(
408                err.downcast_ref::<IpcError>(),
409                Some(IpcError::SocketPathTooLong { len: l, limit, .. })
410                    if *l == len && *limit == SOCKET_PATH_CAPACITY
411            ),
412            "{err:?}"
413        );
414    }
415
416    #[cfg(unix)]
417    #[test]
418    fn socket_path_capacity_matches_the_platform() {
419        // 108 on Linux, 104 on macOS and the BSDs.
420        assert!((100..=108).contains(&SOCKET_PATH_CAPACITY));
421    }
422
423    #[test]
424    fn filtered_clean_ipc_round_trips() {
425        let request = IpcRequest::CleanFiltered {
426            namespaces: vec!["worktree".to_string()],
427            daemons: vec![DaemonId::new("worktree", "api")],
428            prune: true,
429        };
430        let mut bytes = serialize(&request).unwrap();
431        bytes.push(b'\n');
432        let decoded: IpcRequest = deserialize(&bytes).unwrap();
433        match decoded {
434            IpcRequest::CleanFiltered {
435                namespaces,
436                daemons,
437                prune,
438            } => {
439                assert_eq!(namespaces, ["worktree"]);
440                assert_eq!(daemons, [DaemonId::new("worktree", "api")]);
441                assert!(prune);
442            }
443            other => panic!("unexpected request: {other:?}"),
444        }
445
446        let mut bytes = serialize(&IpcResponse::Cleaned { count: 3 }).unwrap();
447        bytes.push(b'\n');
448        let decoded: IpcResponse = deserialize(&bytes).unwrap();
449        assert!(matches!(decoded, IpcResponse::Cleaned { count: 3 }));
450    }
451
452    fn round_trip<T: serde::Serialize + serde::de::DeserializeOwned>(value: &T) -> T {
453        let mut bytes = serialize(value).unwrap();
454        bytes.push(b'\n');
455        deserialize(&bytes).unwrap()
456    }
457
458    #[test]
459    fn claim_daemons_ipc_round_trips() {
460        let ids = vec![DaemonId::new("proj", "api"), DaemonId::new("proj", "db")];
461        match round_trip(&IpcRequest::ClaimDaemons { ids: ids.clone() }) {
462            IpcRequest::ClaimDaemons { ids: decoded } => assert_eq!(decoded, ids),
463            other => panic!("unexpected request: {other:?}"),
464        }
465    }
466
467    /// The idle-shutdown ownership crosses IPC in both structs, alongside
468    /// fields that are skipped when empty.
469    #[test]
470    fn proxy_idle_timeout_survives_the_ipc_encoding() {
471        let daemon = Daemon {
472            id: DaemonId::new("proj", "api"),
473            proxy_idle_timeout_ms: Some(900_000),
474            ..Default::default()
475        };
476        match round_trip(&IpcResponse::ActiveDaemons(vec![daemon])) {
477            IpcResponse::ActiveDaemons(daemons) => {
478                assert_eq!(daemons[0].proxy_idle_timeout_ms, Some(900_000));
479                assert!(!daemons[0].oneshot);
480            }
481            other => panic!("unexpected response: {other:?}"),
482        }
483
484        let opts = RunOptions {
485            id: DaemonId::new("proj", "api"),
486            proxy_idle_timeout_ms: Some(900_000),
487            ..Default::default()
488        };
489        match round_trip(&IpcRequest::Run(opts)) {
490            IpcRequest::Run(opts) => assert_eq!(opts.proxy_idle_timeout_ms, Some(900_000)),
491            other => panic!("unexpected request: {other:?}"),
492        }
493    }
494
495    /// A start replaces the saved record only when it says so: a request from
496    /// an older client, which does not send the flag, keeps merging.
497    #[test]
498    fn replaces_saved_record_is_opt_in_across_ipc() {
499        let full = RunOptions {
500            id: DaemonId::new("proj", "api"),
501            replaces_saved_record: true,
502            ..Default::default()
503        };
504        match round_trip(&IpcRequest::Run(full)) {
505            IpcRequest::Run(opts) => assert!(opts.replaces_saved_record),
506            other => panic!("unexpected request: {other:?}"),
507        }
508
509        let older_client = RunOptions {
510            id: DaemonId::new("proj", "api"),
511            ..Default::default()
512        };
513        let bytes = serialize(&IpcRequest::Run(older_client)).unwrap();
514        assert!(!String::from_utf8_lossy(&bytes).contains("replaces_saved_record"));
515        match round_trip(&IpcRequest::Run(RunOptions {
516            id: DaemonId::new("proj", "api"),
517            ..Default::default()
518        })) {
519            IpcRequest::Run(opts) => assert!(!opts.replaces_saved_record),
520            other => panic!("unexpected request: {other:?}"),
521        }
522    }
523}