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