mj_controller/pollers/
lifecycle.rs1use 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
22pub 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
42pub fn interrupted_lifecycle_cause(session: &SessionRecord) -> Option<String> {
56 match session.state {
57 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 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
90pub 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 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}