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}