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