Skip to main content

mj_controller/daemon/
views.rs

1use super::*;
2
3impl RuntimeState {
4    pub(super) fn cancel_lifecycle(&self, session_id: &str) -> Result<()> {
5        // Controller before lifecycle, the order worker polling takes.
6        let controller = self
7            .controller
8            .lock()
9            .unwrap_or_else(PoisonError::into_inner);
10        let lifecycle = self
11            .lifecycle
12            .lock()
13            .unwrap_or_else(PoisonError::into_inner);
14        let active = lifecycle.get(session_id).with_context(|| {
15            format!("no lifecycle operation is running for session {session_id}")
16        })?;
17        ensure!(
18            lifecycle_cancellable(active.kind, durable_session_state(&controller, session_id)),
19            "stop of {session_id} has passed its verified checkpoint and is removing the target; \
20             it cannot be cancelled"
21        );
22        ensure!(
23            active.request_cancel(),
24            "lifecycle operation is no longer cancellable"
25        );
26        drop(lifecycle);
27        drop(controller);
28        self.publish_revision();
29        Ok(())
30    }
31
32    /// Let storage cleanup drain briefly, then cancel and join every lifecycle
33    /// owner before the daemon closes its session manager and database writer.
34    /// One shared deadline bounds all cleanup tasks rather than granting eight
35    /// seconds to each session serially.
36    pub(super) async fn cancel_and_wait_lifecycles(&self) -> Result<()> {
37        let mut pending = {
38            let lifecycle = self
39                .lifecycle
40                .lock()
41                .unwrap_or_else(PoisonError::into_inner);
42            lifecycle
43                .iter()
44                .filter(|(_, active)| active.result.borrow().is_none())
45                .map(|(session_id, active)| {
46                    if active.kind != LifecycleKind::Cleanup {
47                        active.request_cancel();
48                    }
49                    let stage = active
50                        .active_stages
51                        .keys()
52                        .next_back()
53                        .map(|stage| stage.label())
54                        .unwrap_or_else(|| "container cleanup".to_owned());
55                    (
56                        session_id.clone(),
57                        active.kind,
58                        stage,
59                        active.cancelled.clone(),
60                        active.result.clone(),
61                    )
62                })
63                .collect::<Vec<_>>()
64        };
65        let cleanup_deadline = tokio::time::Instant::now() + Duration::from_secs(8);
66        for (session_id, kind, stage, cancelled, result) in &mut pending {
67            if *kind != LifecycleKind::Cleanup || result.borrow().is_some() {
68                continue;
69            }
70            tracing::info!(%session_id, %stage, "daemon shutdown is waiting for deferred cleanup");
71            self.set_lifecycle_notice(
72                session_id,
73                &format!("Daemon shutdown is waiting for {stage}"),
74            );
75            let finished = tokio::time::timeout_at(cleanup_deadline, async {
76                while result.borrow_and_update().is_none() {
77                    result.changed().await.with_context(|| {
78                        format!("cleanup owner stopped without a result for session {session_id}")
79                    })?;
80                }
81                Ok::<_, anyhow::Error>(())
82            })
83            .await;
84            match finished {
85                Ok(result) => result?,
86                Err(_) => {
87                    tracing::warn!(%session_id, %stage, "deferred cleanup exceeded the daemon shutdown drain deadline");
88                    cancelled.store(true, Ordering::Release);
89                }
90            }
91        }
92        let join_deadline = tokio::time::Instant::now() + Duration::from_secs(1);
93        for (session_id, _, stage, cancelled, mut result) in pending {
94            cancelled.store(true, Ordering::Release);
95            let joined = tokio::time::timeout_at(join_deadline, async {
96                while result.borrow_and_update().is_none() {
97                    result.changed().await.with_context(|| {
98                        format!("lifecycle owner stopped without a result for session {session_id}")
99                    })?;
100                }
101                Ok::<_, anyhow::Error>(())
102            })
103            .await;
104            if joined.is_err() {
105                bail!(
106                    "timed out cancelling lifecycle owner for session {session_id} while {stage}"
107                );
108            }
109            joined.expect("checked timeout")?;
110        }
111        Ok(())
112    }
113
114    /// Every lifecycle operation running now.
115    ///
116    /// The dashboard receives these through a watch channel built by its own
117    /// poller, which the phone server does not have; rather than plumb that
118    /// channel through the session-manager handle, the phone loop reads the
119    /// same state directly. The read is a mutex acquisition over a small map,
120    /// and it happens once per published snapshot, so it never blocks the
121    /// loop the way an await on the async snapshot path would.
122    pub fn active_lifecycles(&self) -> Vec<RuntimeLifecycleView> {
123        // Controller before lifecycle, the order worker polling takes. Both are
124        // plain mutex acquisitions over small maps, so a render loop calling
125        // this never awaits.
126        let controller = self
127            .controller
128            .lock()
129            .unwrap_or_else(PoisonError::into_inner);
130        self.active_lifecycles_with(&controller)
131    }
132
133    /// The same view for a caller that already holds the controller lock.
134    /// The lock is not reentrant, so taking it again here would deadlock the
135    /// daemon.
136    pub(super) fn active_lifecycles_with(
137        &self,
138        controller: &Controller,
139    ) -> Vec<RuntimeLifecycleView> {
140        self.lifecycle
141            .lock()
142            .unwrap_or_else(PoisonError::into_inner)
143            .iter()
144            .filter(|(_, active)| active.is_visible())
145            .map(|(session_id, active)| RuntimeLifecycleView {
146                operation_id: active.operation_id.clone(),
147                cancellable: active.is_cancellable()
148                    && lifecycle_cancellable(
149                        active.kind,
150                        durable_session_state(controller, session_id),
151                    ),
152                session_id: session_id.clone(),
153                kind: active.kind.into(),
154                started_at_epoch_seconds: active.started_at_epoch_seconds,
155                active_stages: active
156                    .active_stages
157                    .iter()
158                    .map(|(stage, (_, started_at))| (*stage, *started_at))
159                    .collect(),
160                resume_destination: active.resume_destination.clone(),
161                notice: active.notice.clone(),
162            })
163            .collect()
164    }
165
166    /// The lifecycle state of one in-memory record, or `None` when the daemon
167    /// holds no record for it. Reading one field costs one lock rather than a
168    /// clone of every record, which is what a poll wants.
169    pub fn session_state(&self, session_id: &str) -> Option<mj_core::state::SessionState> {
170        if self.close_is_requested(session_id) {
171            return Some(SessionState::Closing);
172        }
173        self.controller
174            .lock()
175            .unwrap_or_else(PoisonError::into_inner)
176            .state
177            .sessions
178            .get(session_id)
179            .map(|record| record.state)
180    }
181
182    /// One in-memory session record, or `None` when the daemon holds none.
183    pub fn session_record(&self, session_id: &str) -> Option<SessionRecord> {
184        self.controller
185            .lock()
186            .unwrap_or_else(PoisonError::into_inner)
187            .state
188            .sessions
189            .get(session_id)
190            .cloned()
191    }
192
193    pub async fn workspace_session_handle(
194        &self,
195        session_id: &str,
196    ) -> Result<crate::session_manager::ManagedSessionHandle> {
197        let record = self.session_record(session_id).context("unknown session")?;
198        ensure!(
199            record.target.is_some()
200                && record.state == SessionState::Running
201                && !self.close_is_requested(session_id),
202            "session must have a live running target for file injection"
203        );
204        self.session_manager.session(session_id.to_owned()).await
205    }
206
207    /// Checkpoint a session now and publish the result, the way the daemon's
208    /// own checkpoint action does.
209    ///
210    /// The API's bundle export needs a fresh archive for a running session. Only
211    /// that session's own lifecycle operation can conflict with its checkpoint,
212    /// so this refuses when the session itself is mid-operation and returns a
213    /// [`SessionLifecycleBusy`] the export path can fall back on. It must not
214    /// take the process-wide lifecycle guard: that rejected every export while
215    /// any unrelated session anywhere was mid-lifecycle (#1010).
216    pub async fn checkpoint_session_now(
217        &self,
218        session_id: &str,
219    ) -> Result<mj_core::state::CheckpointMetadata> {
220        if self.session_lifecycle_active(session_id) {
221            return Err(anyhow::Error::new(SessionLifecycleBusy {
222                session_id: session_id.to_owned(),
223            }));
224        }
225        let mut controller = blocking(Controller::load).await?;
226        let checkpoint = controller.checkpoint_session(session_id).await?;
227        refresh_runtime_controller(self).await;
228        Ok(checkpoint)
229    }
230
231    /// Whether this specific session has a lifecycle operation still running.
232    /// A checkpoint conflicts only with its own session's operations, never
233    /// with another session's (#1010).
234    pub(super) fn session_lifecycle_active(&self, session_id: &str) -> bool {
235        self.lifecycle
236            .lock()
237            .unwrap_or_else(PoisonError::into_inner)
238            .get(session_id)
239            .is_some_and(|active| active.result.borrow().is_none())
240    }
241
242    /// In-memory records and ownership sampled with the same lock order as
243    /// completion. A web publish must not pair old records with a new absence
244    /// of ownership, even while its background database reload is in flight.
245    pub fn session_projection(
246        &self,
247    ) -> (BTreeMap<String, SessionRecord>, Vec<RuntimeLifecycleView>) {
248        let controller = self
249            .controller
250            .lock()
251            .unwrap_or_else(PoisonError::into_inner);
252        let operations = self.active_lifecycles_with(&controller);
253        let mut records = controller.state.sessions.clone();
254        for id in self
255            .close_requested
256            .lock()
257            .unwrap_or_else(PoisonError::into_inner)
258            .iter()
259        {
260            if let Some(record) = records.get_mut(id)
261                && record.state != SessionState::Stopped
262            {
263                record.state = SessionState::Closing;
264            }
265        }
266        (records, operations)
267    }
268
269    pub fn cancel_lifecycle_if_active(&self, session_id: &str) {
270        if let Some(active) = self
271            .lifecycle
272            .lock()
273            .unwrap_or_else(PoisonError::into_inner)
274            .get(session_id)
275        {
276            active.request_cancel();
277            self.publish_revision();
278        }
279    }
280
281    pub(super) fn set_lifecycle_resume_destination(
282        &self,
283        session_id: &str,
284        profile_id: String,
285        target_id: String,
286    ) {
287        if let Some(active) = self
288            .lifecycle
289            .lock()
290            .unwrap_or_else(PoisonError::into_inner)
291            .get_mut(session_id)
292        {
293            active.resume_destination = Some((profile_id, target_id));
294            self.publish_revision();
295        }
296    }
297
298    pub(super) fn change_lifecycle_stage(
299        &self,
300        session_id: &str,
301        stage: ProvisionStage,
302        active: bool,
303    ) {
304        let changed = {
305            let mut lifecycle = self
306                .lifecycle
307                .lock()
308                .unwrap_or_else(PoisonError::into_inner);
309            let Some(operation) = lifecycle.get_mut(session_id) else {
310                return;
311            };
312            if active {
313                let entry = operation
314                    .active_stages
315                    .entry(stage)
316                    .or_insert_with(|| (0, epoch_seconds()));
317                entry.0 += 1;
318                entry.0 == 1
319            } else {
320                let Some((count, _)) = operation.active_stages.get_mut(&stage) else {
321                    return;
322                };
323                *count -= 1;
324                if *count == 0 {
325                    operation.active_stages.remove(&stage);
326                    true
327                } else {
328                    false
329                }
330            }
331        };
332        if changed {
333            self.publish_revision();
334        }
335    }
336
337    /// Record something the daemon did on its own, for every attached surface
338    /// to report once.
339    pub(super) fn push_notice(&self, session_id: &str, text: impl Into<String>) {
340        const RETAINED_NOTICES: usize = 32;
341
342        let notice = RuntimeNotice {
343            id: self.next_notice_id.fetch_add(1, Ordering::AcqRel),
344            session_id: session_id.to_owned(),
345            text: text.into(),
346        };
347        {
348            let mut notices = self.notices.lock().unwrap_or_else(PoisonError::into_inner);
349            notices.push_back(notice);
350            while notices.len() > RETAINED_NOTICES {
351                notices.pop_front();
352            }
353        }
354        self.publish_revision();
355    }
356
357    pub(super) fn set_lifecycle_notice(&self, session_id: &str, notice: &str) {
358        if let Some(active) = self
359            .lifecycle
360            .lock()
361            .unwrap_or_else(PoisonError::into_inner)
362            .get_mut(session_id)
363        {
364            if active.kind == LifecycleKind::Move && notice == "Preparing destination" {
365                active.move_source_closed = true;
366            }
367            active.notice = Some(notice.to_owned());
368            self.publish_revision();
369        }
370    }
371}