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)]
36#[allow(clippy::large_enum_variant)]
37pub enum IpcRequest {
38 Connect,
39 ConnectV2 {
43 version: String,
44 },
45 Clean,
46 Stop {
47 id: DaemonId,
48 },
49 GetActiveDaemons,
50 GetDisabledDaemons,
51 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 SyncMdns,
70 ReloadConfig,
73 ProjectEnter {
75 pid: u32,
76 dir: PathBuf,
77 },
78 ProjectLeave {
80 pid: u32,
81 dir: PathBuf,
82 },
83 GetProjectSessions,
86 SinkOutputLine {
94 id: DaemonId,
95 token: u64,
98 fires_hook: bool,
102 line: String,
103 },
104 GetWebUrl,
107 CleanFiltered {
110 namespaces: Vec<String>,
111 daemons: Vec<DaemonId>,
112 prune: bool,
113 },
114 ClaimDaemons {
120 ids: Vec<DaemonId>,
121 },
122 GetDaemons {
126 ids: Vec<DaemonId>,
127 },
128 #[serde(skip)]
130 Invalid {
131 error: String,
132 },
133}
134
135#[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 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 ShuttingDown {
167 budget_ms: u64,
168 },
169 DaemonAlreadyRunning,
170 DaemonStart {
171 daemon: Daemon,
172 },
173 DaemonFailed {
174 error: String,
175 },
176 PortConflict {
178 port: u16,
179 process: String,
180 pid: u32,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
184 address: Option<String>,
185 },
186 NoAvailablePort {
188 start_port: u16,
189 attempts: u32,
190 },
191 DaemonReady {
192 daemon: Daemon,
193 },
194 DaemonFailedWithCode {
195 exit_code: Option<i32>,
196 #[serde(default)]
199 resolved_ports: Vec<u16>,
200 },
201 DaemonWasNotRunning,
203 MdnsSynced,
205 ConfigReloaded,
207 WebUrl {
209 url: Option<String>,
210 },
211 DaemonStopFailed {
213 error: String,
214 },
215 DaemonNotRunning,
217 DaemonNotFound,
218 ProjectSessions(Vec<ProjectSessionInfo>),
220 Cleaned {
222 count: u64,
223 },
224 Daemons(Vec<Daemon>),
226}
227
228#[cfg(unix)]
232const SOCKET_PATH_CAPACITY: usize = {
233 let sun = unsafe { std::mem::zeroed::<libc::sockaddr_un>() };
235 sun.sun_path.len()
236};
237
238#[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 #[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 #[cfg(windows)]
285 {
286 let state_dir = env::PITCHFORK_STATE_DIR.to_string_lossy();
287 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
303pub(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
315pub(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 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
343fn 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 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 #[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 #[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}