Skip to main content

codex_helper_core/
runtime_manager.rs

1use std::fmt;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7
8use crate::config::proxy_home_dir;
9use crate::logging::now_ms;
10use crate::proxy::admin_port_for_proxy_port;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum ProxyLifecycleMode {
15    EphemeralConsole,
16    AttachedObserver,
17    ResidentDaemon,
18    DesktopOwned,
19}
20
21impl ProxyLifecycleMode {
22    pub fn as_str(self) -> &'static str {
23        match self {
24            Self::EphemeralConsole => "ephemeral_console",
25            Self::AttachedObserver => "attached_observer",
26            Self::ResidentDaemon => "resident_daemon",
27            Self::DesktopOwned => "desktop_owned",
28        }
29    }
30
31    pub fn parse(value: &str) -> Option<Self> {
32        match normalize_token(value).as_str() {
33            "ephemeral_console" | "ephemeral" | "console" | "owner" => Some(Self::EphemeralConsole),
34            "attached_observer" | "attached" | "observer" | "attach" => {
35                Some(Self::AttachedObserver)
36            }
37            "resident_daemon" | "resident" | "daemon" | "supervisor" => Some(Self::ResidentDaemon),
38            "desktop_owned" | "desktop" | "tray" | "tauri" => Some(Self::DesktopOwned),
39            _ => None,
40        }
41    }
42
43    pub fn owns_runtime(self) -> bool {
44        matches!(
45            self,
46            Self::EphemeralConsole | Self::ResidentDaemon | Self::DesktopOwned
47        )
48    }
49
50    pub fn detach_on_normal_exit(self) -> bool {
51        matches!(self, Self::AttachedObserver)
52    }
53
54    pub fn keeps_client_patch_on_exit(self) -> bool {
55        matches!(self, Self::ResidentDaemon | Self::DesktopOwned)
56    }
57}
58
59impl fmt::Display for ProxyLifecycleMode {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        f.write_str(self.as_str())
62    }
63}
64
65impl std::str::FromStr for ProxyLifecycleMode {
66    type Err = String;
67
68    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
69        Self::parse(value).ok_or_else(|| format!("unknown proxy lifecycle mode: {value}"))
70    }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum RuntimeOwnerKind {
76    ManualCli,
77    Supervisor,
78    Desktop,
79}
80
81impl RuntimeOwnerKind {
82    pub fn as_str(self) -> &'static str {
83        match self {
84            Self::ManualCli => "manual_cli",
85            Self::Supervisor => "supervisor",
86            Self::Desktop => "desktop",
87        }
88    }
89
90    pub fn parse(value: &str) -> Option<Self> {
91        match normalize_token(value).as_str() {
92            "manual_cli" | "manual" | "cli" | "resident" => Some(Self::ManualCli),
93            "supervisor" | "watchdog" => Some(Self::Supervisor),
94            "desktop" | "desktop_owned" | "tray" | "tauri" => Some(Self::Desktop),
95            _ => None,
96        }
97    }
98
99    pub fn lifecycle_mode(self) -> ProxyLifecycleMode {
100        match self {
101            Self::ManualCli | Self::Supervisor => ProxyLifecycleMode::ResidentDaemon,
102            Self::Desktop => ProxyLifecycleMode::DesktopOwned,
103        }
104    }
105}
106
107impl fmt::Display for RuntimeOwnerKind {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.write_str(self.as_str())
110    }
111}
112
113impl std::str::FromStr for RuntimeOwnerKind {
114    type Err = String;
115
116    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
117        Self::parse(value).ok_or_else(|| format!("unknown runtime owner kind: {value}"))
118    }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct RuntimeOwnerMarker {
123    pub schema_version: u32,
124    pub owner: RuntimeOwnerKind,
125    pub lifecycle_mode: ProxyLifecycleMode,
126    pub service_name: String,
127    pub proxy_port: u16,
128    pub admin_port: u16,
129    pub pid: u32,
130    pub started_at_ms: u64,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub supervisor_pid: Option<u32>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub note: Option<String>,
135}
136
137impl RuntimeOwnerMarker {
138    pub fn new(owner: RuntimeOwnerKind, service_name: impl Into<String>, proxy_port: u16) -> Self {
139        Self::new_with_pid(
140            owner,
141            service_name,
142            proxy_port,
143            std::process::id(),
144            now_ms(),
145        )
146    }
147
148    pub fn new_with_pid(
149        owner: RuntimeOwnerKind,
150        service_name: impl Into<String>,
151        proxy_port: u16,
152        pid: u32,
153        started_at_ms: u64,
154    ) -> Self {
155        Self {
156            schema_version: 1,
157            owner,
158            lifecycle_mode: owner.lifecycle_mode(),
159            service_name: service_name.into(),
160            proxy_port,
161            admin_port: admin_port_for_proxy_port(proxy_port),
162            pid,
163            started_at_ms,
164            supervisor_pid: None,
165            note: None,
166        }
167    }
168
169    pub fn with_supervisor_pid(mut self, supervisor_pid: u32) -> Self {
170        self.supervisor_pid = Some(supervisor_pid);
171        self
172    }
173
174    pub fn with_note(mut self, note: impl Into<String>) -> Self {
175        let note = note.into();
176        if !note.trim().is_empty() {
177            self.note = Some(note);
178        }
179        self
180    }
181}
182
183pub fn runtime_run_dir() -> PathBuf {
184    proxy_home_dir().join("run")
185}
186
187pub fn owner_marker_path(service_name: &str, proxy_port: u16) -> PathBuf {
188    owner_marker_path_in(runtime_run_dir(), service_name, proxy_port)
189}
190
191pub fn owner_marker_path_in(
192    run_dir: impl AsRef<Path>,
193    service_name: &str,
194    proxy_port: u16,
195) -> PathBuf {
196    run_dir.as_ref().join(format!(
197        "{}-{}.owner.json",
198        normalize_service_name(service_name),
199        proxy_port
200    ))
201}
202
203pub fn write_owner_marker(marker: &RuntimeOwnerMarker) -> Result<PathBuf> {
204    write_owner_marker_to(runtime_run_dir(), marker)
205}
206
207pub fn write_owner_marker_to(
208    run_dir: impl AsRef<Path>,
209    marker: &RuntimeOwnerMarker,
210) -> Result<PathBuf> {
211    let run_dir = run_dir.as_ref();
212    fs::create_dir_all(run_dir)
213        .with_context(|| format!("create runtime run dir {}", run_dir.display()))?;
214    let path = owner_marker_path_in(run_dir, &marker.service_name, marker.proxy_port);
215    let text = serde_json::to_string_pretty(marker)?;
216    fs::write(&path, text).with_context(|| format!("write owner marker {}", path.display()))?;
217    Ok(path)
218}
219
220pub fn read_owner_marker(
221    service_name: &str,
222    proxy_port: u16,
223) -> Result<Option<RuntimeOwnerMarker>> {
224    read_owner_marker_from(runtime_run_dir(), service_name, proxy_port)
225}
226
227pub fn read_owner_marker_best_effort(
228    service_name: &str,
229    proxy_port: u16,
230) -> Option<RuntimeOwnerMarker> {
231    read_owner_marker_best_effort_from(runtime_run_dir(), service_name, proxy_port)
232}
233
234pub fn read_owner_marker_from(
235    run_dir: impl AsRef<Path>,
236    service_name: &str,
237    proxy_port: u16,
238) -> Result<Option<RuntimeOwnerMarker>> {
239    let path = owner_marker_path_in(run_dir, service_name, proxy_port);
240    if !path.exists() {
241        return Ok(None);
242    }
243    let text = fs::read_to_string(&path)
244        .with_context(|| format!("read owner marker {}", path.display()))?;
245    let marker = serde_json::from_str::<RuntimeOwnerMarker>(&text)
246        .with_context(|| format!("parse owner marker {}", path.display()))?;
247    Ok(Some(marker))
248}
249
250pub fn read_owner_marker_best_effort_from(
251    run_dir: impl AsRef<Path>,
252    service_name: &str,
253    proxy_port: u16,
254) -> Option<RuntimeOwnerMarker> {
255    match read_owner_marker_from(run_dir, service_name, proxy_port) {
256        Ok(marker) => marker,
257        Err(err) => {
258            tracing::warn!(
259                "ignoring unreadable runtime owner marker for {}:{}: {err}",
260                service_name,
261                proxy_port
262            );
263            None
264        }
265    }
266}
267
268pub fn clear_owner_marker(service_name: &str, proxy_port: u16) -> Result<()> {
269    clear_owner_marker_from(runtime_run_dir(), service_name, proxy_port)
270}
271
272pub fn clear_owner_marker_from(
273    run_dir: impl AsRef<Path>,
274    service_name: &str,
275    proxy_port: u16,
276) -> Result<()> {
277    let path = owner_marker_path_in(run_dir, service_name, proxy_port);
278    match fs::remove_file(&path) {
279        Ok(()) => Ok(()),
280        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
281        Err(err) => Err(err).with_context(|| format!("clear owner marker {}", path.display())),
282    }
283}
284
285#[derive(Debug)]
286pub struct RuntimeOwnerMarkerGuard {
287    run_dir: Option<PathBuf>,
288    service_name: String,
289    proxy_port: u16,
290    enabled: bool,
291}
292
293impl RuntimeOwnerMarkerGuard {
294    pub fn new(service_name: impl Into<String>, proxy_port: u16, enabled: bool) -> Self {
295        Self {
296            run_dir: None,
297            service_name: service_name.into(),
298            proxy_port,
299            enabled,
300        }
301    }
302
303    pub fn new_in(
304        run_dir: impl Into<PathBuf>,
305        service_name: impl Into<String>,
306        proxy_port: u16,
307        enabled: bool,
308    ) -> Self {
309        Self {
310            run_dir: Some(run_dir.into()),
311            service_name: service_name.into(),
312            proxy_port,
313            enabled,
314        }
315    }
316
317    pub fn disarm(&mut self) {
318        self.enabled = false;
319    }
320}
321
322impl Drop for RuntimeOwnerMarkerGuard {
323    fn drop(&mut self) {
324        if !self.enabled {
325            return;
326        }
327        let result = if let Some(run_dir) = self.run_dir.as_ref() {
328            clear_owner_marker_from(run_dir, &self.service_name, self.proxy_port)
329        } else {
330            clear_owner_marker(&self.service_name, self.proxy_port)
331        };
332        if let Err(err) = result {
333            tracing::warn!("failed to clear runtime owner marker: {err}");
334        }
335    }
336}
337
338pub fn describe_normal_exit(mode: ProxyLifecycleMode) -> &'static str {
339    match mode {
340        ProxyLifecycleMode::EphemeralConsole => "stop_owned_runtime",
341        ProxyLifecycleMode::AttachedObserver => "detach_only",
342        ProxyLifecycleMode::ResidentDaemon => "keep_resident_runtime",
343        ProxyLifecycleMode::DesktopOwned => "keep_until_desktop_quit",
344    }
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum RuntimeConnectionMode {
349    Owned,
350    Attached,
351    Stopped,
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub enum RuntimeStopIntent {
356    OwnerExit,
357    ExplicitStop,
358}
359
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum RuntimeStopAction {
362    StopOwnedRuntime,
363    ShutdownAttachedRuntime,
364    DetachOnly,
365    Noop,
366}
367
368pub fn decide_runtime_stop_action(
369    connection: RuntimeConnectionMode,
370    intent: RuntimeStopIntent,
371    attached_shutdown_available: bool,
372) -> RuntimeStopAction {
373    match (connection, intent) {
374        (RuntimeConnectionMode::Owned, _) => RuntimeStopAction::StopOwnedRuntime,
375        (RuntimeConnectionMode::Attached, RuntimeStopIntent::OwnerExit) => {
376            RuntimeStopAction::DetachOnly
377        }
378        (RuntimeConnectionMode::Attached, RuntimeStopIntent::ExplicitStop)
379            if attached_shutdown_available =>
380        {
381            RuntimeStopAction::ShutdownAttachedRuntime
382        }
383        (RuntimeConnectionMode::Attached, RuntimeStopIntent::ExplicitStop) => {
384            RuntimeStopAction::DetachOnly
385        }
386        (RuntimeConnectionMode::Stopped, _) => RuntimeStopAction::Noop,
387    }
388}
389
390fn normalize_token(value: &str) -> String {
391    value.trim().to_ascii_lowercase().replace(['-', ' '], "_")
392}
393
394fn normalize_service_name(value: &str) -> String {
395    let normalized = normalize_token(value);
396    if normalized.is_empty() {
397        "codex".to_string()
398    } else {
399        normalized
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    fn unique_run_dir(test_name: &str) -> PathBuf {
408        let mut dir = std::env::temp_dir();
409        dir.push(format!(
410            "codex-helper-runtime-manager-{}-{}-{}",
411            std::process::id(),
412            test_name,
413            now_ms()
414        ));
415        dir
416    }
417
418    #[test]
419    fn lifecycle_modes_make_exit_policy_explicit() {
420        assert_eq!(
421            ProxyLifecycleMode::parse("ephemeral-console"),
422            Some(ProxyLifecycleMode::EphemeralConsole)
423        );
424        assert_eq!(
425            ProxyLifecycleMode::parse("attached"),
426            Some(ProxyLifecycleMode::AttachedObserver)
427        );
428        assert_eq!(
429            ProxyLifecycleMode::parse("desktop"),
430            Some(ProxyLifecycleMode::DesktopOwned)
431        );
432        assert!(ProxyLifecycleMode::EphemeralConsole.owns_runtime());
433        assert!(!ProxyLifecycleMode::AttachedObserver.owns_runtime());
434        assert!(ProxyLifecycleMode::AttachedObserver.detach_on_normal_exit());
435        assert!(!ProxyLifecycleMode::EphemeralConsole.keeps_client_patch_on_exit());
436        assert!(ProxyLifecycleMode::ResidentDaemon.keeps_client_patch_on_exit());
437        assert_eq!(
438            describe_normal_exit(ProxyLifecycleMode::DesktopOwned),
439            "keep_until_desktop_quit"
440        );
441    }
442
443    #[test]
444    fn owner_kind_maps_to_lifecycle_mode() {
445        assert_eq!(
446            RuntimeOwnerKind::parse("manual-cli"),
447            Some(RuntimeOwnerKind::ManualCli)
448        );
449        assert_eq!(
450            RuntimeOwnerKind::parse("watchdog"),
451            Some(RuntimeOwnerKind::Supervisor)
452        );
453        assert_eq!(
454            RuntimeOwnerKind::parse("tray"),
455            Some(RuntimeOwnerKind::Desktop)
456        );
457        assert_eq!(
458            RuntimeOwnerKind::ManualCli.lifecycle_mode(),
459            ProxyLifecycleMode::ResidentDaemon
460        );
461        assert_eq!(
462            RuntimeOwnerKind::Desktop.lifecycle_mode(),
463            ProxyLifecycleMode::DesktopOwned
464        );
465    }
466
467    #[test]
468    fn owner_marker_round_trips_through_run_dir() {
469        let run_dir = unique_run_dir("round-trip");
470        let marker =
471            RuntimeOwnerMarker::new_with_pid(RuntimeOwnerKind::Desktop, "codex", 3211, 42, 123456)
472                .with_supervisor_pid(7)
473                .with_note("started by desktop shell");
474
475        let path = write_owner_marker_to(&run_dir, &marker).expect("write owner marker");
476        assert_eq!(path, run_dir.join("codex-3211.owner.json"));
477
478        let loaded = read_owner_marker_from(&run_dir, "codex", 3211)
479            .expect("read owner marker")
480            .expect("owner marker exists");
481        assert_eq!(loaded.owner, RuntimeOwnerKind::Desktop);
482        assert_eq!(loaded.lifecycle_mode, ProxyLifecycleMode::DesktopOwned);
483        assert_eq!(loaded.admin_port, admin_port_for_proxy_port(3211));
484        assert_eq!(loaded.pid, 42);
485        assert_eq!(loaded.supervisor_pid, Some(7));
486        assert_eq!(loaded.note.as_deref(), Some("started by desktop shell"));
487
488        clear_owner_marker_from(&run_dir, "codex", 3211).expect("clear owner marker");
489        assert!(
490            read_owner_marker_from(&run_dir, "codex", 3211)
491                .expect("read after clear")
492                .is_none()
493        );
494    }
495
496    #[test]
497    fn missing_owner_marker_is_not_an_error() {
498        let run_dir = unique_run_dir("missing");
499        assert!(
500            read_owner_marker_from(&run_dir, "claude", 3210)
501                .expect("missing marker read")
502                .is_none()
503        );
504        clear_owner_marker_from(&run_dir, "claude", 3210).expect("missing marker clear");
505    }
506
507    #[test]
508    fn corrupt_owner_marker_can_be_ignored_by_best_effort_reader() {
509        let run_dir = unique_run_dir("corrupt");
510        fs::create_dir_all(&run_dir).expect("create run dir");
511        fs::write(owner_marker_path_in(&run_dir, "codex", 3211), "{not-json")
512            .expect("write corrupt marker");
513
514        assert!(read_owner_marker_from(&run_dir, "codex", 3211).is_err());
515        assert!(read_owner_marker_best_effort_from(&run_dir, "codex", 3211).is_none());
516    }
517
518    #[test]
519    fn owner_marker_guard_clears_marker_on_drop() {
520        let run_dir = unique_run_dir("guard");
521        let marker =
522            RuntimeOwnerMarker::new_with_pid(RuntimeOwnerKind::ManualCli, "codex", 3211, 42, 1);
523        write_owner_marker_to(&run_dir, &marker).expect("write owner marker");
524
525        {
526            let _guard = RuntimeOwnerMarkerGuard::new_in(&run_dir, "codex", 3211, true);
527        }
528
529        assert!(
530            read_owner_marker_from(&run_dir, "codex", 3211)
531                .expect("read after guard drop")
532                .is_none()
533        );
534    }
535
536    #[test]
537    fn runtime_stop_decision_keeps_attached_exit_safe() {
538        assert_eq!(
539            decide_runtime_stop_action(
540                RuntimeConnectionMode::Owned,
541                RuntimeStopIntent::OwnerExit,
542                false
543            ),
544            RuntimeStopAction::StopOwnedRuntime
545        );
546        assert_eq!(
547            decide_runtime_stop_action(
548                RuntimeConnectionMode::Attached,
549                RuntimeStopIntent::OwnerExit,
550                true
551            ),
552            RuntimeStopAction::DetachOnly
553        );
554        assert_eq!(
555            decide_runtime_stop_action(
556                RuntimeConnectionMode::Attached,
557                RuntimeStopIntent::ExplicitStop,
558                true
559            ),
560            RuntimeStopAction::ShutdownAttachedRuntime
561        );
562        assert_eq!(
563            decide_runtime_stop_action(
564                RuntimeConnectionMode::Attached,
565                RuntimeStopIntent::ExplicitStop,
566                false
567            ),
568            RuntimeStopAction::DetachOnly
569        );
570        assert_eq!(
571            decide_runtime_stop_action(
572                RuntimeConnectionMode::Stopped,
573                RuntimeStopIntent::ExplicitStop,
574                true
575            ),
576            RuntimeStopAction::Noop
577        );
578    }
579}