Skip to main content

ados_sdk/
lifecycle.rs

1//! Plugin lifecycle trait and the runner entry.
2//!
3//! Ports `ados.plugins.runner` for the `runtime: rust` case. A Rust plugin's
4//! binary implements [`Plugin`] and calls [`run_plugin`] from `main`; the
5//! runner reads `--socket` / `--token` / `--agent-id`, falling back to the
6//! `ADOS_PLUGIN_*` values of the token credential systemd loads into
7//! `$CREDENTIALS_DIRECTORY` (or, without one, the environment — the exact
8//! contract `runner.py` follows), connects the
9//! [`PluginIpcClient`], builds a [`PluginContext`], and drives the lifecycle
10//! hooks until SIGTERM/SIGINT, then runs the teardown hooks. A lost host
11//! session (a plugin-host restart) also ends the run, as an error, so the
12//! binary exits non-zero and its unit restarts it with a fresh session.
13//!
14//! **Entry: function, not proc-macro.** A `#[ados_plugin]` attribute macro
15//! would expand to nothing more than `fn main() { run_plugin::<P>() }` — it
16//! saves one line and costs a separate proc-macro crate, a syn/quote dependency
17//! tree, and an opaque codegen step. The plain generic function is the cleaner
18//! contract: a plugin author writes a three-line `main`, the call site is
19//! explicit, and there is no macro to debug. So the SDK ships [`run_plugin`]
20//! and no proc-macro sub-crate.
21//!
22//! Hook order matches the Python runner exactly:
23//! `on_install` -> `on_enable` -> `on_configure` -> `on_start` -> (wait for
24//! shutdown) -> `on_stop` -> `on_disable`. Every hook has a default no-op so a
25//! plugin overrides only what it needs.
26
27use std::collections::BTreeMap;
28use std::future::Future;
29use std::sync::Arc;
30
31use async_trait::async_trait;
32use rmpv::Value;
33use thiserror::Error;
34
35use crate::client::{ClientError, PluginIpcClient};
36use crate::context::PluginContext;
37
38/// The lifecycle hook trait a Rust plugin implements. Mirrors the duck-typed
39/// `on_install` / `on_enable` / `on_configure` / `on_start` / `on_stop` /
40/// `on_disable` methods the Python runner calls via `getattr`.
41///
42/// Every hook defaults to a no-op. `on_configure` receives the resolved config
43/// map (the Python runner passes `{}` today; the SDK passes the same shape and
44/// will carry the live config when the host wires it).
45#[async_trait]
46pub trait Plugin: Send + Sync {
47    /// Construct the plugin. Called once before any hook. The runner uses this
48    /// rather than `Default` so a plugin can fail construction explicitly.
49    fn new() -> Self
50    where
51        Self: Sized;
52
53    async fn on_install(&mut self, _ctx: &PluginContext) -> Result<(), ClientError> {
54        Ok(())
55    }
56    async fn on_enable(&mut self, _ctx: &PluginContext) -> Result<(), ClientError> {
57        Ok(())
58    }
59    async fn on_configure(
60        &mut self,
61        _ctx: &PluginContext,
62        _config: &BTreeMap<String, Value>,
63    ) -> Result<(), ClientError> {
64        Ok(())
65    }
66    async fn on_start(&mut self, _ctx: &PluginContext) -> Result<(), ClientError> {
67        Ok(())
68    }
69    async fn on_stop(&mut self, _ctx: &PluginContext) -> Result<(), ClientError> {
70        Ok(())
71    }
72    async fn on_disable(&mut self, _ctx: &PluginContext) -> Result<(), ClientError> {
73        Ok(())
74    }
75}
76
77/// Errors raised while bootstrapping or running a plugin via [`run_plugin`].
78#[derive(Debug, Error)]
79pub enum RunnerError {
80    /// The plugin id positional argument was missing.
81    #[error("missing plugin id argument")]
82    MissingPluginId,
83    /// `--socket` / `ADOS_PLUGIN_SOCKET` or `--token` / `ADOS_PLUGIN_TOKEN` was
84    /// absent, so there is no host to connect to. Mirrors the Python runner's
85    /// fall-back-to-null-ipc path; here it is an explicit error because a Rust
86    /// `runtime: rust` plugin is always launched with the bridge wired.
87    #[error("no supervisor socket/token supplied (set --socket and --token)")]
88    NoBridge,
89    /// The client failed to connect or handshake.
90    #[error("ipc connect failed: {0}")]
91    Connect(#[from] ClientError),
92    /// The plugin host closed this plugin's session (a plugin-host restart) or
93    /// the stream broke. Nothing reconnects it: the restarted host re-mints the
94    /// socket and token for a fresh process, so the runner stops and the
95    /// binary exits non-zero for its unit's `Restart=` to bring it back.
96    #[error(
97        "plugin host session lost; exiting so the unit restarts with a fresh socket and token"
98    )]
99    SessionLost,
100}
101
102/// The systemd credential id a plugin unit loads its token file under. It
103/// appears as `$CREDENTIALS_DIRECTORY/<this>` inside the plugin process.
104pub const TOKEN_CREDENTIAL_NAME: &str = "ados-plugin-token";
105
106/// The parsed runner arguments. Mirrors the `plugin_id` positional plus the
107/// `--socket` / `--token` / `--agent-id` options the Python runner reads, with
108/// the `ADOS_PLUGIN_SOCKET` / `ADOS_PLUGIN_TOKEN` / `ADOS_PLUGIN_AGENT_ID`
109/// fallbacks read from the token credential.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct RunnerArgs {
112    pub plugin_id: String,
113    pub socket_path: Option<String>,
114    pub token: Option<String>,
115    pub agent_id: String,
116    /// The plugin's per-drone data directory, from `ADOS_PLUGIN_DATA_DIR`. The
117    /// host delivers it in the token credential; a plugin reads it through
118    /// `ctx.data_dir` rather than re-deriving a path it might get wrong.
119    pub data_dir: Option<String>,
120}
121
122impl RunnerArgs {
123    /// Parse from a raw argv (excluding `argv[0]`) and an env lookup. The env
124    /// lookup is injected so the parse is unit-testable without touching the
125    /// process environment.
126    ///
127    /// A value an option does not give comes from the `KEY=VALUE` lines of
128    /// `$CREDENTIALS_DIRECTORY/ados-plugin-token`, the file a systemd unit
129    /// loads with `LoadCredential=` (the token never sits in the process
130    /// environment there). Without a credential the environment supplies it,
131    /// which is how the launchd entry script delivers the same keys.
132    pub fn parse<F>(args: &[String], env: F) -> Result<Self, RunnerError>
133    where
134        F: Fn(&str) -> Option<String>,
135    {
136        let mut plugin_id: Option<String> = None;
137        let mut socket_path: Option<String> = None;
138        let mut token: Option<String> = None;
139        let mut agent_id: Option<String> = None;
140
141        let mut i = 0;
142        while i < args.len() {
143            let arg = &args[i];
144            match arg.as_str() {
145                "--socket" => {
146                    socket_path = args.get(i + 1).cloned();
147                    i += 2;
148                }
149                "--token" => {
150                    token = args.get(i + 1).cloned();
151                    i += 2;
152                }
153                "--agent-id" => {
154                    agent_id = args.get(i + 1).cloned();
155                    i += 2;
156                }
157                other if other.starts_with("--socket=") => {
158                    socket_path = Some(other["--socket=".len()..].to_string());
159                    i += 1;
160                }
161                other if other.starts_with("--token=") => {
162                    token = Some(other["--token=".len()..].to_string());
163                    i += 1;
164                }
165                other if other.starts_with("--agent-id=") => {
166                    agent_id = Some(other["--agent-id=".len()..].to_string());
167                    i += 1;
168                }
169                _ if plugin_id.is_none() => {
170                    plugin_id = Some(arg.clone());
171                    i += 1;
172                }
173                _ => {
174                    i += 1;
175                }
176            }
177        }
178
179        let credential = read_credential(&env);
180        let launch_value = |key: &str| {
181            credential
182                .get(key)
183                .filter(|v| !v.is_empty())
184                .cloned()
185                .or_else(|| env(key).filter(|v| !v.is_empty()))
186        };
187        Ok(Self {
188            plugin_id: plugin_id.ok_or(RunnerError::MissingPluginId)?,
189            socket_path: socket_path.or_else(|| launch_value("ADOS_PLUGIN_SOCKET")),
190            token: token.or_else(|| launch_value("ADOS_PLUGIN_TOKEN")),
191            agent_id: agent_id
192                .or_else(|| launch_value("ADOS_PLUGIN_AGENT_ID"))
193                .unwrap_or_default(),
194            data_dir: launch_value("ADOS_PLUGIN_DATA_DIR"),
195        })
196    }
197}
198
199/// The `KEY=VALUE` lines of the token credential under the
200/// `CREDENTIALS_DIRECTORY` the lookup names; empty when there is none or it
201/// cannot be read. Values are taken verbatim after the first `=`.
202fn read_credential<F>(env: &F) -> BTreeMap<String, String>
203where
204    F: Fn(&str) -> Option<String>,
205{
206    let Some(dir) = env("CREDENTIALS_DIRECTORY").filter(|d| !d.is_empty()) else {
207        return BTreeMap::new();
208    };
209    let path = std::path::Path::new(&dir).join(TOKEN_CREDENTIAL_NAME);
210    let Ok(body) = std::fs::read_to_string(path) else {
211        return BTreeMap::new();
212    };
213    body.lines()
214        .filter_map(|line| line.split_once('='))
215        .filter(|(key, _)| !key.is_empty())
216        .map(|(key, value)| (key.to_string(), value.to_string()))
217        .collect()
218}
219
220/// Run a plugin to completion: parse argv/env, connect, build the context,
221/// drive the hooks, and tear down on the shutdown signal. This is the
222/// `runtime: rust` plugin's `main` body.
223///
224/// `plugin_version` is the version the host installed; a real binary embeds its
225/// own `env!("CARGO_PKG_VERSION")`. `static_config` is the manifest-supplied
226/// config the runner would have read off disk; the SDK takes it as an argument
227/// so the binary stays in control of where it loads config from.
228///
229/// Returns when the shutdown future resolves (SIGTERM/SIGINT in a real binary)
230/// or a hook errors. Returns [`RunnerError::SessionLost`] when the host session
231/// drops while the plugin runs; the binary MUST exit non-zero on any error so
232/// systemd restarts it with a fresh session.
233pub async fn run_plugin<P, S>(
234    plugin_version: impl Into<String>,
235    static_config: BTreeMap<String, Value>,
236    shutdown: S,
237) -> Result<(), RunnerError>
238where
239    P: Plugin,
240    S: Future<Output = ()>,
241{
242    let argv: Vec<String> = std::env::args().skip(1).collect();
243    let args = RunnerArgs::parse(&argv, |k| std::env::var(k).ok())?;
244    run_plugin_with::<P, S>(args, plugin_version, static_config, shutdown).await
245}
246
247/// Run a plugin against pre-parsed [`RunnerArgs`]. Splits out from
248/// [`run_plugin`] so a host harness or a test can supply args directly without
249/// touching the process environment.
250pub async fn run_plugin_with<P, S>(
251    args: RunnerArgs,
252    plugin_version: impl Into<String>,
253    static_config: BTreeMap<String, Value>,
254    shutdown: S,
255) -> Result<(), RunnerError>
256where
257    P: Plugin,
258    S: Future<Output = ()>,
259{
260    let (Some(socket_path), Some(token)) = (args.socket_path.clone(), args.token.clone()) else {
261        return Err(RunnerError::NoBridge);
262    };
263
264    let ipc = Arc::new(PluginIpcClient::new(
265        args.plugin_id.clone(),
266        token,
267        socket_path,
268    ));
269    ipc.connect().await?;
270
271    ensure_data_dir(args.data_dir.as_deref());
272
273    let ctx = PluginContext::new(
274        ipc.clone(),
275        plugin_version,
276        args.agent_id,
277        args.data_dir,
278        static_config.clone(),
279    );
280
281    let mut plugin = P::new();
282    let result = drive(
283        &mut plugin,
284        &ctx,
285        &static_config,
286        shutdown,
287        ipc.session_lost(),
288    )
289    .await;
290
291    // Always close the client, success or failure.
292    ipc.close().await;
293    result
294}
295
296/// Create the plugin's data directory so it exists before the plugin writes to
297/// it. The host creates the plugin's data dir (`.../plugin-data/<id>`, owned by
298/// the plugin's account and bound writable into its unit) and the per-drone
299/// leaf it points `ctx.data_dir` at when that dir is new; a leaf for a device
300/// id that appeared later, or a run outside the host, is made here, inside the
301/// plugin's own dir. Best-effort: a failure is logged and left to surface as
302/// the plugin's own write error rather than aborting the runner. `None` is a
303/// no-op.
304fn ensure_data_dir(data_dir: Option<&str>) {
305    if let Some(dir) = data_dir {
306        if let Err(e) = std::fs::create_dir_all(dir) {
307            tracing::warn!(dir, error = %e, "could not create plugin data dir");
308        }
309    }
310}
311
312/// Drive the hook sequence. Separated so the teardown hooks run even when a
313/// startup hook errors. Mirrors the Python runner's try/finally shape.
314///
315/// The run phase ends on the shutdown signal or on the loss of the host
316/// session, whichever comes first. A lost session still runs the teardown
317/// hooks so the plugin releases its local resources, but their errors are
318/// only logged (their host calls cannot succeed any more) and the result is
319/// [`RunnerError::SessionLost`].
320async fn drive<P, S, L>(
321    plugin: &mut P,
322    ctx: &PluginContext,
323    config: &BTreeMap<String, Value>,
324    shutdown: S,
325    session_lost: L,
326) -> Result<(), RunnerError>
327where
328    P: Plugin,
329    S: Future<Output = ()>,
330    L: Future<Output = ()>,
331{
332    plugin.on_install(ctx).await?;
333    plugin.on_enable(ctx).await?;
334    plugin.on_configure(ctx, config).await?;
335    plugin.on_start(ctx).await?;
336
337    tracing::info!(plugin_id = %ctx.plugin_id, "plugin ready");
338    let lost = tokio::select! {
339        () = shutdown => false,
340        () = session_lost => true,
341    };
342
343    if lost {
344        tracing::error!(
345            plugin_id = %ctx.plugin_id,
346            "plugin host session lost; stopping so the unit restarts with a fresh session"
347        );
348        for (hook, result) in [
349            ("on_stop", plugin.on_stop(ctx).await),
350            ("on_disable", plugin.on_disable(ctx).await),
351        ] {
352            if let Err(e) = result {
353                tracing::debug!(plugin_id = %ctx.plugin_id, hook, error = %e, "teardown hook failed after session loss");
354            }
355        }
356        return Err(RunnerError::SessionLost);
357    }
358
359    plugin.on_stop(ctx).await?;
360    plugin.on_disable(ctx).await?;
361    tracing::info!(plugin_id = %ctx.plugin_id, "plugin clean exit");
362    Ok(())
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    fn no_env(_: &str) -> Option<String> {
370        None
371    }
372
373    #[test]
374    fn parses_positional_id_and_space_separated_options() {
375        let argv = vec![
376            "com.example.demo".to_string(),
377            "--socket".to_string(),
378            "/run/ados/plugins/x.sock".to_string(),
379            "--token".to_string(),
380            "v1|p|s|0|600||sig".to_string(),
381            "--agent-id".to_string(),
382            "agent-42".to_string(),
383        ];
384        let args = RunnerArgs::parse(&argv, no_env).unwrap();
385        assert_eq!(args.plugin_id, "com.example.demo");
386        assert_eq!(
387            args.socket_path.as_deref(),
388            Some("/run/ados/plugins/x.sock")
389        );
390        assert_eq!(args.token.as_deref(), Some("v1|p|s|0|600||sig"));
391        assert_eq!(args.agent_id, "agent-42");
392    }
393
394    #[test]
395    fn parses_equals_form_options() {
396        let argv = vec![
397            "com.example.demo".to_string(),
398            "--socket=/tmp/y.sock".to_string(),
399            "--token=tok".to_string(),
400        ];
401        let args = RunnerArgs::parse(&argv, no_env).unwrap();
402        assert_eq!(args.socket_path.as_deref(), Some("/tmp/y.sock"));
403        assert_eq!(args.token.as_deref(), Some("tok"));
404        // agent-id defaults to empty, matching the Python `default=""`.
405        assert_eq!(args.agent_id, "");
406    }
407
408    #[test]
409    fn falls_back_to_env_for_socket_token_agent() {
410        let argv = vec!["com.example.demo".to_string()];
411        let env = |k: &str| match k {
412            "ADOS_PLUGIN_SOCKET" => Some("/env/sock".to_string()),
413            "ADOS_PLUGIN_TOKEN" => Some("env-tok".to_string()),
414            "ADOS_PLUGIN_AGENT_ID" => Some("env-agent".to_string()),
415            _ => None,
416        };
417        let args = RunnerArgs::parse(&argv, env).unwrap();
418        assert_eq!(args.socket_path.as_deref(), Some("/env/sock"));
419        assert_eq!(args.token.as_deref(), Some("env-tok"));
420        assert_eq!(args.agent_id, "env-agent");
421    }
422
423    #[test]
424    fn explicit_option_overrides_env() {
425        let argv = vec![
426            "com.example.demo".to_string(),
427            "--socket".to_string(),
428            "/explicit/sock".to_string(),
429        ];
430        let env = |k: &str| match k {
431            "ADOS_PLUGIN_SOCKET" => Some("/env/sock".to_string()),
432            _ => None,
433        };
434        let args = RunnerArgs::parse(&argv, env).unwrap();
435        assert_eq!(args.socket_path.as_deref(), Some("/explicit/sock"));
436    }
437
438    #[test]
439    fn missing_plugin_id_errors() {
440        let argv = vec!["--socket".to_string(), "/tmp/x".to_string()];
441        let err = RunnerArgs::parse(&argv, no_env).unwrap_err();
442        assert!(matches!(err, RunnerError::MissingPluginId));
443    }
444
445    #[test]
446    fn the_token_credential_supplies_what_the_options_do_not() {
447        // Under systemd the launch values arrive only in the credential file;
448        // a stale environment value never wins over it, and an option does.
449        let dir = tempfile::tempdir().unwrap();
450        std::fs::write(
451            dir.path().join(TOKEN_CREDENTIAL_NAME),
452            "ADOS_PLUGIN_TOKEN=pay=load|sig==\n\
453             ADOS_PLUGIN_SOCKET=/run/ados/plugins/com.example.demo/host.sock\n\
454             ADOS_PLUGIN_AGENT_ID=drone-abc\n\
455             ADOS_PLUGIN_DATA_DIR=/var/ados/plugin-data/com.example.demo/drones/drone-abc\n",
456        )
457        .unwrap();
458        let creds = dir.path().to_string_lossy().into_owned();
459        let env = move |k: &str| match k {
460            "CREDENTIALS_DIRECTORY" => Some(creds.clone()),
461            "ADOS_PLUGIN_TOKEN" => Some("stale-env".to_string()),
462            _ => None,
463        };
464        let argv = vec![
465            "com.example.demo".to_string(),
466            "--agent-id=explicit".to_string(),
467        ];
468        let args = RunnerArgs::parse(&argv, env).unwrap();
469        assert_eq!(args.token.as_deref(), Some("pay=load|sig=="));
470        assert_eq!(
471            args.socket_path.as_deref(),
472            Some("/run/ados/plugins/com.example.demo/host.sock")
473        );
474        assert_eq!(args.agent_id, "explicit");
475        assert_eq!(
476            args.data_dir.as_deref(),
477            Some("/var/ados/plugin-data/com.example.demo/drones/drone-abc")
478        );
479    }
480
481    #[test]
482    fn an_unreadable_credential_falls_back_to_the_environment() {
483        let dir = tempfile::tempdir().unwrap();
484        let missing = dir.path().join("absent").to_string_lossy().into_owned();
485        let env = move |k: &str| match k {
486            "CREDENTIALS_DIRECTORY" => Some(missing.clone()),
487            "ADOS_PLUGIN_TOKEN" => Some("env-tok".to_string()),
488            _ => None,
489        };
490        let args = RunnerArgs::parse(&["com.example.demo".to_string()], env).unwrap();
491        assert_eq!(args.token.as_deref(), Some("env-tok"));
492        assert_eq!(args.socket_path, None);
493    }
494
495    /// A dummy plugin proves the trait's default-no-op hooks compile and that a
496    /// minimal implementation only needs `new`.
497    struct DummyPlugin {
498        started: bool,
499    }
500
501    #[async_trait]
502    impl Plugin for DummyPlugin {
503        fn new() -> Self {
504            Self { started: false }
505        }
506        async fn on_start(&mut self, _ctx: &PluginContext) -> Result<(), ClientError> {
507            self.started = true;
508            Ok(())
509        }
510    }
511
512    #[test]
513    fn dummy_plugin_constructs_with_only_new() {
514        let p = DummyPlugin::new();
515        assert!(!p.started);
516    }
517
518    #[test]
519    fn ensure_data_dir_creates_the_nested_leaf() {
520        // The bug this closes: the per-drone leaf never existed, so a plugin's
521        // first write failed. Prove the nested path is created and a file can be
522        // written under it — not just that a path string was produced.
523        let base = tempfile::tempdir().unwrap();
524        let leaf = base.path().join("com.example.plugin/drones/drone-xyz");
525        assert!(!leaf.exists());
526
527        ensure_data_dir(Some(leaf.to_str().unwrap()));
528        assert!(
529            leaf.is_dir(),
530            "the nested data dir must exist after the call"
531        );
532
533        // A plugin can now actually write there.
534        std::fs::write(leaf.join("state.json"), b"{}").expect("write under data dir");
535
536        // Idempotent: a second call on an existing dir is fine.
537        ensure_data_dir(Some(leaf.to_str().unwrap()));
538        // None is a no-op (does not panic).
539        ensure_data_dir(None);
540    }
541
542    #[tokio::test]
543    async fn no_bridge_when_socket_or_token_absent() {
544        // plugin id present but no socket/token -> NoBridge, before any connect.
545        let args = RunnerArgs {
546            plugin_id: "com.example.demo".to_string(),
547            socket_path: None,
548            token: None,
549            agent_id: String::new(),
550            data_dir: None,
551        };
552        let err = run_plugin_with::<DummyPlugin, _>(
553            args,
554            "1.0.0",
555            BTreeMap::new(),
556            std::future::ready(()),
557        )
558        .await
559        .unwrap_err();
560        assert!(matches!(err, RunnerError::NoBridge));
561    }
562
563    #[tokio::test]
564    async fn a_dropped_host_session_ends_the_runner_with_an_error() {
565        use ados_protocol::plugin::{Envelope, PROTOCOL_VERSION};
566        use tokio::io::{AsyncReadExt, AsyncWriteExt};
567
568        // A plugin-host restart: the host answers the handshake, then its end
569        // of the session goes away. The runner must stop with an error (the
570        // binary then exits non-zero and systemd restarts it) instead of
571        // running on against a dead socket until an operator intervenes.
572        let dir = tempfile::tempdir().expect("tempdir");
573        let sock = dir.path().join("p.sock");
574        let listener = tokio::net::UnixListener::bind(&sock).expect("bind");
575        let host = tokio::spawn(async move {
576            let (mut stream, _) = listener.accept().await.expect("accept");
577            let len = stream.read_u32().await.expect("hello header") as usize;
578            let mut body = vec![0u8; len];
579            stream.read_exact(&mut body).await.expect("hello body");
580            let hello = Envelope::from_msgpack(&body).expect("hello envelope");
581            let ready = Envelope {
582                version: PROTOCOL_VERSION,
583                kind: "response".to_string(),
584                method: String::new(),
585                capability: String::new(),
586                args: Value::Map(vec![(Value::from("ready"), Value::Boolean(true))]),
587                request_id: hello.request_id,
588                token: String::new(),
589                error: None,
590            };
591            stream
592                .write_all(&ready.encode_frame().unwrap())
593                .await
594                .expect("ready");
595            // The host restarts: this session is gone.
596            drop(stream);
597        });
598
599        let args = RunnerArgs {
600            plugin_id: "com.example.demo".to_string(),
601            socket_path: Some(sock.to_string_lossy().into_owned()),
602            token: Some("tok".to_string()),
603            agent_id: String::new(),
604            data_dir: None,
605        };
606        // No shutdown signal ever arrives; only the session loss can end it.
607        let result = tokio::time::timeout(
608            std::time::Duration::from_secs(5),
609            run_plugin_with::<DummyPlugin, _>(
610                args,
611                "1.0.0",
612                BTreeMap::new(),
613                std::future::pending(),
614            ),
615        )
616        .await
617        .expect("the runner must stop once the host session is lost");
618        host.await.expect("fake host");
619        assert!(
620            matches!(result, Err(RunnerError::SessionLost)),
621            "got {result:?}"
622        );
623    }
624}