mj_controller/controller/
worker_restart.rs1use std::time::Duration;
10
11use anyhow::{Context, Result, bail};
12
13use crate::session_manager::{SessionManagerControl, StandaloneSession};
14use crate::targets::{self, CommandExecutor, CommandSpec};
15use mj_core::relay::RelayExecutionState;
16
17use super::Controller;
18use super::readiness::{connect_started_worker_with_timeout, wait_for_native_session};
19use super::worker_binary::{
20 prepare_managed_harness_for_upgrade, replace_installed_worker_binary,
21 replace_installed_worker_launch_config, start_worker, stop_worker_after_target_recovery,
22 worker_binary_for, worker_probe_diagnosis,
23};
24
25const WORKER_RESTART_TIMEOUT: Duration = Duration::from_secs(300);
29
30const UPGRADE_LEASE_TIMEOUT: Duration = Duration::from_secs(5);
32
33#[derive(Debug)]
37pub struct WorkerRestartLeftNoWorker;
38
39impl WorkerRestartLeftNoWorker {
40 #[must_use]
46 pub fn marks(error: &anyhow::Error) -> bool {
47 error.downcast_ref::<Self>().is_some()
48 }
49}
50
51impl std::fmt::Display for WorkerRestartLeftNoWorker {
52 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 formatter.write_str("the worker restart left the session without a live worker")
54 }
55}
56
57impl std::error::Error for WorkerRestartLeftNoWorker {}
58
59fn mark_if_transport_died(error: anyhow::Error) -> anyhow::Error {
62 if crate::worker_client::RelayTransportDead::marks(&error) {
63 error.context(WorkerRestartLeftNoWorker)
64 } else {
65 error
66 }
67}
68
69pub(super) struct WorkerRestartMessages {
73 pub stop: &'static str,
74 pub replace: &'static str,
75 pub start: &'static str,
76 pub connect: &'static str,
77 pub project_memory: &'static str,
78 pub native_session: &'static str,
79}
80
81pub(super) struct InstalledWorkerRestart<'a> {
82 pub backend: &'a targets::TargetLocator,
83 pub worker_root: &'a str,
84 pub reconnect: &'a CommandSpec,
85 pub launch: Option<&'a mj_core::worker_launch::WorkerLaunchConfig>,
86 pub messages: &'a WorkerRestartMessages,
87}
88
89pub(super) const RESTART_FOR_CHECKPOINT: WorkerRestartMessages = WorkerRestartMessages {
91 stop: "stop wedged Mjolnir worker before retrying checkpoint",
92 replace: "replace Mjolnir worker binary before retrying checkpoint",
93 start: "start Mjolnir worker after interrupting a wedged ACP turn",
94 connect: "connect to Mjolnir worker after restarting it for checkpoint",
95 project_memory: "project memory will not be synchronized after checkpoint worker restart",
96 native_session: "wait for ACP session after restarting the worker for checkpoint",
97};
98
99const RESTART_FOR_UPGRADE: WorkerRestartMessages = WorkerRestartMessages {
102 stop: "stop the Mjolnir worker before installing the current binary",
103 replace: "install the current Mjolnir worker binary",
104 start: "start Mjolnir worker on the current binary",
105 connect: "connect to Mjolnir worker after upgrading its binary",
106 project_memory: "project memory will not be synchronized after the worker upgrade",
107 native_session: "wait for ACP session after upgrading the worker",
108};
109
110#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum WorkerUpgradeOutcome {
114 Upgraded { build: String },
117 AlreadyCurrent { build: String },
119 Deferred,
122}
123
124impl WorkerUpgradeOutcome {
125 #[must_use]
128 pub fn build(&self) -> Option<&str> {
129 match self {
130 Self::Upgraded { build } | Self::AlreadyCurrent { build } => Some(build),
131 Self::Deferred => None,
132 }
133 }
134}
135
136fn worker_runs_installed_build(reported: Option<&str>, installed: &str) -> bool {
142 reported.is_some_and(|reported| reported == installed)
143}
144
145impl Controller {
146 pub async fn upgrade_session_worker(
155 &self,
156 session_id: &str,
157 executor: &(impl CommandExecutor + Sync),
158 manager: &SessionManagerControl,
159 reported_build: Option<&str>,
160 ) -> Result<WorkerUpgradeOutcome> {
161 let (backend, worker_root) = self.worker_placement(session_id)?;
162 let reconnect = targets::reconnect_plan(&backend, session_id)?
163 .commands
164 .into_iter()
165 .next()
166 .context("reconnect plan is empty")?;
167 let binary = worker_binary_for(&backend, executor)
168 .context("resolve the worker binary this controller would install")?;
169 let installed = mj_core::worker_launch::worker_executable_digest(&binary)?;
170 if worker_runs_installed_build(reported_build, &installed) {
171 return Ok(WorkerUpgradeOutcome::AlreadyCurrent { build: installed });
172 }
173
174 let handle = manager
178 .wait_for_session(session_id, UPGRADE_LEASE_TIMEOUT)
179 .await?;
180 let mut lease = handle.lease_connection().await?;
181 let snapshot = lease
182 .connection_mut()
183 .sync()
184 .await
185 .context("read the session state before upgrading its worker")?;
186 if worker_runs_installed_build(snapshot.worker_build.as_deref(), &installed) {
187 lease.release();
188 return Ok(WorkerUpgradeOutcome::AlreadyCurrent { build: installed });
189 }
190 let harness = self.state.sessions[session_id].harness_kind;
191 let safe_to_replace = snapshot.operational.safe_to_replace(harness);
192 tracing::debug!(
193 session_id,
194 safe_to_replace,
195 goal_synchronized = snapshot.operational.goal.synchronized(),
196 goal_active = snapshot.operational.goal.active(),
197 native_running = snapshot.operational.goal.running(),
198 "evaluated automatic worker replacement"
199 );
200 if !safe_to_replace {
201 lease.release();
202 return Ok(WorkerUpgradeOutcome::Deferred);
203 }
204 let launch = self.current_worker_launch_config(session_id, &backend)?;
205 if let Err(error) =
206 prepare_managed_harness_for_upgrade(executor, &backend, session_id, &binary, &launch)
207 .context("prepare the current managed harness before replacing the worker")
208 {
209 lease.release();
212 return Err(error);
213 }
214
215 let restarted = self
216 .restart_worker_with_installed_binary(
217 session_id,
218 executor,
219 InstalledWorkerRestart {
220 backend: &backend,
221 worker_root: &worker_root,
222 reconnect: &reconnect,
223 launch: Some(&launch),
224 messages: &RESTART_FOR_UPGRADE,
225 },
226 )
227 .await;
228 match restarted {
229 Ok(connection) => {
230 lease.replace_connection(connection);
231 lease.release();
232 Ok(WorkerUpgradeOutcome::Upgraded { build: installed })
233 }
234 Err(error) => {
235 drop(lease);
238 Err(error)
239 }
240 }
241 }
242
243 pub(super) async fn restart_worker_with_installed_binary(
246 &self,
247 session_id: &str,
248 executor: &(impl CommandExecutor + Sync),
249 restart: InstalledWorkerRestart<'_>,
250 ) -> Result<StandaloneSession> {
251 let InstalledWorkerRestart {
252 backend,
253 worker_root,
254 reconnect,
255 launch,
256 messages,
257 } = restart;
258 stop_worker_after_target_recovery(executor, backend, session_id, worker_root)
262 .context(messages.stop)?;
263 let mut connection = async {
266 let binary = worker_binary_for(backend, executor)?;
270 replace_installed_worker_binary(executor, backend, session_id, &binary)
271 .context(messages.replace)?;
272 if let Some(launch) = launch {
273 replace_installed_worker_launch_config(executor, backend, session_id, launch)
274 .context("install the current Mjolnir worker launch configuration")?;
275 }
276 start_worker(executor, backend, worker_root).context(messages.start)?;
277 match connect_started_worker_with_timeout(
280 reconnect,
281 session_id,
282 executor,
283 backend,
284 worker_root,
285 WORKER_RESTART_TIMEOUT,
286 )
287 .await
288 {
289 Ok(connection) => Ok(connection),
290 Err(error) => Err(
291 worker_probe_diagnosis(executor, backend, worker_root, error)
292 .context(messages.connect),
293 ),
294 }
295 }
296 .await
297 .map_err(|error| error.context(WorkerRestartLeftNoWorker))?;
298 let project_memory = match self.project_memory_sync_target(session_id) {
299 Ok(target) => Some(target),
300 Err(error) => {
301 tracing::warn!(
302 session_id,
303 error = format!("{error:#}"),
304 "{}",
305 messages.project_memory
306 );
307 None
308 }
309 };
310 connection.set_project_memory_target(project_memory);
311 async {
314 let checkpoint_only = connection.sync().await?.operational.checkpoint_only;
315 if let Some(launch) = launch {
316 anyhow::ensure!(
317 checkpoint_only
318 == (launch.run_mode
319 == mj_core::worker_launch::WorkerRunMode::CheckpointOnly),
320 "restarted worker did not enter the requested execution mode"
321 );
322 }
323 if checkpoint_only {
324 return Ok(());
325 }
326 wait_for_native_session(&mut connection, executor)
327 .await
328 .context(messages.native_session)?;
329 wait_for_idle_projection(&mut connection, WORKER_RESTART_TIMEOUT)
330 .await
331 .context("wait for ACP to go idle after worker restart")
332 }
333 .await
334 .map_err(mark_if_transport_died)?;
335 Ok(connection)
336 }
337}
338
339async fn wait_for_idle_projection(relay: &mut StandaloneSession, timeout: Duration) -> Result<()> {
348 let deadline = tokio::time::Instant::now() + timeout;
349 let mut last_ordinal = None;
350 let mut stable_polls = 0_u8;
351 loop {
352 let snapshot = relay.sync().await?;
353 let ordinal = snapshot.operational.latest_ordinal;
354 let goal_active =
355 snapshot.operational.goal.synchronized() && snapshot.operational.goal.active();
356 let idle = snapshot.operational.native_session_is_ready()
357 && (!snapshot.operational.has_work_in_flight() || goal_active);
358 if idle && (goal_active || last_ordinal == Some(ordinal)) {
359 stable_polls = stable_polls.saturating_add(1);
360 if stable_polls >= 3 {
361 return Ok(());
362 }
363 } else {
364 stable_polls = 0;
365 }
366 last_ordinal = Some(ordinal);
367 if snapshot.operational.execution == RelayExecutionState::Closed {
368 bail!("ACP runtime stopped before becoming idle");
369 }
370 if tokio::time::Instant::now() >= deadline {
371 bail!(
372 "ACP runtime did not become idle after worker restart (execution={:?}, ordinal={ordinal})",
373 snapshot.operational.execution
374 );
375 }
376 tokio::time::sleep(Duration::from_millis(200)).await;
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 #[test]
383 fn a_dead_transport_after_reconnect_marks_the_restart_as_leaving_no_worker() {
384 let died = anyhow::Error::new(crate::worker_client::RelayTransportDead::new(
385 "relay proxy disconnected during attach",
386 ))
387 .context("wait for ACP session after restarting the worker for checkpoint");
388 assert!(super::WorkerRestartLeftNoWorker::marks(
389 &super::mark_if_transport_died(died)
390 ));
391
392 let slow = anyhow::anyhow!("timed out waiting for the ACP session")
393 .context("wait for ACP session after restarting the worker for checkpoint");
394 let slow = super::mark_if_transport_died(slow);
395 assert!(!super::WorkerRestartLeftNoWorker::marks(&slow), "{slow:#}");
396 }
397
398 use super::*;
399
400 #[cfg(unix)]
401 use std::sync::Mutex;
402
403 #[cfg(unix)]
404 use crate::targets::CommandOutput;
405
406 #[cfg(unix)]
409 struct StopSucceedsThenFails {
410 executed: Mutex<Vec<String>>,
411 }
412
413 #[cfg(unix)]
414 impl CommandExecutor for StopSucceedsThenFails {
415 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
416 let mut executed = self.executed.lock().expect("executed commands");
417 executed.push(command.program.clone());
418 if executed.len() == 1 {
419 return Ok(CommandOutput {
420 status: 0,
421 stdout: Vec::new(),
422 stderr: Vec::new(),
423 });
424 }
425 Ok(CommandOutput {
426 status: 1,
427 stdout: Vec::new(),
428 stderr: b"no such target".to_vec(),
429 })
430 }
431 }
432
433 #[cfg(unix)]
434 struct FailingStop;
435
436 #[cfg(unix)]
437 impl CommandExecutor for FailingStop {
438 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
439 Ok(CommandOutput {
440 status: 1,
441 stdout: Vec::new(),
442 stderr: b"permission denied".to_vec(),
443 })
444 }
445 }
446
447 #[cfg(unix)]
448 fn bare_restart_controller() -> Controller {
449 Controller {
450 config: mj_core::config::Config::default(),
451 state: mj_core::state::State::default(),
452 }
453 }
454
455 #[cfg(unix)]
456 async fn restart_error(
457 session_id: &str,
458 executor: &(impl CommandExecutor + Sync),
459 ) -> anyhow::Error {
460 let worker_root = format!("/tmp/mjolnir-restart-test/{session_id}");
461 let backend = targets::TargetLocator::LocalBare {
462 worker_root: worker_root.clone(),
463 };
464 let reconnect = CommandSpec::new("unused", std::iter::empty::<&str>());
465 let result = bare_restart_controller()
466 .restart_worker_with_installed_binary(
467 session_id,
468 executor,
469 InstalledWorkerRestart {
470 backend: &backend,
471 worker_root: &worker_root,
472 reconnect: &reconnect,
473 launch: None,
474 messages: &RESTART_FOR_CHECKPOINT,
475 },
476 )
477 .await;
478 match result {
479 Ok(_) => panic!("a failing executor unexpectedly restarted the worker"),
480 Err(error) => error,
481 }
482 }
483
484 #[cfg(unix)]
485 #[tokio::test]
486 async fn a_restart_that_could_not_stop_the_worker_leaves_it_running() {
487 let error = restart_error("0123456789abcdef0123456789abcdef", &FailingStop).await;
488
489 assert!(
490 !WorkerRestartLeftNoWorker::marks(&error),
491 "a failed stop may leave the old worker alive: {error:#}"
492 );
493 }
494
495 #[cfg(unix)]
496 #[tokio::test]
497 async fn a_restart_that_stopped_the_worker_and_then_failed_is_marked() {
498 let executor = StopSucceedsThenFails {
499 executed: Mutex::new(Vec::new()),
500 };
501
502 let error = restart_error("0123456789abcdef0123456789abcdef", &executor).await;
503
504 assert!(
505 WorkerRestartLeftNoWorker::marks(&error),
506 "the worker was stopped and nothing replaced it: {error:#}"
507 );
508 assert!(
509 executor.executed.lock().expect("executed commands").len() > 1,
510 "the restart should have failed after its stop, not during it"
511 );
512 }
513
514 #[test]
517 fn only_a_matching_reported_build_counts_as_current() {
518 let installed = "a".repeat(64);
519
520 assert!(worker_runs_installed_build(Some(&installed), &installed));
521 assert!(!worker_runs_installed_build(
522 Some(&"b".repeat(64)),
523 &installed
524 ));
525 assert!(
526 !worker_runs_installed_build(None, &installed),
527 "a worker too old to report a build is older than this controller"
528 );
529 }
530}