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 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 SyncMdns,
66 ReloadConfig,
69 ProjectEnter {
71 pid: u32,
72 dir: PathBuf,
73 },
74 ProjectLeave {
76 pid: u32,
77 dir: PathBuf,
78 },
79 GetProjectSessions,
82 SinkOutputLine {
90 id: DaemonId,
91 token: u64,
94 fires_hook: bool,
98 line: String,
99 },
100 GetWebUrl,
103 CleanFiltered {
106 namespaces: Vec<String>,
107 daemons: Vec<DaemonId>,
108 prune: bool,
109 },
110 ClaimDaemons {
116 ids: Vec<DaemonId>,
117 },
118 #[serde(skip)]
120 Invalid {
121 error: String,
122 },
123}
124
125#[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 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 PortConflict {
163 port: u16,
164 process: String,
165 pid: u32,
166 },
167 NoAvailablePort {
169 start_port: u16,
170 attempts: u32,
171 },
172 DaemonReady {
173 daemon: Daemon,
174 },
175 DaemonFailedWithCode {
176 exit_code: Option<i32>,
177 #[serde(default)]
180 resolved_ports: Vec<u16>,
181 },
182 DaemonWasNotRunning,
184 MdnsSynced,
186 ConfigReloaded,
188 WebUrl {
190 url: Option<String>,
191 },
192 DaemonStopFailed {
194 error: String,
195 },
196 DaemonNotRunning,
198 DaemonNotFound,
199 ProjectSessions(Vec<ProjectSessionInfo>),
201 Cleaned {
203 count: u64,
204 },
205}
206
207#[cfg(unix)]
211const SOCKET_PATH_CAPACITY: usize = {
212 let sun = unsafe { std::mem::zeroed::<libc::sockaddr_un>() };
214 sun.sun_path.len()
215};
216
217#[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 #[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 #[cfg(windows)]
264 {
265 let state_dir = env::PITCHFORK_STATE_DIR.to_string_lossy();
266 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
282pub(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
294pub(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 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
322fn 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 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 #[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}