Skip to main content

gaze_proxy/
daemon.rs

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
212/// Rebuilds the daemon's configured surface as `gaze proxy serve` flags.
213///
214/// The persisted [`DaemonConfig`] is the single source of truth for a detached
215/// proxy: `start` and `restart` write it, and the child is handed nothing but
216/// what this derives from it, so `serve` keeps exactly one policy input whether
217/// it runs in the foreground or as the daemon child. The exhaustive
218/// destructuring is load-bearing — a new `DaemonConfig` field stops compiling
219/// here instead of silently never reaching the daemon, which is how `--policy`,
220/// `--rulepack`, and the adapter upstreams went missing (solo todo #2965).
221fn 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    // The policy path travels, not its resolved contents: the child loads it
242    // through the same sequence as `gaze clean`, and a path that has moved since
243    // `start` fails the child closed instead of quietly demoting the chokepoint
244    // to the bundled floor. Serializing resolved inputs into the config would
245    // make that file a second, staleable copy of the policy.
246    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
335/// Confirms the spawned child is still alive after a short probe window and
336/// returns its pid.
337///
338/// The child resolves the configured policy, so a fail-closed load error
339/// surfaces as an immediate exit. `kill(pid, 0)` cannot see that: an unreaped
340/// child is a zombie and still answers as alive, which reported a dead daemon as
341/// started (solo todo #2965). `try_wait` reaps and reports the real status
342/// within the same 250ms budget, returning early on failure.
343///
344/// A failure slower than the probe window still reports success; that daemon is
345/// dead rather than serving unprotected, and `gaze proxy status` shows it.
346fn 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::DaemonAlreadyRunning {
405            pid: status.pid,
406            pidfile: options.paths.pidfile,
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    if !matches!(stop_result, Ok(()) | Err(ProxyError::DaemonNotRunning)) {
418        cleanup_stale(&options.paths)?;
419    }
420    start(options)
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
504/// Owns both the descriptor lock and the persistent `<pidfile>.lock` lock.
505///
506/// Identity uses `same_file::Handle`, comparing `(dev, ino)` on Unix and the
507/// corresponding file identity on Windows.
508///
509/// The sidecar is never unlinked: startup takes it before creating the pidfile,
510/// foreground publication holds it through the write, and every removal (stale,
511/// failed spawn, early exit, stop) goes through this guard. Thus no competing
512/// lifecycle path can replace the pathname between identity check and unlink.
513/// Lock order is always sidecar then pidfile; contention never blocks a handoff.
514/// External programs must not remove the sidecar or mutate the pidfile while
515/// Gaze is using it. The inode lock alone cannot protect a pathname after unlink.
516struct PidfileGuard {
517    file: File,
518    _namespace: NamespaceLock,
519}
520
521struct NamespaceLock(File);
522
523impl Drop for NamespaceLock {
524    fn drop(&mut self) {
525        // A concurrent process spawn can briefly inherit a duplicate descriptor.
526        // Closing ours alone would leave its shared flock alive until exec.
527        let _ = FileExt::unlock(&self.0);
528    }
529}
530
531impl Drop for PidfileGuard {
532    fn drop(&mut self) {
533        // A failed startup retains a cloned descriptor as its identity witness.
534        // Explicit unlock releases the shared lock before the child takes over.
535        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                // A second cleaner finishes after this cleaner opened the old inode.
798                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        // Reach unlink, rather than failing to create the sidecar directory entry.
843        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        // Restore permissions before asserting so the fixture always cleans up.
854        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        // Deterministic equivalent of a descriptor inherited during fork/exec.
901        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            // Exercise startup and another cleaner in the check-to-unlink window.
918            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        // Both a startup holding its lock and an unlocked, still-empty handoff
934        // belong to the replacement. Contents alone cannot establish ownership.
935        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        // Reaping is deterministic: the probe observes an already-exited child.
1007        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    // solo todo #2965: every field the adopter can configure has to reach the
1102    // detached child, because the child's argv is all it ever sees.
1103    #[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    /// A locked empty pidfile is a startup in progress.  `cleanup_stale` must
1163    /// not unlink it: doing so lets a concurrent caller create a fresh inode at
1164    /// the same path, acquire its own lock, and proceed as a second daemon.
1165    #[test]
1166    fn cleanup_stale_preserves_locked_startup_pidfile() {
1167        let dir = tempfile::tempdir().unwrap();
1168        let paths = temp_paths(&dir);
1169
1170        // Simulate a startup that has created and locked an empty pidfile but
1171        // has not yet published a PID.
1172        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` sees an unparseable (empty) file, but must recognise
1183        // the exclusive lock and leave the inode intact.
1184        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        // A second concurrent start attempt must not be able to re-lock the
1192        // file, proving the original startup still owns it.
1193        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        // Releasing the lock explicitly (mimics startup completing or aborting).
1206        drop(lock);
1207    }
1208
1209    /// `cleanup_stale` must propagate real unlink failures rather than
1210    /// swallowing them and reporting success.  This regression covers the
1211    /// deterministic path described in the r2 review: make the parent directory
1212    /// read/execute-only so that `unlink` (which requires write permission on
1213    /// the parent) fails with EACCES, then verify that the file is still present
1214    /// and that `cleanup_stale` returns an I/O error.
1215    ///
1216    /// Skipped when running as root because root bypasses DAC permission checks.
1217    #[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            // root ignores DAC permissions; the test would be a false pass.
1224            return;
1225        }
1226
1227        let dir = tempfile::tempdir().unwrap();
1228        let paths = temp_paths(&dir);
1229
1230        // Create an empty (stale) pidfile.
1231        std::fs::write(&paths.pidfile, "").unwrap();
1232        // Reach unlink, rather than failing to create the sidecar directory entry.
1233        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        // Revoke write permission on the parent directory so unlink fails.
1241        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        // Restore permissions before asserting so the TempDir drop can clean up.
1248        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}