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    let child = command.spawn().map_err(|source| ProxyError::DaemonIo {
260        path: PathBuf::from("gaze proxy serve"),
261        source,
262    })?;
263    std::thread::sleep(Duration::from_millis(250));
264    if process_exists(child.id()) {
265        Ok(child.id())
266    } else {
267        Err(ProxyError::DaemonNotRunning)
268    }
269}
270
271pub fn init_foreground_daemon(paths: &DaemonPaths, bind: SocketAddr) -> Result<(), ProxyError> {
272    create_parent(&paths.pidfile)?;
273    create_parent(&paths.log_file)?;
274    let _lock = lock_pidfile(paths)?;
275    write_pidfile(&paths.pidfile, std::process::id(), bind, Utc::now())
276}
277
278pub fn stop(options: StopOptions) -> Result<(), ProxyError> {
279    cleanup_stale(&options.paths)?;
280    let Some(status) = status(&options.paths)? else {
281        return Err(ProxyError::DaemonNotRunning);
282    };
283    terminate(status.pid, false)?;
284    let started = Instant::now();
285    while started.elapsed() < options.timeout {
286        if !process_exists(status.pid) {
287            let _ = fs::remove_file(&options.paths.pidfile);
288            return Ok(());
289        }
290        std::thread::sleep(Duration::from_millis(100));
291    }
292    if options.force {
293        terminate(status.pid, true)?;
294        let _ = fs::remove_file(&options.paths.pidfile);
295        Ok(())
296    } else {
297        Err(ProxyError::DaemonAlreadyRunning {
298            pid: status.pid,
299            pidfile: options.paths.pidfile,
300        })
301    }
302}
303
304pub fn restart(options: StartOptions, timeout: Duration, force: bool) -> Result<u32, ProxyError> {
305    let stop_result = stop(StopOptions {
306        paths: options.paths.clone(),
307        timeout,
308        force,
309    });
310    if !matches!(stop_result, Ok(()) | Err(ProxyError::DaemonNotRunning)) {
311        cleanup_stale(&options.paths)?;
312    }
313    start(options)
314}
315
316pub fn status(paths: &DaemonPaths) -> Result<Option<DaemonStatus>, ProxyError> {
317    if !paths.pidfile.exists() {
318        return Ok(None);
319    }
320    let record = read_pidfile(&paths.pidfile)?;
321    Ok(Some(DaemonStatus {
322        running: process_exists(record.pid),
323        pid: record.pid,
324        bind: record.bind,
325        started_at: record.started_at,
326    }))
327}
328
329pub fn cleanup_stale(paths: &DaemonPaths) -> Result<(), ProxyError> {
330    if let Some(status) = status(paths)? {
331        if !status.running {
332            fs::remove_file(&paths.pidfile).map_err(|source| ProxyError::DaemonIo {
333                path: paths.pidfile.clone(),
334                source,
335            })?;
336        }
337    }
338    Ok(())
339}
340
341pub fn logs(paths: &DaemonPaths, follow: bool) -> Result<(), ProxyError> {
342    if follow {
343        Command::new("tail")
344            .arg("-F")
345            .arg(&paths.log_file)
346            .status()
347            .map_err(|source| ProxyError::DaemonIo {
348                path: paths.log_file.clone(),
349                source,
350            })?;
351    } else if paths.log_file.exists() {
352        let text = fs::read_to_string(&paths.log_file).map_err(|source| ProxyError::DaemonIo {
353            path: paths.log_file.clone(),
354            source,
355        })?;
356        print!("{text}");
357    }
358    Ok(())
359}
360
361fn create_parent(path: &Path) -> Result<(), ProxyError> {
362    if let Some(parent) = path.parent() {
363        fs::create_dir_all(parent).map_err(|source| ProxyError::DaemonIo {
364            path: parent.to_path_buf(),
365            source,
366        })?;
367    }
368    Ok(())
369}
370
371fn lock_pidfile(paths: &DaemonPaths) -> Result<File, ProxyError> {
372    let file = OpenOptions::new()
373        .create(true)
374        .truncate(false)
375        .read(true)
376        .write(true)
377        .open(&paths.pidfile)
378        .map_err(|source| ProxyError::DaemonIo {
379            path: paths.pidfile.clone(),
380            source,
381        })?;
382    file.try_lock_exclusive()
383        .map_err(|source| ProxyError::DaemonIo {
384            path: paths.pidfile.clone(),
385            source,
386        })?;
387    Ok(file)
388}
389
390fn write_pidfile(
391    path: &Path,
392    pid: u32,
393    bind: SocketAddr,
394    started_at: DateTime<Utc>,
395) -> Result<(), ProxyError> {
396    let contents = format!(
397        "{pid}\nbind={bind}\nstarted_at={}\n",
398        started_at.to_rfc3339()
399    );
400    if contents.len() > 200 {
401        return Err(ProxyError::DaemonConfig {
402            detail: "pidfile content exceeds 200 bytes".to_string(),
403        });
404    }
405    fs::write(path, contents).map_err(|source| ProxyError::DaemonIo {
406        path: path.to_path_buf(),
407        source,
408    })
409}
410
411struct PidRecord {
412    pid: u32,
413    bind: Option<String>,
414    started_at: Option<String>,
415}
416
417fn read_pidfile(path: &Path) -> Result<PidRecord, ProxyError> {
418    let mut text = String::new();
419    File::open(path)
420        .and_then(|mut file| file.read_to_string(&mut text))
421        .map_err(|source| ProxyError::DaemonIo {
422            path: path.to_path_buf(),
423            source,
424        })?;
425    let mut lines = text.lines();
426    let pid = lines
427        .next()
428        .ok_or_else(|| ProxyError::DaemonPidfileStale {
429            pidfile: path.to_path_buf(),
430        })?
431        .parse::<u32>()
432        .map_err(|_| ProxyError::DaemonPidfileStale {
433            pidfile: path.to_path_buf(),
434        })?;
435    let mut bind = None;
436    let mut started_at = None;
437    for line in lines {
438        if let Some(rest) = line.strip_prefix("bind=") {
439            bind = Some(rest.to_string());
440        } else if let Some(rest) = line.strip_prefix("started_at=") {
441            started_at = Some(rest.to_string());
442        }
443    }
444    Ok(PidRecord {
445        pid,
446        bind,
447        started_at,
448    })
449}
450
451#[cfg(unix)]
452fn process_exists(pid: u32) -> bool {
453    unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
454}
455
456#[cfg(not(unix))]
457fn process_exists(_pid: u32) -> bool {
458    false
459}
460
461#[cfg(unix)]
462fn terminate(pid: u32, force: bool) -> Result<(), ProxyError> {
463    let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
464    let rc = unsafe { libc::kill(pid as libc::pid_t, signal) };
465    if rc == 0 {
466        Ok(())
467    } else {
468        Err(ProxyError::DaemonNotRunning)
469    }
470}
471
472#[cfg(not(unix))]
473fn terminate(_pid: u32, _force: bool) -> Result<(), ProxyError> {
474    Err(ProxyError::DaemonConfig {
475        detail: "daemon stop is not implemented on this platform".to_string(),
476    })
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    fn temp_paths(dir: &tempfile::TempDir) -> DaemonPaths {
484        DaemonPaths {
485            pidfile: dir.path().join("proxy.pid"),
486            config: dir.path().join("proxy.toml"),
487            log_file: dir.path().join("proxy.log"),
488            stderr_file: dir.path().join("proxy-stderr.log"),
489        }
490    }
491
492    #[test]
493    fn pidfile_round_trip_includes_bind_and_started_at() {
494        let dir = tempfile::tempdir().unwrap();
495        let paths = temp_paths(&dir);
496        write_pidfile(
497            &paths.pidfile,
498            123,
499            "127.0.0.1:8787".parse().unwrap(),
500            DateTime::parse_from_rfc3339("2026-05-14T00:00:00Z")
501                .unwrap()
502                .with_timezone(&Utc),
503        )
504        .unwrap();
505        let status = status(&paths).unwrap().unwrap();
506        assert_eq!(status.pid, 123);
507        assert_eq!(status.bind.as_deref(), Some("127.0.0.1:8787"));
508        assert!(!status.running);
509    }
510
511    #[test]
512    fn cleanup_stale_unlinks_dead_pidfile() {
513        let dir = tempfile::tempdir().unwrap();
514        let paths = temp_paths(&dir);
515        write_pidfile(
516            &paths.pidfile,
517            999_999,
518            "127.0.0.1:8787".parse().unwrap(),
519            Utc::now(),
520        )
521        .unwrap();
522        cleanup_stale(&paths).unwrap();
523        assert!(!paths.pidfile.exists());
524    }
525
526    #[test]
527    fn config_round_trip() {
528        let dir = tempfile::tempdir().unwrap();
529        let paths = temp_paths(&dir);
530        let config = DaemonConfig::default();
531        write_config(&paths, &config).unwrap();
532        let loaded = read_or_default_config(&paths).unwrap();
533        assert_eq!(loaded.bind, config.bind);
534        assert_eq!(
535            loaded.adapters.openai.upstream,
536            config.adapters.openai.upstream
537        );
538    }
539}