Skip to main content

mj_controller/controller/
worker_restart.rs

1//! Replacing a session's worker process in place.
2//!
3//! Two things ask for this: a checkpoint whose ACP turn will not finish, and a
4//! session whose worker predates the controller now talking to it. Both stop
5//! the worker, install the binary this controller would provision, start it
6//! and reconnect, so the sequence lives here once and each caller supplies
7//! only what it tells the operator.
8
9use 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
25/// How long a restarted worker has to recover its journal, bind `control.sock`
26/// and report an idle ACP session. Journal recovery over a long transcript
27/// runs before the socket exists, so this has to outlast it.
28const WORKER_RESTART_TIMEOUT: Duration = Duration::from_secs(300);
29
30/// How long a quiet session's upgrade waits for its actor and its lease.
31const UPGRADE_LEASE_TIMEOUT: Duration = Duration::from_secs(5);
32
33/// The worker was stopped so it could be replaced, and no worker came back:
34/// the binary swap, start, connect, or ACP readiness after it failed. The
35/// session has no live worker until something restarts one.
36#[derive(Debug)]
37pub struct WorkerRestartLeftNoWorker;
38
39impl WorkerRestartLeftNoWorker {
40    /// Whether a failed operation left the session without a live worker.
41    ///
42    /// The marker is carried by the error, not by its text. Callers wrap
43    /// restart errors in further context, and `anyhow`'s downcast walks those
44    /// layers, so added context does not hide it.
45    #[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
59/// After a restarted worker has answered once, only a dead transport proves
60/// the worker is gone again; any other failure leaves a live worker behind.
61fn 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
69/// What one restart tells the operator at each step. The steps are identical;
70/// only the reason differs, and a diagnostic that named the wrong reason would
71/// send someone looking in the wrong place.
72pub(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
89/// A wedged ACP turn is being killed so a checkpoint barrier can be admitted.
90pub(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
99/// A quiet session is being moved onto the worker binary this controller
100/// would install.
101const 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/// What an upgrade attempt found. Nothing here is a failure: a worker that is
111/// already current and a session that started working again are both ordinary.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum WorkerUpgradeOutcome {
114    /// The worker was replaced and the managed session now speaks to one
115    /// running this build.
116    Upgraded { build: String },
117    /// The worker already runs the binary this controller would install.
118    AlreadyCurrent { build: String },
119    /// The session was working when the upgrade reached it. A worker restart
120    /// would have killed that work, so nothing was touched.
121    Deferred,
122}
123
124impl WorkerUpgradeOutcome {
125    /// The build the session's worker runs now, or `None` when the attempt
126    /// stood down without establishing one.
127    #[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
136/// Whether a worker that reported `reported` in hello is running `installed`,
137/// the binary this controller would provision.
138///
139/// A worker that reported nothing is not: the field postdates it, so its
140/// binary does too.
141fn worker_runs_installed_build(reported: Option<&str>, installed: &str) -> bool {
142    reported.is_some_and(|reported| reported == installed)
143}
144
145impl Controller {
146    /// Replace a session's worker with the binary this controller would
147    /// install, when the session is quiet and its worker is a different build.
148    ///
149    /// `reported_build` is the digest the worker gave the observer that asked
150    /// for this. It only saves work: a match returns before anything is leased.
151    /// The decision that matters is taken again under the lease, against a
152    /// snapshot read from the worker itself, because a session can start
153    /// working between an observation and this call.
154    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        // From here the session actor holds no connection, so no prompt it
175        // accepts can reach the worker: submissions queue until the lease
176        // returns. That is what makes the quiet check below decisive.
177        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            // Preparation never touches the running worker. Return its live
210            // connection directly instead of making the actor reconnect.
211            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                // Dropping the lease returns the actor to reconnecting on its
236                // own, which is the recovery for a half-finished restart.
237                drop(lease);
238                Err(error)
239            }
240        }
241    }
242
243    /// Stop the worker, install the binary this controller would provision,
244    /// start it and reconnect to the session it recovers.
245    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        // A failed stop may leave the old worker alive, so it stays outside the
259        // marker below: only steps after a successful stop can leave the
260        // session with no worker at all.
261        stop_worker_after_target_recovery(executor, backend, session_id, worker_root)
262            .context(messages.stop)?;
263        // Everything up to the first successful connection either fails with no
264        // worker running or cannot tell: the marker covers all of it.
265        let mut connection = async {
266            // Copy through hel.next and rename. scp/cp onto a still-mapped hel
267            // fails with ETXTBSY ("dest open ... Failure") even after SIGKILL,
268            // and prepare_worker_files writes that path in place.
269            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            // Journal recovery runs before the daemon binds control.sock. A long
278            // kimi session can take well over the ordinary 30s startup window.
279            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        // A worker answered, so a failure from here on only means "no worker"
312        // when the transport to it died again.
313        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
339/// Wait until a restarted worker's projection stops moving and reports idle.
340///
341/// Three stable polls, not one: a worker that has just recovered its journal
342/// can report idle between two events it is still applying.
343async fn wait_for_idle_projection(relay: &mut StandaloneSession, timeout: Duration) -> Result<()> {
344    let deadline = tokio::time::Instant::now() + timeout;
345    let mut last_ordinal = None;
346    let mut stable_polls = 0_u8;
347    loop {
348        let snapshot = relay.sync().await?;
349        let ordinal = snapshot.operational.latest_ordinal;
350        let idle = snapshot.operational.native_session_is_ready()
351            && (snapshot.operational.execution == RelayExecutionState::Idle
352                || (snapshot.operational.goal.synchronized()
353                    && snapshot.operational.goal.active()));
354        if idle && (snapshot.operational.goal.active() || last_ordinal == Some(ordinal)) {
355            stable_polls = stable_polls.saturating_add(1);
356            if stable_polls >= 3 {
357                return Ok(());
358            }
359        } else {
360            stable_polls = 0;
361        }
362        last_ordinal = Some(ordinal);
363        if snapshot.operational.execution == RelayExecutionState::Closed {
364            bail!("ACP runtime stopped before becoming idle");
365        }
366        if tokio::time::Instant::now() >= deadline {
367            bail!(
368                "ACP runtime did not become idle after worker restart (execution={:?}, ordinal={ordinal})",
369                snapshot.operational.execution
370            );
371        }
372        tokio::time::sleep(Duration::from_millis(200)).await;
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    #[test]
379    fn a_dead_transport_after_reconnect_marks_the_restart_as_leaving_no_worker() {
380        let died = anyhow::Error::new(crate::worker_client::RelayTransportDead::new(
381            "relay proxy disconnected during attach",
382        ))
383        .context("wait for ACP session after restarting the worker for checkpoint");
384        assert!(super::WorkerRestartLeftNoWorker::marks(
385            &super::mark_if_transport_died(died)
386        ));
387
388        let slow = anyhow::anyhow!("timed out waiting for the ACP session")
389            .context("wait for ACP session after restarting the worker for checkpoint");
390        let slow = super::mark_if_transport_died(slow);
391        assert!(!super::WorkerRestartLeftNoWorker::marks(&slow), "{slow:#}");
392    }
393
394    use super::*;
395
396    #[cfg(unix)]
397    use std::sync::Mutex;
398
399    #[cfg(unix)]
400    use crate::targets::CommandOutput;
401
402    /// Fails every command after the first, so a restart gets past its stop and
403    /// then loses the worker it was replacing.
404    #[cfg(unix)]
405    struct StopSucceedsThenFails {
406        executed: Mutex<Vec<String>>,
407    }
408
409    #[cfg(unix)]
410    impl CommandExecutor for StopSucceedsThenFails {
411        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
412            let mut executed = self.executed.lock().expect("executed commands");
413            executed.push(command.program.clone());
414            if executed.len() == 1 {
415                return Ok(CommandOutput {
416                    status: 0,
417                    stdout: Vec::new(),
418                    stderr: Vec::new(),
419                });
420            }
421            Ok(CommandOutput {
422                status: 1,
423                stdout: Vec::new(),
424                stderr: b"no such target".to_vec(),
425            })
426        }
427    }
428
429    #[cfg(unix)]
430    struct FailingStop;
431
432    #[cfg(unix)]
433    impl CommandExecutor for FailingStop {
434        fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
435            Ok(CommandOutput {
436                status: 1,
437                stdout: Vec::new(),
438                stderr: b"permission denied".to_vec(),
439            })
440        }
441    }
442
443    #[cfg(unix)]
444    fn bare_restart_controller() -> Controller {
445        Controller {
446            config: mj_core::config::Config::default(),
447            state: mj_core::state::State::default(),
448        }
449    }
450
451    #[cfg(unix)]
452    async fn restart_error(
453        session_id: &str,
454        executor: &(impl CommandExecutor + Sync),
455    ) -> anyhow::Error {
456        let worker_root = format!("/tmp/mjolnir-restart-test/{session_id}");
457        let backend = targets::TargetLocator::LocalBare {
458            worker_root: worker_root.clone(),
459        };
460        let reconnect = CommandSpec::new("unused", std::iter::empty::<&str>());
461        let result = bare_restart_controller()
462            .restart_worker_with_installed_binary(
463                session_id,
464                executor,
465                InstalledWorkerRestart {
466                    backend: &backend,
467                    worker_root: &worker_root,
468                    reconnect: &reconnect,
469                    launch: None,
470                    messages: &RESTART_FOR_CHECKPOINT,
471                },
472            )
473            .await;
474        match result {
475            Ok(_) => panic!("a failing executor unexpectedly restarted the worker"),
476            Err(error) => error,
477        }
478    }
479
480    #[cfg(unix)]
481    #[tokio::test]
482    async fn a_restart_that_could_not_stop_the_worker_leaves_it_running() {
483        let error = restart_error("0123456789abcdef0123456789abcdef", &FailingStop).await;
484
485        assert!(
486            !WorkerRestartLeftNoWorker::marks(&error),
487            "a failed stop may leave the old worker alive: {error:#}"
488        );
489    }
490
491    #[cfg(unix)]
492    #[tokio::test]
493    async fn a_restart_that_stopped_the_worker_and_then_failed_is_marked() {
494        let executor = StopSucceedsThenFails {
495            executed: Mutex::new(Vec::new()),
496        };
497
498        let error = restart_error("0123456789abcdef0123456789abcdef", &executor).await;
499
500        assert!(
501            WorkerRestartLeftNoWorker::marks(&error),
502            "the worker was stopped and nothing replaced it: {error:#}"
503        );
504        assert!(
505            executor.executed.lock().expect("executed commands").len() > 1,
506            "the restart should have failed after its stop, not during it"
507        );
508    }
509
510    /// The three answers hello can produce, and what each means for the
511    /// worker's binary.
512    #[test]
513    fn only_a_matching_reported_build_counts_as_current() {
514        let installed = "a".repeat(64);
515
516        assert!(worker_runs_installed_build(Some(&installed), &installed));
517        assert!(!worker_runs_installed_build(
518            Some(&"b".repeat(64)),
519            &installed
520        ));
521        assert!(
522            !worker_runs_installed_build(None, &installed),
523            "a worker too old to report a build is older than this controller"
524        );
525    }
526}