Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 184 additions & 2 deletions desktop/src-tauri/src/exit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,33 @@ pub fn decide(phase: ExitPhase, reason: Option<ExitReason>, hides_to_tray: bool)
}
}

/// What the runtime supervisor needs to know before it brings a runtime back.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Supervision {
pub phase: ExitPhase,
/// Whether the app still wants a runtime. True from launch; the tray's Stop, a quit's drain and
/// an update's drain clear it before the runtime's exit can arrive, so the supervisor never
/// undoes any of them.
pub wanted: bool,
/// Something has claimed the app's ending: a quit or a restart.
pub reason_set: bool,
}

impl Supervision {
/// Nothing is in flight, nobody asked for the runtime to stop, and the app is not ending.
pub fn allowed(self) -> bool {
self.phase == ExitPhase::Idle && self.wanted && !self.reason_set
}
}

struct Inner {
phase: ExitPhase,
reason: Option<ExitReason>,
hides_to_tray: bool,
/// An exit that arrived while a runtime was being started or stopped, and still has to happen.
deferred: bool,
/// See [`Supervision::wanted`]. Sticky: finishing a stop does not restore it.
wanted: bool,
}

/// The exit sequence's state, managed by the app.
Expand All @@ -157,6 +178,7 @@ impl ExitCoordinator {
// tray that turns out not to exist is the exact failure D6 is about.
hides_to_tray: TrayAvailability::assumed().hides_to_tray(),
deferred: false,
wanted: true,
}),
}
}
Expand Down Expand Up @@ -193,6 +215,9 @@ impl ExitCoordinator {
/// [`ExitCoordinator::finish_stop`] is holding the phase.
pub fn claim_drain(&self, fallback: ExitReason) -> Option<ExitReason> {
let mut inner = self.inner();
// A quit or an update is on its way, whoever ends up running the drain: the runtime it
// stops is not one to bring back.
inner.wanted = false;
match inner.phase {
ExitPhase::Idle => {
let reason = *inner.reason.get_or_insert(fallback);
Expand Down Expand Up @@ -226,6 +251,51 @@ impl ExitCoordinator {
};
}

/// Give a coordinated restart that is not going to happen back to a running app.
///
/// An update drains before it installs. When the install then fails, or the drain itself did,
/// the drain's phase used to be the end of the road: `Drained` is terminal, so no runtime could
/// be started again and a bare window close quit the app. This returns the app to `Idle` with
/// no claimed reason and wants a runtime again, which is what a successful update would have
/// ended in too. It touches nothing unless an update's restart holds the phase: a quit is never
/// aborted, and a drain still running belongs to whoever runs it. Returns the phase it left.
pub fn abort_restart(&self) -> Option<ExitPhase> {
let mut inner = self.inner();
let left = inner.phase;
let restart = inner.reason == Some(ExitReason::CoordinatedRestart);
let settled = matches!(
left,
ExitPhase::Drained | ExitPhase::DrainFailed | ExitPhase::OwnershipUnknown
);
if !restart || !settled {
return None;
}
inner.phase = ExitPhase::Idle;
inner.reason = None;
inner.deferred = false;
inner.wanted = true;
Some(left)
}

/// What the runtime supervisor reads before it acts.
pub fn supervision(&self) -> Supervision {
let inner = self.inner();
Supervision {
phase: inner.phase,
wanted: inner.wanted,
reason_set: inner.reason.is_some(),
}
}

pub fn supervision_allowed(&self) -> bool {
self.supervision().allowed()
}

/// A person asked for a runtime again (the startup page's retry).
pub fn resume(&self) {
self.inner().wanted = true;
}

/// Reserve the right to start a runtime. False once something else owns the phase.
///
/// The reservation exists instead of holding the lock across the spawn. Holding it would make
Expand All @@ -243,8 +313,18 @@ impl ExitCoordinator {
}

/// Reserve the runtime for a stop that does not end the app.
///
/// A stop that takes the phase also stops the app wanting a runtime, in the same step, so the
/// runtime's exit can never reach the supervisor first. One that cannot take it stops nothing
/// and changes nothing: the runtime it would have stopped stays supervised.
pub fn begin_stop(&self) -> bool {
self.begin(ExitPhase::Stopping)
let mut inner = self.inner();
if inner.phase != ExitPhase::Idle {
return false;
}
inner.phase = ExitPhase::Stopping;
inner.wanted = false;
true
}

/// Release the stop, handing back a reason that arrived meanwhile.
Expand Down Expand Up @@ -545,10 +625,112 @@ fn hide_windows(app: &AppHandle) {
mod tests {
use super::{
decide, DrainVerdict, ExitCoordinator, ExitDecision, ExitPhase, ExitReason,
RestartReadiness,
RestartReadiness, Supervision,
};
use crate::tray_availability::TrayAvailability;

#[test]
fn stop_quit_and_update_stop_wanting_a_runtime_and_only_a_resume_restores_it() {
let coordinator = ExitCoordinator::new();
assert!(coordinator.supervision_allowed());
assert!(coordinator.begin_stop());
assert!(!coordinator.supervision().wanted);
assert_eq!(coordinator.finish_stop(), None);
// Back to Idle and still not wanted: the stop was the person's intent, not a phase.
assert_eq!(coordinator.phase(), ExitPhase::Idle);
assert!(!coordinator.supervision_allowed());
coordinator.resume();
assert!(coordinator.supervision_allowed());

for reason in [ExitReason::UserQuit, ExitReason::CoordinatedRestart] {
let coordinator = ExitCoordinator::new();
assert_eq!(coordinator.claim_drain(reason), Some(reason));
assert!(!coordinator.supervision().wanted);
}
// A stop that could not take the phase stopped nothing, so it changes nothing either.
let coordinator = ExitCoordinator::new();
assert!(coordinator.begin_spawn());
assert!(!coordinator.begin_stop());
assert!(coordinator.supervision().wanted);
}

#[test]
fn supervision_is_allowed_only_idle_wanted_and_with_no_ending_claimed() {
for phase in [
ExitPhase::Spawning,
ExitPhase::Stopping,
ExitPhase::Draining,
ExitPhase::Drained,
ExitPhase::DrainFailed,
ExitPhase::OwnershipUnknown,
] {
let supervision = Supervision {
phase,
wanted: true,
reason_set: false,
};
assert!(!supervision.allowed(), "{phase:?}");
}
let idle = Supervision {
phase: ExitPhase::Idle,
wanted: true,
reason_set: false,
};
assert!(idle.allowed());
assert!(!Supervision {
wanted: false,
..idle
}
.allowed());
assert!(!Supervision {
reason_set: true,
..idle
}
.allowed());
let coordinator = ExitCoordinator::new();
coordinator.claim(ExitReason::UserQuit);
assert!(!coordinator.supervision_allowed());
}

#[test]
fn a_failed_update_returns_the_app_to_a_running_runtime() {
for verdict in [
DrainVerdict::Drained,
DrainVerdict::Failed,
DrainVerdict::OwnershipUnknown,
] {
let coordinator = ExitCoordinator::new();
coordinator.set_tray(TrayAvailability::Available);
assert_eq!(
coordinator.claim_drain(ExitReason::CoordinatedRestart),
Some(ExitReason::CoordinatedRestart)
);
coordinator.finish_drain(verdict);
let left = coordinator.phase();
assert_eq!(coordinator.abort_restart(), Some(left));
assert_eq!(coordinator.phase(), ExitPhase::Idle);
// A bare close hides again instead of quitting out of a terminal phase.
assert_eq!(coordinator.decision(), ExitDecision::Hide);
assert!(coordinator.supervision_allowed());
assert!(coordinator.begin_spawn());
}
}

#[test]
fn a_quit_or_a_drain_in_flight_is_never_aborted() {
let coordinator = ExitCoordinator::new();
coordinator.claim_drain(ExitReason::UserQuit);
coordinator.finish_drain(DrainVerdict::Drained);
assert_eq!(coordinator.abort_restart(), None);
assert_eq!(coordinator.phase(), ExitPhase::Drained);
assert_eq!(coordinator.decision(), ExitDecision::Proceed);
// A restart still draining belongs to whoever runs it.
let coordinator = ExitCoordinator::new();
coordinator.claim_drain(ExitReason::CoordinatedRestart);
assert_eq!(coordinator.abort_restart(), None);
assert_eq!(coordinator.phase(), ExitPhase::Draining);
}

#[test]
fn a_bare_gesture_hides_when_there_is_a_tray_to_come_back_from() {
assert_eq!(decide(ExitPhase::Idle, None, true), ExitDecision::Hide);
Expand Down
18 changes: 18 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ mod resolve;
mod runtime_stop;
mod sidecar;
mod startup;
mod supervisor;
mod tray;
mod tray_availability;
mod updater;
Expand All @@ -57,6 +58,9 @@ pub struct AppState {
child: Mutex<Option<CommandChild>>,
/// The pid of the child this app started, if it started one.
child_pid: Mutex<Option<u32>>,
/// When that child was spawned, so a later run can tell a child still starting from one that
/// will never answer (`startup::waits_on_child`).
child_spawned: Mutex<Option<std::time::Instant>>,
/// Whether the process answering the endpoint has been confirmed to be that child.
///
/// Durable consent and current process ownership are different facts. Consent is a recorded
Expand All @@ -75,6 +79,7 @@ impl AppState {
proxy: Mutex::new(None),
child: Mutex::new(None),
child_pid: Mutex::new(None),
child_spawned: Mutex::new(None),
confirmed: AtomicBool::new(false),
watch: sidecar::SidecarWatch::default(),
}
Expand Down Expand Up @@ -102,6 +107,12 @@ impl AppState {
*Self::slot(&self.child_pid)
}

/// How long ago the child this app tracks was spawned; nothing when it tracks none.
pub fn child_age(&self) -> Option<std::time::Duration> {
self.child_pid()?;
Self::slot(&self.child_spawned).map(|spawned| spawned.elapsed())
}

/// Confirm that the instance answering is the child this app started.
///
/// This is the only thing that grants ownership. A spawn records a pid; it does not record that
Expand All @@ -115,6 +126,7 @@ impl AppState {

pub fn adopt(&self, child: CommandChild) {
*Self::slot(&self.child_pid) = Some(child.pid());
*Self::slot(&self.child_spawned) = Some(std::time::Instant::now());
*Self::slot(&self.child) = Some(child);
// Spawned, not yet confirmed: the health probe is what establishes that this pid is the
// one answering.
Expand All @@ -128,6 +140,7 @@ impl AppState {
pub fn release(&self) {
self.confirmed.store(false, Ordering::Release);
let _ = Self::slot(&self.child_pid).take();
let _ = Self::slot(&self.child_spawned).take();
let _ = Self::slot(&self.child).take();
}
}
Expand Down Expand Up @@ -176,8 +189,11 @@ fn startup_phases() -> Vec<startup::PhaseInfo> {
}

/// Run the startup sequence again. A run already in flight is left alone.
///
/// A person asking for a runtime again also resumes supervision, even after the tray's Stop.
#[tauri::command]
fn retry_startup(app: tauri::AppHandle) {
supervisor::resume(&app);
startup::begin(&app);
}

Expand Down Expand Up @@ -280,6 +296,8 @@ pub fn run() {
app.manage(tray::TrayState::default());
app.manage(exit::ExitCoordinator::new());
app.manage(startup::Startup::new());
// Registers the exit hook and an idle watchdog; it starts no runtime of its own.
supervisor::watch_runtime(app.handle());

// D7: the window is created and shown before anything is registered, resolved, probed
// or started, so every state below has somewhere to be reported. A login launch stays
Expand Down
33 changes: 22 additions & 11 deletions desktop/src-tauri/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ pub enum LiveVerdict {
NotLive,
/// It is a proxy, on an address this shell can reach. Attach as a guest.
Attach,
/// A Child's client runtime, on loopback. It serves Codex through its Home and the Child's own
/// dashboard, not the management plane, and it is never taken over: the shell attaches to it as
/// a guest and asks nothing. When it is the child this app started, that attach is ownership.
Client,
/// Something is listening and this shell cannot use it. Never a reason to start a second one.
Unusable(String),
}
Expand All @@ -178,22 +182,20 @@ pub fn loopback_reachable(hostname: Option<&str>) -> bool {
/// Read a live verdict.
///
/// Liveness answers "is something there", and core's predicate accepts a connected client's
/// listener on purpose so duplicate-start avoidance can see it. This shell needs the management
/// plane, so it has to discriminate on the role the CLI carried: a client listener serves machine
/// routes, not `/api/*`, and attaching to it would report Ready against an endpoint the dashboard
/// and the tray cannot use.
/// listener on purpose so duplicate-start avoidance can see it. The shell discriminates on the role
/// the CLI carried: a client listener serves machine routes and the Child's dashboard, not `/api/*`,
/// and the takeover a proxy can be offered does not apply to it. It is also what a Child runs,
/// including this app's own sidecar after Connect as Child, so it is attached to
/// ([`LiveVerdict::Client`]) rather than refused. Refusing it failed every recovery on a Child whose
/// runtime restarted outside the app, and each failure scheduled the next.
pub fn live_verdict(resolution: &Resolution) -> LiveVerdict {
let Some(resolved) = resolution.resolved() else {
return LiveVerdict::NotLive;
};
if resolved.liveness.status != Status::Live {
return LiveVerdict::NotLive;
}
if resolved.liveness.role.as_deref() == Some("client") {
return LiveVerdict::Unusable(
"a connected client is listening on this port, not a proxy this app can manage".into(),
);
}
let client = resolved.liveness.role.as_deref() == Some("client");
if !loopback_reachable(resolved.liveness.hostname.as_deref()) {
return LiveVerdict::Unusable(format!(
"the runtime is bound to {} and this app only speaks to loopback",
Expand All @@ -204,6 +206,9 @@ pub fn live_verdict(resolution: &Resolution) -> LiveVerdict {
.unwrap_or("an unknown address")
));
}
if client {
return LiveVerdict::Client;
}
LiveVerdict::Attach
}

Expand Down Expand Up @@ -302,17 +307,23 @@ mod tests {
}

#[test]
fn a_connected_client_is_live_but_not_a_runtime_to_attach_to() {
fn a_connected_client_is_attached_to_and_never_started_beside() {
let client = LIVE.replace(
r#""version":"2.61.0""#,
r#""version":"2.61.0","role":"client""#,
);
let resolution = read(Some(0), client.as_bytes(), b"");
// A Child's runtime: attached to as a guest, never taken over and never refused.
assert_eq!(live_verdict(&resolution), LiveVerdict::Client);
// Live is still live: it is never a reason to start a second one.
assert!(!may_start(&resolution));
// Off loopback it is as unusable as any other listener there.
let elsewhere = client.replace(r#""pid":42"#, r#""pid":42,"hostname":"::1""#);
let resolution = read(Some(0), elsewhere.as_bytes(), b"");
assert!(matches!(
live_verdict(&resolution),
LiveVerdict::Unusable(_)
));
// Live and unusable is still live: it is never a reason to start a second one.
assert!(!may_start(&resolution));
}

Expand Down
Loading
Loading