1use std::ffi::OsString;
2use std::fs::{self, File, OpenOptions};
3use std::io::{Read, Seek};
4use std::net::SocketAddr;
5use std::path::{Path, PathBuf};
6use std::process::{Child, Command, Stdio};
7use std::time::{Duration, Instant};
8
9use chrono::{DateTime, Utc};
10use fs2::FileExt;
11use serde::{Deserialize, Serialize};
12use url::Url;
13
14use crate::error::ProxyError;
15
16#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub struct DaemonPaths {
19 pub pidfile: PathBuf,
20 pub config: PathBuf,
21 pub log_file: PathBuf,
22 pub stderr_file: PathBuf,
23}
24
25impl DaemonPaths {
26 pub fn new(pidfile: PathBuf, config: PathBuf, log_file: PathBuf, stderr_file: PathBuf) -> Self {
27 Self {
28 pidfile,
29 config,
30 log_file,
31 stderr_file,
32 }
33 }
34
35 pub fn resolve() -> Result<Self, ProxyError> {
36 let data_dir = dirs::data_local_dir()
37 .ok_or_else(|| ProxyError::DaemonConfig {
38 detail: "could not resolve local data directory".to_string(),
39 })?
40 .join("gaze");
41 let config_dir = dirs::config_dir()
42 .ok_or_else(|| ProxyError::DaemonConfig {
43 detail: "could not resolve config directory".to_string(),
44 })?
45 .join("gaze");
46 let log_dir = if cfg!(target_os = "macos") {
47 dirs::home_dir()
48 .ok_or_else(|| ProxyError::DaemonConfig {
49 detail: "could not resolve home directory".to_string(),
50 })?
51 .join("Library/Logs/gaze")
52 } else {
53 data_dir.join("Logs")
54 };
55 Ok(Self {
56 pidfile: data_dir.join("proxy.pid"),
57 config: config_dir.join("proxy.toml"),
58 log_file: log_dir.join("proxy.log"),
59 stderr_file: log_dir.join("proxy-stderr.log"),
60 })
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[non_exhaustive]
66pub struct DaemonConfig {
67 pub bind: SocketAddr,
68 pub session_ttl: String,
69 pub policy: Option<PathBuf>,
70 pub rulepack: Option<String>,
71 pub adapters: AdapterConfig,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[non_exhaustive]
76pub struct AdapterConfig {
77 pub openai: ProviderConfig,
78 pub anthropic: ProviderConfig,
79 pub gemini: ProviderConfig,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[non_exhaustive]
84pub struct ProviderConfig {
85 pub upstream: Url,
86}
87
88impl ProviderConfig {
89 pub fn new(upstream: Url) -> Self {
90 Self { upstream }
91 }
92}
93
94impl AdapterConfig {
95 pub fn new(openai: Url, anthropic: Url, gemini: Url) -> Self {
96 Self {
97 openai: ProviderConfig::new(openai),
98 anthropic: ProviderConfig::new(anthropic),
99 gemini: ProviderConfig::new(gemini),
100 }
101 }
102}
103
104impl Default for AdapterConfig {
105 fn default() -> Self {
106 Self {
107 openai: ProviderConfig {
108 upstream: Url::parse("https://api.openai.com").expect("static url"),
109 },
110 anthropic: ProviderConfig {
111 upstream: Url::parse("https://api.anthropic.com").expect("static url"),
112 },
113 gemini: ProviderConfig {
114 upstream: Url::parse("https://generativelanguage.googleapis.com")
115 .expect("static url"),
116 },
117 }
118 }
119}
120
121impl Default for DaemonConfig {
122 fn default() -> Self {
123 Self {
124 bind: "127.0.0.1:8787".parse().expect("static bind addr"),
125 session_ttl: "30m".to_string(),
126 policy: None,
127 rulepack: Some("core".to_string()),
128 adapters: AdapterConfig::default(),
129 }
130 }
131}
132
133#[derive(Debug, Clone)]
134#[non_exhaustive]
135pub struct StartOptions {
136 pub paths: DaemonPaths,
137 pub config: DaemonConfig,
138 pub extra_args: Vec<String>,
139}
140
141impl StartOptions {
142 pub fn new(paths: DaemonPaths, config: DaemonConfig) -> Self {
143 Self {
144 paths,
145 config,
146 extra_args: Vec::new(),
147 }
148 }
149
150 pub fn with_extra_args(mut self, extra_args: Vec<String>) -> Self {
151 self.extra_args = extra_args;
152 self
153 }
154}
155
156#[derive(Debug, Clone)]
157#[non_exhaustive]
158pub struct StopOptions {
159 pub paths: DaemonPaths,
160 pub timeout: Duration,
161 pub force: bool,
162}
163
164impl StopOptions {
165 pub fn new(paths: DaemonPaths, timeout: Duration, force: bool) -> Self {
166 Self {
167 paths,
168 timeout,
169 force,
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
175#[non_exhaustive]
176pub struct DaemonStatus {
177 pub pid: u32,
178 pub bind: Option<String>,
179 pub started_at: Option<String>,
180 pub running: bool,
181}
182
183pub fn read_or_default_config(paths: &DaemonPaths) -> Result<DaemonConfig, ProxyError> {
184 if !paths.config.exists() {
185 return Ok(DaemonConfig::default());
186 }
187 let text = fs::read_to_string(&paths.config).map_err(|source| ProxyError::DaemonIo {
188 path: paths.config.clone(),
189 source,
190 })?;
191 toml::from_str(&text).map_err(|err| ProxyError::DaemonConfig {
192 detail: err.to_string(),
193 })
194}
195
196pub fn write_config(paths: &DaemonPaths, config: &DaemonConfig) -> Result<(), ProxyError> {
197 if let Some(parent) = paths.config.parent() {
198 fs::create_dir_all(parent).map_err(|source| ProxyError::DaemonIo {
199 path: parent.to_path_buf(),
200 source,
201 })?;
202 }
203 let text = toml::to_string_pretty(config).map_err(|err| ProxyError::DaemonConfig {
204 detail: err.to_string(),
205 })?;
206 fs::write(&paths.config, text).map_err(|source| ProxyError::DaemonIo {
207 path: paths.config.clone(),
208 source,
209 })
210}
211
212fn serve_args(config: &DaemonConfig) -> Vec<OsString> {
222 let DaemonConfig {
223 bind,
224 session_ttl,
225 policy,
226 rulepack,
227 adapters,
228 } = config;
229 let AdapterConfig {
230 openai,
231 anthropic,
232 gemini,
233 } = adapters;
234
235 let mut args: Vec<OsString> = vec![
236 "--bind".into(),
237 bind.to_string().into(),
238 "--session-ttl".into(),
239 session_ttl.into(),
240 ];
241 if let Some(policy) = policy {
247 args.push("--policy".into());
248 args.push(policy.into());
249 }
250 if let Some(rulepack) = rulepack {
251 args.push("--rulepack".into());
252 args.push(rulepack.into());
253 }
254 for (flag, ProviderConfig { upstream }) in [
255 ("--upstream-openai", openai),
256 ("--upstream-anthropic", anthropic),
257 ("--upstream-gemini", gemini),
258 ] {
259 args.push(flag.into());
260 args.push(upstream.to_string().into());
261 }
262 args
263}
264
265pub fn start(options: StartOptions) -> Result<u32, ProxyError> {
266 cleanup_stale(&options.paths)?;
267 if let Some(status) = status(&options.paths)? {
268 if status.running {
269 return Err(ProxyError::DaemonAlreadyRunning {
270 pid: status.pid,
271 pidfile: options.paths.pidfile,
272 });
273 }
274 }
275 write_config(&options.paths, &options.config)?;
276 create_parent(&options.paths.pidfile)?;
277 create_parent(&options.paths.log_file)?;
278 let lock = lock_pidfile(&options.paths)?;
279 let owned_file = lock
280 .file
281 .try_clone()
282 .map_err(|source| daemon_io(&options.paths.pidfile, source))?;
283
284 let mut child = {
285 let spawn_result = (|| {
286 let stdout = OpenOptions::new()
287 .create(true)
288 .append(true)
289 .open(&options.paths.log_file)
290 .map_err(|source| ProxyError::DaemonIo {
291 path: options.paths.log_file.clone(),
292 source,
293 })?;
294 let stderr = OpenOptions::new()
295 .create(true)
296 .append(true)
297 .open(&options.paths.stderr_file)
298 .map_err(|source| ProxyError::DaemonIo {
299 path: options.paths.stderr_file.clone(),
300 source,
301 })?;
302 let mut command =
303 Command::new(
304 std::env::current_exe().map_err(|source| ProxyError::DaemonIo {
305 path: PathBuf::from("current_exe"),
306 source,
307 })?,
308 );
309 command
310 .args(["proxy", "serve", "--_foreground-daemon"])
311 .args(serve_args(&options.config))
312 .args(&options.extra_args)
313 .stdin(Stdio::null())
314 .stdout(Stdio::from(stdout))
315 .stderr(Stdio::from(stderr));
316 drop(lock);
317 command.spawn().map_err(|source| ProxyError::DaemonIo {
318 path: PathBuf::from("gaze proxy serve"),
319 source,
320 })
321 })();
322 match spawn_result {
323 Ok(child) => child,
324 Err(e) => {
325 #[cfg(test)]
326 tests::run_start_before_cleanup_hook();
327 cleanup_owned(&options.paths.pidfile, owned_file, CleanupRule::Empty)?;
328 return Err(e);
329 }
330 }
331 };
332 confirm_started(&mut child, &options.paths, owned_file)
333}
334
335fn confirm_started(
347 child: &mut Child,
348 paths: &DaemonPaths,
349 owned_file: File,
350) -> Result<u32, ProxyError> {
351 let deadline = Instant::now() + Duration::from_millis(250);
352 loop {
353 match child.try_wait() {
354 Ok(Some(status)) => {
355 cleanup_owned(&paths.pidfile, owned_file, CleanupRule::Stale)?;
356 return Err(ProxyError::DaemonExitedEarly {
357 code: status.code(),
358 stderr_file: paths.stderr_file.clone(),
359 });
360 }
361 Ok(None) if Instant::now() >= deadline => return Ok(child.id()),
362 Ok(None) => std::thread::sleep(Duration::from_millis(25)),
363 Err(source) => {
364 return Err(ProxyError::DaemonIo {
365 path: PathBuf::from("gaze proxy serve"),
366 source,
367 });
368 }
369 }
370 }
371}
372
373pub fn init_foreground_daemon(paths: &DaemonPaths, bind: SocketAddr) -> Result<(), ProxyError> {
374 create_parent(&paths.pidfile)?;
375 create_parent(&paths.log_file)?;
376 let _lock = lock_pidfile(paths)?;
377 write_pidfile(&paths.pidfile, std::process::id(), bind, Utc::now())
378}
379
380pub fn stop(options: StopOptions) -> Result<(), ProxyError> {
381 cleanup_stale(&options.paths)?;
382 let Some(status) = status(&options.paths)? else {
383 return Err(ProxyError::DaemonNotRunning);
384 };
385 let owned_file = open_pidfile(&options.paths.pidfile)?;
386 terminate(status.pid, false)?;
387 let started = Instant::now();
388 while started.elapsed() < options.timeout {
389 if !process_exists(status.pid) {
390 if let Some(file) = owned_file {
391 cleanup_owned(&options.paths.pidfile, file, CleanupRule::Pid(status.pid))?;
392 }
393 return Ok(());
394 }
395 std::thread::sleep(Duration::from_millis(100));
396 }
397 if options.force {
398 terminate(status.pid, true)?;
399 if let Some(file) = owned_file {
400 cleanup_owned(&options.paths.pidfile, file, CleanupRule::Pid(status.pid))?;
401 }
402 Ok(())
403 } else {
404 Err(ProxyError::DaemonStopTimeout {
405 pid: status.pid,
406 timeout: options.timeout,
407 })
408 }
409}
410
411pub fn restart(options: StartOptions, timeout: Duration, force: bool) -> Result<u32, ProxyError> {
412 let stop_result = stop(StopOptions {
413 paths: options.paths.clone(),
414 timeout,
415 force,
416 });
417 match stop_result {
418 Ok(()) | Err(ProxyError::DaemonNotRunning) => start(options),
419 Err(error) => Err(error),
420 }
421}
422
423pub fn status(paths: &DaemonPaths) -> Result<Option<DaemonStatus>, ProxyError> {
424 if !paths.pidfile.exists() {
425 return Ok(None);
426 }
427 let record = read_pidfile(&paths.pidfile)?;
428 Ok(Some(DaemonStatus {
429 running: process_exists(record.pid),
430 pid: record.pid,
431 bind: record.bind,
432 started_at: record.started_at,
433 }))
434}
435
436pub fn cleanup_stale(paths: &DaemonPaths) -> Result<(), ProxyError> {
437 let Some(file) = open_pidfile(&paths.pidfile)? else {
438 return Ok(());
439 };
440 #[cfg(test)]
441 tests::run_cleanup_after_open_hook();
442 cleanup_owned(&paths.pidfile, file, CleanupRule::Stale)?;
443 Ok(())
444}
445
446fn daemon_io(path: &Path, source: std::io::Error) -> ProxyError {
447 ProxyError::DaemonIo {
448 path: path.to_path_buf(),
449 source,
450 }
451}
452
453fn open_pidfile(path: &Path) -> Result<Option<File>, ProxyError> {
454 match OpenOptions::new().read(true).write(true).open(path) {
455 Ok(file) => Ok(Some(file)),
456 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
457 Err(source) => Err(daemon_io(path, source)),
458 }
459}
460
461fn lock_contended(error: &std::io::Error) -> bool {
462 error.kind() == std::io::ErrorKind::WouldBlock
463 || error.raw_os_error() == fs2::lock_contended_error().raw_os_error()
464}
465
466fn try_pidfile_lock(file: &File) -> std::io::Result<()> {
467 #[cfg(test)]
468 if let Some(error) = tests::take_lock_error() {
469 return Err(error);
470 }
471 file.try_lock_exclusive()
472}
473
474#[derive(Debug, PartialEq, Eq)]
475enum CleanupOutcome {
476 Removed,
477 Missing,
478 Replaced,
479 Busy,
480 Kept,
481}
482
483#[derive(Clone, Copy)]
484enum CleanupRule {
485 Empty,
486 Stale,
487 Pid(u32),
488}
489
490impl CleanupRule {
491 fn permits(self, contents: &str) -> bool {
492 let pid = contents
493 .lines()
494 .next()
495 .and_then(|line| line.parse::<u32>().ok());
496 match self {
497 Self::Empty => contents.trim().is_empty(),
498 Self::Stale => pid.is_none_or(|pid| !process_exists(pid)),
499 Self::Pid(expected) => pid == Some(expected),
500 }
501 }
502}
503
504struct PidfileGuard {
517 file: File,
518 _namespace: NamespaceLock,
519}
520
521struct NamespaceLock(File);
522
523impl Drop for NamespaceLock {
524 fn drop(&mut self) {
525 let _ = FileExt::unlock(&self.0);
528 }
529}
530
531impl Drop for PidfileGuard {
532 fn drop(&mut self) {
533 let _ = FileExt::unlock(&self.file);
536 }
537}
538
539impl PidfileGuard {
540 fn namespace(path: &Path) -> std::io::Result<NamespaceLock> {
541 let mut name = path.as_os_str().to_os_string();
542 name.push(".lock");
543 let file = OpenOptions::new()
544 .create(true)
545 .truncate(false)
546 .read(true)
547 .write(true)
548 .open(PathBuf::from(name))?;
549 file.try_lock_exclusive()?;
550 Ok(NamespaceLock(file))
551 }
552
553 fn remove(mut self, path: &Path, rule: CleanupRule) -> Result<CleanupOutcome, ProxyError> {
554 let identity = same_file::Handle::from_file(
555 self.file
556 .try_clone()
557 .map_err(|source| daemon_io(path, source))?,
558 )
559 .map_err(|source| daemon_io(path, source))?;
560 let current = match same_file::Handle::from_path(path) {
561 Ok(current) => current,
562 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
563 return Ok(CleanupOutcome::Missing)
564 }
565 Err(source) => return Err(daemon_io(path, source)),
566 };
567 if identity != current {
568 tracing::debug!("pidfile cleanup skipped: owned inode was replaced");
569 return Ok(CleanupOutcome::Replaced);
570 }
571 self.file
572 .rewind()
573 .map_err(|source| daemon_io(path, source))?;
574 let mut contents = String::new();
575 self.file
576 .read_to_string(&mut contents)
577 .map_err(|source| daemon_io(path, source))?;
578 if !rule.permits(&contents) {
579 return Ok(CleanupOutcome::Kept);
580 }
581 #[cfg(test)]
582 tests::run_cleanup_before_unlink_hook();
583 match fs::remove_file(path) {
584 Ok(()) => Ok(CleanupOutcome::Removed),
585 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(CleanupOutcome::Missing),
586 Err(source) => Err(daemon_io(path, source)),
587 }
588 }
589}
590
591fn cleanup_owned(path: &Path, file: File, rule: CleanupRule) -> Result<CleanupOutcome, ProxyError> {
592 let namespace = match PidfileGuard::namespace(path) {
593 Ok(lock) => lock,
594 Err(e) if lock_contended(&e) => return Ok(CleanupOutcome::Busy),
595 Err(source) => return Err(daemon_io(path, source)),
596 };
597 match try_pidfile_lock(&file) {
598 Ok(()) => PidfileGuard {
599 file,
600 _namespace: namespace,
601 }
602 .remove(path, rule),
603 Err(e) if lock_contended(&e) => Ok(CleanupOutcome::Busy),
604 Err(source) => Err(daemon_io(path, source)),
605 }
606}
607
608pub fn logs(paths: &DaemonPaths, follow: bool) -> Result<(), ProxyError> {
609 if follow {
610 Command::new("tail")
611 .arg("-F")
612 .arg(&paths.log_file)
613 .status()
614 .map_err(|source| ProxyError::DaemonIo {
615 path: paths.log_file.clone(),
616 source,
617 })?;
618 } else if paths.log_file.exists() {
619 let text = fs::read_to_string(&paths.log_file).map_err(|source| ProxyError::DaemonIo {
620 path: paths.log_file.clone(),
621 source,
622 })?;
623 print!("{text}");
624 }
625 Ok(())
626}
627
628fn create_parent(path: &Path) -> Result<(), ProxyError> {
629 if let Some(parent) = path.parent() {
630 fs::create_dir_all(parent).map_err(|source| ProxyError::DaemonIo {
631 path: parent.to_path_buf(),
632 source,
633 })?;
634 }
635 Ok(())
636}
637
638fn lock_pidfile(paths: &DaemonPaths) -> Result<PidfileGuard, ProxyError> {
639 let namespace = PidfileGuard::namespace(&paths.pidfile)
640 .map_err(|source| daemon_io(&paths.pidfile, source))?;
641 let file = OpenOptions::new()
642 .create(true)
643 .truncate(false)
644 .read(true)
645 .write(true)
646 .open(&paths.pidfile)
647 .map_err(|source| daemon_io(&paths.pidfile, source))?;
648 try_pidfile_lock(&file).map_err(|source| daemon_io(&paths.pidfile, source))?;
649 Ok(PidfileGuard {
650 file,
651 _namespace: namespace,
652 })
653}
654
655fn write_pidfile(
656 path: &Path,
657 pid: u32,
658 bind: SocketAddr,
659 started_at: DateTime<Utc>,
660) -> Result<(), ProxyError> {
661 let contents = format!(
662 "{pid}\nbind={bind}\nstarted_at={}\n",
663 started_at.to_rfc3339()
664 );
665 if contents.len() > 200 {
666 return Err(ProxyError::DaemonConfig {
667 detail: "pidfile content exceeds 200 bytes".to_string(),
668 });
669 }
670 fs::write(path, contents).map_err(|source| ProxyError::DaemonIo {
671 path: path.to_path_buf(),
672 source,
673 })
674}
675
676struct PidRecord {
677 pid: u32,
678 bind: Option<String>,
679 started_at: Option<String>,
680}
681
682fn read_pidfile(path: &Path) -> Result<PidRecord, ProxyError> {
683 let mut text = String::new();
684 File::open(path)
685 .and_then(|mut file| file.read_to_string(&mut text))
686 .map_err(|source| ProxyError::DaemonIo {
687 path: path.to_path_buf(),
688 source,
689 })?;
690 let mut lines = text.lines();
691 let pid = lines
692 .next()
693 .ok_or_else(|| ProxyError::DaemonPidfileStale {
694 pidfile: path.to_path_buf(),
695 })?
696 .parse::<u32>()
697 .map_err(|_| ProxyError::DaemonPidfileStale {
698 pidfile: path.to_path_buf(),
699 })?;
700 let mut bind = None;
701 let mut started_at = None;
702 for line in lines {
703 if let Some(rest) = line.strip_prefix("bind=") {
704 bind = Some(rest.to_string());
705 } else if let Some(rest) = line.strip_prefix("started_at=") {
706 started_at = Some(rest.to_string());
707 }
708 }
709 Ok(PidRecord {
710 pid,
711 bind,
712 started_at,
713 })
714}
715
716#[cfg(unix)]
717fn process_exists(pid: u32) -> bool {
718 unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
719}
720
721#[cfg(not(unix))]
722fn process_exists(_pid: u32) -> bool {
723 false
724}
725
726#[cfg(unix)]
727fn terminate(pid: u32, force: bool) -> Result<(), ProxyError> {
728 let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
729 let rc = unsafe { libc::kill(pid as libc::pid_t, signal) };
730 if rc == 0 {
731 Ok(())
732 } else {
733 Err(ProxyError::DaemonNotRunning)
734 }
735}
736
737#[cfg(not(unix))]
738fn terminate(_pid: u32, _force: bool) -> Result<(), ProxyError> {
739 Err(ProxyError::DaemonConfig {
740 detail: "daemon stop is not implemented on this platform".to_string(),
741 })
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747
748 thread_local! {
749 static CLEANUP_AFTER_OPEN: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
750 std::cell::RefCell::new(None);
751 }
752
753 thread_local! {
754 static START_BEFORE_CLEANUP: std::cell::RefCell<Option<Box<dyn FnOnce()>>> = std::cell::RefCell::new(None);
755 static CLEANUP_BEFORE_UNLINK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> = std::cell::RefCell::new(None);
756 static LOCK_ERROR: std::cell::RefCell<Option<std::io::Error>> = const { std::cell::RefCell::new(None) };
757 }
758
759 pub(super) fn take_lock_error() -> Option<std::io::Error> {
760 LOCK_ERROR.with(|slot| slot.borrow_mut().take())
761 }
762
763 pub(super) fn run_start_before_cleanup_hook() {
764 let hook = START_BEFORE_CLEANUP.with(|slot| slot.borrow_mut().take());
765 if let Some(hook) = hook {
766 hook();
767 }
768 }
769
770 pub(super) fn run_cleanup_before_unlink_hook() {
771 let hook = CLEANUP_BEFORE_UNLINK.with(|slot| slot.borrow_mut().take());
772 if let Some(hook) = hook {
773 hook();
774 }
775 }
776
777 pub(super) fn run_cleanup_after_open_hook() {
778 let hook = CLEANUP_AFTER_OPEN.with(|slot| slot.borrow_mut().take());
779 if let Some(hook) = hook {
780 hook();
781 }
782 }
783
784 #[test]
785 #[cfg(unix)]
786 fn r3_cleanup_preserves_replacement_inode_after_open() {
787 use std::os::unix::fs::MetadataExt;
788 let dir = tempfile::tempdir().unwrap();
789 let paths = temp_paths(&dir);
790 fs::write(&paths.pidfile, "").unwrap();
791 let old_inode = fs::metadata(&paths.pidfile).unwrap().ino();
792 let replacement_lock = std::rc::Rc::new(std::cell::RefCell::new(None));
793 let held = replacement_lock.clone();
794 let concurrent_paths = paths.clone();
795 CLEANUP_AFTER_OPEN.with(|slot| {
796 *slot.borrow_mut() = Some(Box::new(move || {
797 cleanup_stale(&concurrent_paths).unwrap();
799 let lock = lock_pidfile(&concurrent_paths).unwrap();
800 write_pidfile(
801 &concurrent_paths.pidfile,
802 std::process::id(),
803 "127.0.0.1:8787".parse().unwrap(),
804 Utc::now(),
805 )
806 .unwrap();
807 assert_ne!(
808 fs::metadata(&concurrent_paths.pidfile).unwrap().ino(),
809 old_inode
810 );
811 *held.borrow_mut() = Some(lock);
812 }));
813 });
814 cleanup_stale(&paths).unwrap();
815 assert!(
816 paths.pidfile.exists(),
817 "cleanup unlinked the replacement startup's locked inode"
818 );
819 assert!(status(&paths).unwrap().unwrap().running);
820 drop(replacement_lock);
821 }
822
823 #[test]
824 fn w1_cleanup_preserves_locked_startup_pidfile() {
825 let dir = tempfile::tempdir().unwrap();
826 let paths = temp_paths(&dir);
827 let _lock = lock_pidfile(&paths).unwrap();
828 let _ = cleanup_stale(&paths);
829 assert!(
830 paths.pidfile.exists(),
831 "cleanup unlinked an actively locked startup pidfile"
832 );
833 }
834
835 #[cfg(unix)]
836 #[test]
837 fn r2_cleanup_stale_reports_unlink_failure() {
838 use std::os::unix::fs::PermissionsExt;
839 let dir = tempfile::tempdir().unwrap();
840 let paths = temp_paths(&dir);
841 std::fs::write(&paths.pidfile, "").unwrap();
842 drop(PidfileGuard::namespace(&paths.pidfile).unwrap());
844 let reached_unlink = std::rc::Rc::new(std::cell::Cell::new(false));
845 let reached = reached_unlink.clone();
846 CLEANUP_BEFORE_UNLINK.with(|slot| {
847 *slot.borrow_mut() = Some(Box::new(move || reached.set(true)));
848 });
849 let original_permissions = std::fs::metadata(dir.path()).unwrap().permissions();
850 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
851 let cleanup_result = cleanup_stale(&paths);
852 let remains = paths.pidfile.exists();
853 std::fs::set_permissions(dir.path(), original_permissions).unwrap();
855 assert!(
856 reached_unlink.get(),
857 "fixture must reach the unlink operation"
858 );
859 assert!(
860 remains,
861 "fixture must actually prevent unlink (run unprivileged)"
862 );
863 assert!(
864 matches!(cleanup_result, Err(ProxyError::DaemonIo { .. })),
865 "cleanup must report the failed unlink instead of success: {cleanup_result:?}"
866 );
867 }
868
869 #[test]
870 fn cleanup_reports_non_contention_lock_error() {
871 let dir = tempfile::tempdir().unwrap();
872 let paths = temp_paths(&dir);
873 fs::write(&paths.pidfile, "").unwrap();
874 LOCK_ERROR
875 .with(|slot| *slot.borrow_mut() = Some(std::io::Error::from_raw_os_error(libc::EIO)));
876 let error = cleanup_stale(&paths).unwrap_err();
877 assert!(
878 matches!(error, ProxyError::DaemonIo { source, .. } if source.raw_os_error() == Some(libc::EIO))
879 );
880 assert!(paths.pidfile.exists());
881 }
882
883 #[test]
884 fn cleanup_documented_lock_contention_is_benign() {
885 let dir = tempfile::tempdir().unwrap();
886 let paths = temp_paths(&dir);
887 fs::write(&paths.pidfile, "").unwrap();
888 LOCK_ERROR.with(|slot| *slot.borrow_mut() = Some(fs2::lock_contended_error()));
889 cleanup_stale(&paths).unwrap();
890 assert!(paths.pidfile.exists());
891 cleanup_stale(&paths).unwrap();
892 assert!(!paths.pidfile.exists());
893 }
894
895 #[test]
896 fn cleanup_releases_namespace_with_inherited_descriptor() {
897 let dir = tempfile::tempdir().unwrap();
898 let paths = temp_paths(&dir);
899 let lock = lock_pidfile(&paths).unwrap();
900 let inherited = lock._namespace.0.try_clone().unwrap();
902 drop(lock);
903 let next = lock_pidfile(&paths).unwrap();
904 drop(next);
905 drop(inherited);
906 cleanup_stale(&paths).unwrap();
907 assert!(!paths.pidfile.exists());
908 }
909
910 #[test]
911 fn cleanup_holds_namespace_through_unlink() {
912 let dir = tempfile::tempdir().unwrap();
913 let paths = temp_paths(&dir);
914 fs::write(&paths.pidfile, "").unwrap();
915 let concurrent_paths = paths.clone();
916 CLEANUP_BEFORE_UNLINK.with(|slot| *slot.borrow_mut() = Some(Box::new(move || {
917 assert!(matches!(PidfileGuard::namespace(&concurrent_paths.pidfile), Err(error) if lock_contended(&error)));
919 assert!(matches!(lock_pidfile(&concurrent_paths), Err(ProxyError::DaemonIo { source, .. }) if lock_contended(&source)));
920 let file = open_pidfile(&concurrent_paths.pidfile).unwrap().unwrap();
921 assert_eq!(cleanup_owned(&concurrent_paths.pidfile, file, CleanupRule::Stale).unwrap(), CleanupOutcome::Busy);
922 assert!(concurrent_paths.pidfile.exists());
923 })));
924 cleanup_stale(&paths).unwrap();
925 assert!(!paths.pidfile.exists());
926 let lock = lock_pidfile(&paths).unwrap();
927 drop(lock);
928 cleanup_stale(&paths).unwrap();
929 }
930
931 #[test]
932 fn cleanup_failed_start_preserves_replacement_inode() {
933 for hold_lock in [false, true] {
936 let dir = tempfile::tempdir().unwrap();
937 let paths = temp_paths(&dir);
938 fs::create_dir(&paths.stderr_file).unwrap();
939 let replacement = std::rc::Rc::new(std::cell::RefCell::new(None));
940 let held = replacement.clone();
941 let concurrent_paths = paths.clone();
942 START_BEFORE_CLEANUP.with(|slot| {
943 *slot.borrow_mut() = Some(Box::new(move || {
944 cleanup_stale(&concurrent_paths).unwrap();
945 let lock = lock_pidfile(&concurrent_paths).unwrap();
946 if hold_lock {
947 *held.borrow_mut() = Some(lock);
948 }
949 }))
950 });
951 assert!(matches!(
952 start(StartOptions::new(paths.clone(), DaemonConfig::default())),
953 Err(ProxyError::DaemonIo { .. })
954 ));
955 assert!(
956 paths.pidfile.exists(),
957 "failed start removed replacement (locked={hold_lock})"
958 );
959 assert_eq!(fs::read_to_string(&paths.pidfile).unwrap(), "");
960 }
961 }
962
963 #[test]
964 fn cleanup_failed_start_reports_lock_error() {
965 let dir = tempfile::tempdir().unwrap();
966 let paths = temp_paths(&dir);
967 fs::create_dir(&paths.stderr_file).unwrap();
968 START_BEFORE_CLEANUP.with(|slot| {
969 *slot.borrow_mut() = Some(Box::new(|| {
970 LOCK_ERROR.with(|slot| {
971 *slot.borrow_mut() = Some(std::io::Error::from_raw_os_error(libc::EIO))
972 });
973 }))
974 });
975 let error = start(StartOptions::new(paths.clone(), DaemonConfig::default())).unwrap_err();
976 assert!(
977 matches!(error, ProxyError::DaemonIo { source, .. } if source.raw_os_error() == Some(libc::EIO))
978 );
979 assert!(paths.pidfile.exists());
980 }
981
982 #[test]
983 fn cleanup_stop_preserves_changed_pid_on_owned_inode() {
984 let dir = tempfile::tempdir().unwrap();
985 let paths = temp_paths(&dir);
986 fs::write(&paths.pidfile, "999999").unwrap();
987 let original = open_pidfile(&paths.pidfile).unwrap().unwrap();
988 init_foreground_daemon(&paths, "127.0.0.1:8787".parse().unwrap()).unwrap();
989 assert_eq!(
990 cleanup_owned(&paths.pidfile, original, CleanupRule::Pid(999999)).unwrap(),
991 CleanupOutcome::Kept
992 );
993 assert!(status(&paths).unwrap().unwrap().running);
994 }
995
996 #[test]
997 fn cleanup_early_exit_preserves_replacement_inode() {
998 let dir = tempfile::tempdir().unwrap();
999 let paths = temp_paths(&dir);
1000 let lock = lock_pidfile(&paths).unwrap();
1001 let original = lock.file.try_clone().unwrap();
1002 drop(lock);
1003 cleanup_stale(&paths).unwrap();
1004 let replacement = lock_pidfile(&paths).unwrap();
1005 drop(replacement);
1006 let mut child = Command::new(std::env::current_exe().unwrap())
1008 .arg("--list")
1009 .stdout(Stdio::null())
1010 .spawn()
1011 .unwrap();
1012 child.wait().unwrap();
1013 assert!(matches!(
1014 confirm_started(&mut child, &paths, original),
1015 Err(ProxyError::DaemonExitedEarly { .. })
1016 ));
1017 assert!(paths.pidfile.exists());
1018 }
1019
1020 fn temp_paths(dir: &tempfile::TempDir) -> DaemonPaths {
1021 DaemonPaths {
1022 pidfile: dir.path().join("proxy.pid"),
1023 config: dir.path().join("proxy.toml"),
1024 log_file: dir.path().join("proxy.log"),
1025 stderr_file: dir.path().join("proxy-stderr.log"),
1026 }
1027 }
1028
1029 #[test]
1030 fn pidfile_round_trip_includes_bind_and_started_at() {
1031 let dir = tempfile::tempdir().unwrap();
1032 let paths = temp_paths(&dir);
1033 write_pidfile(
1034 &paths.pidfile,
1035 123,
1036 "127.0.0.1:8787".parse().unwrap(),
1037 DateTime::parse_from_rfc3339("2026-05-14T00:00:00Z")
1038 .unwrap()
1039 .with_timezone(&Utc),
1040 )
1041 .unwrap();
1042 let status = status(&paths).unwrap().unwrap();
1043 assert_eq!(status.pid, 123);
1044 assert_eq!(status.bind.as_deref(), Some("127.0.0.1:8787"));
1045 assert!(!status.running);
1046 }
1047
1048 #[test]
1049 fn cleanup_stale_unlinks_dead_pidfile() {
1050 let dir = tempfile::tempdir().unwrap();
1051 let paths = temp_paths(&dir);
1052 write_pidfile(
1053 &paths.pidfile,
1054 999_999,
1055 "127.0.0.1:8787".parse().unwrap(),
1056 Utc::now(),
1057 )
1058 .unwrap();
1059 cleanup_stale(&paths).unwrap();
1060 assert!(!paths.pidfile.exists());
1061 }
1062
1063 #[test]
1064 fn cleanup_stale_unlinks_empty_pidfile() {
1065 let dir = tempfile::tempdir().unwrap();
1066 let paths = temp_paths(&dir);
1067 std::fs::write(&paths.pidfile, "").unwrap();
1068
1069 cleanup_stale(&paths).unwrap();
1070 assert!(!paths.pidfile.exists());
1071 assert!(status(&paths).unwrap().is_none());
1072 }
1073
1074 #[test]
1075 fn cleanup_stale_leaves_running_pidfile_alone() {
1076 let dir = tempfile::tempdir().unwrap();
1077 let paths = temp_paths(&dir);
1078 init_foreground_daemon(&paths, "127.0.0.1:8787".parse().unwrap()).unwrap();
1079
1080 cleanup_stale(&paths).unwrap();
1081 assert!(
1082 paths.pidfile.exists(),
1083 "cleanup_stale must not unlink a live daemon pidfile"
1084 );
1085 }
1086
1087 #[test]
1088 fn cleanup_stale_propagates_non_stale_io_error() {
1089 let dir = tempfile::tempdir().unwrap();
1090 let paths = temp_paths(&dir);
1091 std::fs::create_dir(&paths.pidfile).unwrap();
1092
1093 let err = cleanup_stale(&paths).unwrap_err();
1094 assert!(matches!(err, ProxyError::DaemonIo { .. }));
1095 assert!(
1096 paths.pidfile.exists(),
1097 "cleanup_stale must not unlink for non-stale errors"
1098 );
1099 }
1100
1101 #[test]
1104 fn serve_args_forward_every_configured_field_to_the_daemon_child() {
1105 let config = DaemonConfig {
1106 policy: Some(PathBuf::from("/etc/gaze/prod.toml")),
1107 rulepack: Some("core-extended".to_string()),
1108 adapters: AdapterConfig::new(
1109 Url::parse("http://127.0.0.1:4001").unwrap(),
1110 Url::parse("http://127.0.0.1:4002").unwrap(),
1111 Url::parse("http://127.0.0.1:4003").unwrap(),
1112 ),
1113 ..Default::default()
1114 };
1115
1116 assert_eq!(
1117 serve_args(&config),
1118 [
1119 "--bind",
1120 "127.0.0.1:8787",
1121 "--session-ttl",
1122 "30m",
1123 "--policy",
1124 "/etc/gaze/prod.toml",
1125 "--rulepack",
1126 "core-extended",
1127 "--upstream-openai",
1128 "http://127.0.0.1:4001/",
1129 "--upstream-anthropic",
1130 "http://127.0.0.1:4002/",
1131 "--upstream-gemini",
1132 "http://127.0.0.1:4003/",
1133 ]
1134 .map(OsString::from)
1135 );
1136 }
1137
1138 #[test]
1139 fn serve_args_omit_policy_when_the_daemon_has_none() {
1140 let args = serve_args(&DaemonConfig::default());
1141
1142 assert!(!args.contains(&OsString::from("--policy")));
1143 assert!(args
1144 .windows(2)
1145 .any(|pair| pair == ["--rulepack", "core"].map(OsString::from)));
1146 }
1147
1148 #[test]
1149 fn config_round_trip() {
1150 let dir = tempfile::tempdir().unwrap();
1151 let paths = temp_paths(&dir);
1152 let config = DaemonConfig::default();
1153 write_config(&paths, &config).unwrap();
1154 let loaded = read_or_default_config(&paths).unwrap();
1155 assert_eq!(loaded.bind, config.bind);
1156 assert_eq!(
1157 loaded.adapters.openai.upstream,
1158 config.adapters.openai.upstream
1159 );
1160 }
1161
1162 #[test]
1166 fn cleanup_stale_preserves_locked_startup_pidfile() {
1167 let dir = tempfile::tempdir().unwrap();
1168 let paths = temp_paths(&dir);
1169
1170 let lock = OpenOptions::new()
1173 .create(true)
1174 .truncate(false)
1175 .read(true)
1176 .write(true)
1177 .open(&paths.pidfile)
1178 .unwrap();
1179 lock.try_lock_exclusive()
1180 .expect("should be able to lock a freshly created file");
1181
1182 cleanup_stale(&paths).unwrap();
1185
1186 assert!(
1187 paths.pidfile.exists(),
1188 "cleanup_stale must not unlink an actively locked startup pidfile"
1189 );
1190
1191 let second = OpenOptions::new()
1194 .create(true)
1195 .truncate(false)
1196 .read(true)
1197 .write(true)
1198 .open(&paths.pidfile)
1199 .unwrap();
1200 assert!(
1201 second.try_lock_exclusive().is_err(),
1202 "a second startup must not acquire the lock while the first holds it"
1203 );
1204
1205 drop(lock);
1207 }
1208
1209 #[test]
1218 #[cfg(unix)]
1219 fn cleanup_stale_reports_unlink_failure() {
1220 use std::os::unix::fs::PermissionsExt;
1221
1222 if unsafe { libc::getuid() } == 0 {
1223 return;
1225 }
1226
1227 let dir = tempfile::tempdir().unwrap();
1228 let paths = temp_paths(&dir);
1229
1230 std::fs::write(&paths.pidfile, "").unwrap();
1232 drop(PidfileGuard::namespace(&paths.pidfile).unwrap());
1234 let reached_unlink = std::rc::Rc::new(std::cell::Cell::new(false));
1235 let reached = reached_unlink.clone();
1236 CLEANUP_BEFORE_UNLINK.with(|slot| {
1237 *slot.borrow_mut() = Some(Box::new(move || reached.set(true)));
1238 });
1239
1240 let parent = paths.pidfile.parent().unwrap();
1242 let original = std::fs::metadata(parent).unwrap().permissions();
1243 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o555)).unwrap();
1244
1245 let result = cleanup_stale(&paths);
1246
1247 std::fs::set_permissions(parent, original).unwrap();
1249 assert!(
1250 reached_unlink.get(),
1251 "fixture must reach the unlink operation"
1252 );
1253
1254 assert!(
1255 result.is_err(),
1256 "cleanup_stale must propagate the unlink failure, not return Ok"
1257 );
1258 assert!(
1259 paths.pidfile.exists(),
1260 "the stale pidfile must remain when unlink failed"
1261 );
1262 }
1263}