Skip to main content

mj_controller/pollers/
lifecycle.rs

1use super::*;
2
3pub enum LifecycleSuccess {
4    Created,
5    Resumed {
6        profile_id: String,
7        target_id: String,
8    },
9    Moved(mj_core::state::MoveOutcome),
10    Closed,
11    ForceStopped,
12    DestroyedStopped,
13    ForceDestroyed,
14}
15
16pub struct LifecycleUpdate {
17    pub session_id: String,
18    pub result: std::result::Result<LifecycleSuccess, String>,
19    pub deferred_cleanup: bool,
20}
21
22/// Whether a close stopped partway and left the record mid-close with its
23/// target still present. Such a record cannot be closed again from the start:
24/// its worker is gone, so only recovery can finish it.
25pub fn is_interrupted_close(session: &SessionRecord) -> bool {
26    matches!(
27        session.state,
28        SessionState::Closing | SessionState::Destroying
29    ) && session.target.is_some()
30}
31
32pub fn interrupted_close_session_ids(controller: &Controller) -> Vec<String> {
33    controller
34        .state
35        .sessions
36        .values()
37        .filter(|session| is_interrupted_close(session))
38        .map(|session| session.id.clone())
39        .collect()
40}
41
42/// Why a record left in an in-flight lifecycle state has nobody to finish it,
43/// in words the user reads in `mj sessions` and the TUI.
44///
45/// Every in-flight state needs an owner that will complete it. A durable move
46/// intent owns its session, [`is_interrupted_close`] owns a close or teardown
47/// that still holds its target, and
48/// `database::recover_interrupted_checkpointing_sessions` returns an
49/// interrupted `Checkpointing` record to `Running` before the controller
50/// loads. What is left is a record whose operation died with the process, and
51/// it has to say so instead of waiting forever.
52///
53/// `None` means the state needs no reconciliation; callers exclude the owned
54/// sessions before asking.
55pub fn interrupted_lifecycle_cause(session: &SessionRecord) -> Option<String> {
56    match session.state {
57        // Provisioning has no durable operation behind it. Whatever the dead
58        // provision created is not named by this record, so the resource is
59        // recovered through `mj recover scan`, which can see it again once the
60        // record is no longer in flight.
61        SessionState::Provisioning => Some(
62            "the daemon stopped while this session was provisioning; anything it created \
63             is offered by `mj recover scan`"
64                .to_owned(),
65        ),
66        // An interrupted close or teardown that still holds its target is
67        // resumed rather than failed, so only the target-less residue reaches
68        // here: there is nothing left to tear down, and no relay through which
69        // to finish the close the record claims.
70        SessionState::Closing => Some(
71            "the daemon stopped while this session was closing, and it has no target left \
72             to close"
73                .to_owned(),
74        ),
75        SessionState::Destroying => Some(
76            "the daemon stopped while this session was being torn down, and it has no \
77             target left to remove"
78                .to_owned(),
79        ),
80        SessionState::Checkpointing
81        | SessionState::Running
82        | SessionState::Disconnected
83        | SessionState::Stopped
84        | SessionState::Lost
85        | SessionState::Error
86        | SessionState::DestroyedWithDataLoss => None,
87    }
88}
89
90/// Every session whose in-flight lifecycle state has no owner, with the cause
91/// to record against it. `owned` names the sessions a durable move intent or
92/// another startup recovery has already claimed.
93pub fn unowned_interrupted_lifecycles(
94    controller: &Controller,
95    owned: &std::collections::BTreeSet<String>,
96) -> Vec<(String, String)> {
97    controller
98        .state
99        .sessions
100        .values()
101        .filter(|session| !owned.contains(&session.id) && !is_interrupted_close(session))
102        .filter_map(|session| {
103            interrupted_lifecycle_cause(session).map(|cause| (session.id.clone(), cause))
104        })
105        .collect()
106}
107
108pub fn spawn_interrupted_close_recovery(
109    session_id: String,
110    session_manager: SessionManagerControl,
111    recovery_observer: crate::recovery_gate::RecoveryObserver,
112    cancelled: Arc<AtomicBool>,
113    updates: tokio::sync::mpsc::UnboundedSender<LifecycleUpdate>,
114    tracker: Option<mj_client::operations::CriticalOperationTracker>,
115) -> tokio::task::JoinHandle<()> {
116    let guard = tracker.map(|tracker| {
117        tracker.begin_cancellable(
118            format!(
119                "recovering session {}",
120                mj_core::state::short_id(&session_id)
121            ),
122            cancelled.clone(),
123        )
124    });
125    let runtime = tokio::runtime::Handle::current();
126    tokio::spawn(async move {
127        let operation_session_id = session_id.clone();
128        let joined = tokio::task::spawn_blocking(move || {
129            (|| -> Result<bool> {
130                let _recovery_reservation = reserve_recovery_or_cancel(
131                    &recovery_observer,
132                    &operation_session_id,
133                    &cancelled,
134                )?;
135                let mut controller = Controller::load()?;
136                let executor = CancellableProcessExecutor::new(cancelled);
137                runtime.block_on(controller.recover_interrupted_close_managed(
138                    &operation_session_id,
139                    &executor,
140                    &session_manager,
141                ))
142            })()
143            .map_err(|error| format!("{error:#}"))
144        })
145        .await;
146        let (result, deferred_cleanup) = match joined {
147            Ok(Ok(deferred_cleanup)) => (Ok(LifecycleSuccess::Closed), deferred_cleanup),
148            Ok(Err(error)) => (Err(error), false),
149            Err(error) => (
150                Err(format!("interrupted close recovery task failed: {error}")),
151                false,
152            ),
153        };
154        if let Err(error) = updates.send(LifecycleUpdate {
155            session_id: session_id.clone(),
156            result,
157            deferred_cleanup,
158        }) {
159            tracing::debug!(%session_id, %error, "interrupted close result dropped after dashboard shutdown");
160        }
161        drop(guard);
162    })
163}
164
165pub fn reserve_recovery_or_cancel(
166    observer: &crate::recovery_gate::RecoveryObserver,
167    session_id: &str,
168    cancelled: &AtomicBool,
169) -> Result<crate::recovery_gate::RecoveryReservation> {
170    let reservation = observer.reserve(session_id);
171    // The reservation stops the next copy; cancelling preempts the one already
172    // running so a lifecycle operation never queues behind a long or wedged
173    // copy.
174    observer.cancel_busy(session_id);
175    while observer.is_busy(session_id) {
176        if cancelled.load(Ordering::Acquire) {
177            bail!("operation cancelled while waiting for recovery copy");
178        }
179        std::thread::sleep(Duration::from_millis(25));
180    }
181    Ok(reservation)
182}
183
184pub fn project_worker_title(
185    controller: &mut Controller,
186    update: &WorkerPollUpdate,
187) -> Option<Option<String>> {
188    let snapshot = update.view.snapshot.as_ref()?;
189    let session = controller.state.sessions.get_mut(&update.session_id)?;
190    let title = snapshot.resolved_title();
191    if session.acp_session_title == title {
192        return None;
193    }
194    session.acp_session_title = title.clone();
195    Some(title)
196}
197
198pub fn apply_worker_record_update(controller: &mut Controller, update: &WorkerPollUpdate) {
199    let Some(title) = project_worker_title(controller, update) else {
200        return;
201    };
202    let session_id = update.session_id.clone();
203    tokio::spawn(async move {
204        let result = tokio::task::spawn_blocking(move || {
205            crate::database::set_session_acp_title(&session_id, title.as_deref())
206        })
207        .await;
208        match result {
209            Ok(Ok(())) => {}
210            Ok(Err(error)) => tracing::warn!(%error, "could not persist relay title"),
211            Err(error) => tracing::warn!(%error, "relay title persistence task failed"),
212        }
213    });
214}