Skip to main content

gaze_proxy/
daemon.rs

1use std::fs::{self, File, OpenOptions};
2use std::io::Read;
3use std::net::SocketAddr;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::time::{Duration, Instant};
7
8use chrono::{DateTime, Utc};
9use fs2::FileExt;
10use serde::{Deserialize, Serialize};
11use url::Url;
12
13use crate::error::ProxyError;
14
15#[derive(Debug, Clone)]
16#[non_exhaustive]
17pub struct DaemonPaths {
18    pub pidfile: PathBuf,
19    pub config: PathBuf,
20    pub log_file: PathBuf,
21    pub stderr_file: PathBuf,
22}
23
24impl DaemonPaths {
25    pub fn new(pidfile: PathBuf, config: PathBuf, log_file: PathBuf, stderr_file: PathBuf) -> Self {
26        Self {
27            pidfile,
28            config,
29            log_file,
30            stderr_file,
31        }
32    }
33
34    pub fn resolve() -> Result<Self, ProxyError> {
35        let data_dir = dirs::data_local_dir()
36            .ok_or_else(|| ProxyError::DaemonConfig {
37                detail: "could not resolve local data directory".to_string(),
38            })?
39            .join("gaze");
40        let config_dir = dirs::config_dir()
41            .ok_or_else(|| ProxyError::DaemonConfig {
42                detail: "could not resolve config directory".to_string(),
43            })?
44            .join("gaze");
45        let log_dir = if cfg!(target_os = "macos") {
46            dirs::home_dir()
47                .ok_or_else(|| ProxyError::DaemonConfig {
48                    detail: "could not resolve home directory".to_string(),
49                })?
50                .join("Library/Logs/gaze")
51        } else {
52            data_dir.join("Logs")
53        };
54        Ok(Self {
55            pidfile: data_dir.join("proxy.pid"),
56            config: config_dir.join("proxy.toml"),
57            log_file: log_dir.join("proxy.log"),
58            stderr_file: log_dir.join("proxy-stderr.log"),
59        })
60    }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[non_exhaustive]
65pub struct DaemonConfig {
66    pub bind: SocketAddr,
67    pub session_ttl: String,
68    pub policy: Option<PathBuf>,
69    pub rulepack: Option<String>,
70    pub adapters: AdapterConfig,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[non_exhaustive]
75pub struct AdapterConfig {
76    pub openai: ProviderConfig,
77    pub anthropic: ProviderConfig,
78    pub gemini: ProviderConfig,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[non_exhaustive]
83pub struct ProviderConfig {
84    pub upstream: Url,
85}
86
87impl ProviderConfig {
88    pub fn new(upstream: Url) -> Self {
89        Self { upstream }
90    }
91}
92
93impl AdapterConfig {
94    pub fn new(openai: Url, anthropic: Url, gemini: Url) -> Self {
95        Self {
96            openai: ProviderConfig::new(openai),
97            anthropic: ProviderConfig::new(anthropic),
98            gemini: ProviderConfig::new(gemini),
99        }
100    }
101}
102
103impl Default for AdapterConfig {
104    fn default() -> Self {
105        Self {
106            openai: ProviderConfig {
107                upstream: Url::parse("https://api.openai.com").expect("static url"),
108            },
109            anthropic: ProviderConfig {
110                upstream: Url::parse("https://api.anthropic.com").expect("static url"),
111            },
112            gemini: ProviderConfig {
113                upstream: Url::parse("https://generativelanguage.googleapis.com")
114                    .expect("static url"),
115            },
116        }
117    }
118}
119
120impl Default for DaemonConfig {
121    fn default() -> Self {
122        Self {
123            bind: "127.0.0.1:8787".parse().expect("static bind addr"),
124            session_ttl: "30m".to_string(),
125            policy: None,
126            rulepack: Some("core".to_string()),
127            adapters: AdapterConfig::default(),
128        }
129    }
130}
131
132#[derive(Debug, Clone)]
133#[non_exhaustive]
134pub struct StartOptions {
135    pub paths: DaemonPaths,
136    pub config: DaemonConfig,
137    pub extra_args: Vec<String>,
138}
139
140impl StartOptions {
141    pub fn new(paths: DaemonPaths, config: DaemonConfig) -> Self {
142        Self {
143            paths,
144            config,
145            extra_args: Vec::new(),
146        }
147    }
148
149    pub fn with_extra_args(mut self, extra_args: Vec<String>) -> Self {
150        self.extra_args = extra_args;
151        self
152    }
153}
154
155#[derive(Debug, Clone)]
156#[non_exhaustive]
157pub struct StopOptions {
158    pub paths: DaemonPaths,
159    pub timeout: Duration,
160    pub force: bool,
161}
162
163impl StopOptions {
164    pub fn new(paths: DaemonPaths, timeout: Duration, force: bool) -> Self {
165        Self {
166            paths,
167            timeout,
168            force,
169        }
170    }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
174#[non_exhaustive]
175pub struct DaemonStatus {
176    pub pid: u32,
177    pub bind: Option<String>,
178    pub started_at: Option<String>,
179    pub running: bool,
180}
181
182pub fn read_or_default_config(paths: &DaemonPaths) -> Result<DaemonConfig, ProxyError> {
183    if !paths.config.exists() {
184        return Ok(DaemonConfig::default());
185    }
186    let text = fs::read_to_string(&paths.config).map_err(|source| ProxyError::DaemonIo {
187        path: paths.config.clone(),
188        source,
189    })?;
190    toml::from_str(&text).map_err(|err| ProxyError::DaemonConfig {
191        detail: err.to_string(),
192    })
193}
194
195pub fn write_config(paths: &DaemonPaths, config: &DaemonConfig) -> Result<(), ProxyError> {
196    if let Some(parent) = paths.config.parent() {
197        fs::create_dir_all(parent).map_err(|source| ProxyError::DaemonIo {
198            path: parent.to_path_buf(),
199            source,
200        })?;
201    }
202    let text = toml::to_string_pretty(config).map_err(|err| ProxyError::DaemonConfig {
203        detail: err.to_string(),
204    })?;
205    fs::write(&paths.config, text).map_err(|source| ProxyError::DaemonIo {
206        path: paths.config.clone(),
207        source,
208    })
209}
210
211pub fn start(options: StartOptions) -> Result<u32, ProxyError> {
212    cleanup_stale(&options.paths)?;
213    if let Some(status) = status(&options.paths)? {
214        if status.running {
215            return Err(ProxyError::DaemonAlreadyRunning {
216                pid: status.pid,
217                pidfile: options.paths.pidfile,
218            });
219        }
220    }
221    write_config(&options.paths, &options.config)?;
222    create_parent(&options.paths.pidfile)?;
223    create_parent(&options.paths.log_file)?;
224    let lock = lock_pidfile(&options.paths)?;
225
226    let stdout = OpenOptions::new()
227        .create(true)
228        .append(true)
229        .open(&options.paths.log_file)
230        .map_err(|source| ProxyError::DaemonIo {
231            path: options.paths.log_file.clone(),
232            source,
233        })?;
234    let stderr = OpenOptions::new()
235        .create(true)
236        .append(true)
237        .open(&options.paths.stderr_file)
238        .map_err(|source| ProxyError::DaemonIo {
239            path: options.paths.stderr_file.clone(),
240            source,
241        })?;
242    let mut command =
243        Command::new(
244            std::env::current_exe().map_err(|source| ProxyError::DaemonIo {
245                path: PathBuf::from("current_exe"),
246                source,
247            })?,
248        );
249    command
250        .args(["proxy", "serve", "--_foreground-daemon"])
251        .arg("--bind")
252        .arg(options.config.bind.to_string())
253        .arg("--session-ttl")
254        .arg(&options.config.session_ttl)
255        .args(options.extra_args)
256        .stdin(Stdio::null())
257        .stdout(Stdio::from(stdout))
258        .stderr(Stdio::from(stderr));
259    drop(lock);
260    let child = command.spawn().map_err(|source| ProxyError::DaemonIo {
261        path: PathBuf::from("gaze proxy serve"),
262        source,
263    })?;
264    std::thread::sleep(Duration::from_millis(250));
265    if process_exists(child.id()) {
266        Ok(child.id())
267    } else {
268        Err(ProxyError::DaemonNotRunning)
269    }
270}
271
272pub fn init_foreground_daemon(paths: &DaemonPaths, bind: SocketAddr) -> Result<(), ProxyError> {
273    create_parent(&paths.pidfile)?;
274    create_parent(&paths.log_file)?;
275    let _lock = lock_pidfile(paths)?;
276    write_pidfile(&paths.pidfile, std::process::id(), bind, Utc::now())
277}
278
279pub fn stop(options: StopOptions) -> Result<(), ProxyError> {
280    cleanup_stale(&options.paths)?;
281    let Some(status) = status(&options.paths)? else {
282        return Err(ProxyError::DaemonNotRunning);
283    };
284    terminate(status.pid, false)?;
285    let started = Instant::now();
286    while started.elapsed() < options.timeout {
287        if !process_exists(status.pid) {
288            let _ = fs::remove_file(&options.paths.pidfile);
289            return Ok(());
290        }
291        std::thread::sleep(Duration::from_millis(100));
292    }
293    if options.force {
294        terminate(status.pid, true)?;
295        let _ = fs::remove_file(&options.paths.pidfile);
296        Ok(())
297    } else {
298        Err(ProxyError::DaemonAlreadyRunning {
299            pid: status.pid,
300            pidfile: options.paths.pidfile,
301        })
302    }
303}
304
305pub fn restart(options: StartOptions, timeout: Duration, force: bool) -> Result<u32, ProxyError> {
306    let stop_result = stop(StopOptions {
307        paths: options.paths.clone(),
308        timeout,
309        force,
310    });
311    if !matches!(stop_result, Ok(()) | Err(ProxyError::DaemonNotRunning)) {
312        cleanup_stale(&options.paths)?;
313    }
314    start(options)
315}
316
317pub fn status(paths: &DaemonPaths) -> Result<Option<DaemonStatus>, ProxyError> {
318    if !paths.pidfile.exists() {
319        return Ok(None);
320    }
321    let record = read_pidfile(&paths.pidfile)?;
322    Ok(Some(DaemonStatus {
323        running: process_exists(record.pid),
324        pid: record.pid,
325        bind: record.bind,
326        started_at: record.started_at,
327    }))
328}
329
330pub fn cleanup_stale(paths: &DaemonPaths) -> Result<(), ProxyError> {
331    if let Some(status) = status(paths)? {
332        if !status.running {
333            fs::remove_file(&paths.pidfile).map_err(|source| ProxyError::DaemonIo {
334                path: paths.pidfile.clone(),
335                source,
336            })?;
337        }
338    }
339    Ok(())
340}
341
342pub fn logs(paths: &DaemonPaths, follow: bool) -> Result<(), ProxyError> {
343    if follow {
344        Command::new("tail")
345            .arg("-F")
346            .arg(&paths.log_file)
347            .status()
348            .map_err(|source| ProxyError::DaemonIo {
349                path: paths.log_file.clone(),
350                source,
351            })?;
352    } else if paths.log_file.exists() {
353        let text = fs::read_to_string(&paths.log_file).map_err(|source| ProxyError::DaemonIo {
354            path: paths.log_file.clone(),
355            source,
356        })?;
357        print!("{text}");
358    }
359    Ok(())
360}
361
362fn create_parent(path: &Path) -> Result<(), ProxyError> {
363    if let Some(parent) = path.parent() {
364        fs::create_dir_all(parent).map_err(|source| ProxyError::DaemonIo {
365            path: parent.to_path_buf(),
366            source,
367        })?;
368    }
369    Ok(())
370}
371
372fn lock_pidfile(paths: &DaemonPaths) -> Result<File, ProxyError> {
373    let file = OpenOptions::new()
374        .create(true)
375        .truncate(false)
376        .read(true)
377        .write(true)
378        .open(&paths.pidfile)
379        .map_err(|source| ProxyError::DaemonIo {
380            path: paths.pidfile.clone(),
381            source,
382        })?;
383    file.try_lock_exclusive()
384        .map_err(|source| ProxyError::DaemonIo {
385            path: paths.pidfile.clone(),
386            source,
387        })?;
388    Ok(file)
389}
390
391fn write_pidfile(
392    path: &Path,
393    pid: u32,
394    bind: SocketAddr,
395    started_at: DateTime<Utc>,
396) -> Result<(), ProxyError> {
397    let contents = format!(
398        "{pid}\nbind={bind}\nstarted_at={}\n",
399        started_at.to_rfc3339()
400    );
401    if contents.len() > 200 {
402        return Err(ProxyError::DaemonConfig {
403            detail: "pidfile content exceeds 200 bytes".to_string(),
404        });
405    }
406    fs::write(path, contents).map_err(|source| ProxyError::DaemonIo {
407        path: path.to_path_buf(),
408        source,
409    })
410}
411
412struct PidRecord {
413    pid: u32,
414    bind: Option<String>,
415    started_at: Option<String>,
416}
417
418fn read_pidfile(path: &Path) -> Result<PidRecord, ProxyError> {
419    let mut text = String::new();
420    File::open(path)
421        .and_then(|mut file| file.read_to_string(&mut text))
422        .map_err(|source| ProxyError::DaemonIo {
423            path: path.to_path_buf(),
424            source,
425        })?;
426    let mut lines = text.lines();
427    let pid = lines
428        .next()
429        .ok_or_else(|| ProxyError::DaemonPidfileStale {
430            pidfile: path.to_path_buf(),
431        })?
432        .parse::<u32>()
433        .map_err(|_| ProxyError::DaemonPidfileStale {
434            pidfile: path.to_path_buf(),
435        })?;
436    let mut bind = None;
437    let mut started_at = None;
438    for line in lines {
439        if let Some(rest) = line.strip_prefix("bind=") {
440            bind = Some(rest.to_string());
441        } else if let Some(rest) = line.strip_prefix("started_at=") {
442            started_at = Some(rest.to_string());
443        }
444    }
445    Ok(PidRecord {
446        pid,
447        bind,
448        started_at,
449    })
450}
451
452#[cfg(unix)]
453fn process_exists(pid: u32) -> bool {
454    unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
455}
456
457#[cfg(not(unix))]
458fn process_exists(_pid: u32) -> bool {
459    false
460}
461
462#[cfg(unix)]
463fn terminate(pid: u32, force: bool) -> Result<(), ProxyError> {
464    let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
465    let rc = unsafe { libc::kill(pid as libc::pid_t, signal) };
466    if rc == 0 {
467        Ok(())
468    } else {
469        Err(ProxyError::DaemonNotRunning)
470    }
471}
472
473#[cfg(not(unix))]
474fn terminate(_pid: u32, _force: bool) -> Result<(), ProxyError> {
475    Err(ProxyError::DaemonConfig {
476        detail: "daemon stop is not implemented on this platform".to_string(),
477    })
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    fn temp_paths(dir: &tempfile::TempDir) -> DaemonPaths {
485        DaemonPaths {
486            pidfile: dir.path().join("proxy.pid"),
487            config: dir.path().join("proxy.toml"),
488            log_file: dir.path().join("proxy.log"),
489            stderr_file: dir.path().join("proxy-stderr.log"),
490        }
491    }
492
493    #[test]
494    fn pidfile_round_trip_includes_bind_and_started_at() {
495        let dir = tempfile::tempdir().unwrap();
496        let paths = temp_paths(&dir);
497        write_pidfile(
498            &paths.pidfile,
499            123,
500            "127.0.0.1:8787".parse().unwrap(),
501            DateTime::parse_from_rfc3339("2026-05-14T00:00:00Z")
502                .unwrap()
503                .with_timezone(&Utc),
504        )
505        .unwrap();
506        let status = status(&paths).unwrap().unwrap();
507        assert_eq!(status.pid, 123);
508        assert_eq!(status.bind.as_deref(), Some("127.0.0.1:8787"));
509        assert!(!status.running);
510    }
511
512    #[test]
513    fn cleanup_stale_unlinks_dead_pidfile() {
514        let dir = tempfile::tempdir().unwrap();
515        let paths = temp_paths(&dir);
516        write_pidfile(
517            &paths.pidfile,
518            999_999,
519            "127.0.0.1:8787".parse().unwrap(),
520            Utc::now(),
521        )
522        .unwrap();
523        cleanup_stale(&paths).unwrap();
524        assert!(!paths.pidfile.exists());
525    }
526
527    #[test]
528    fn config_round_trip() {
529        let dir = tempfile::tempdir().unwrap();
530        let paths = temp_paths(&dir);
531        let config = DaemonConfig::default();
532        write_config(&paths, &config).unwrap();
533        let loaded = read_or_default_config(&paths).unwrap();
534        assert_eq!(loaded.bind, config.bind);
535        assert_eq!(
536            loaded.adapters.openai.upstream,
537            config.adapters.openai.upstream
538        );
539    }
540}