Skip to main content

start_command/
execution_recovery.rs

1//! Launch-time recovery for killed detached docker sessions (issue #176).
2//!
3//! `$ --isolated docker --detached --on-kill-resume 3 --recovery-command B -- A`
4//! runs `A`; when the main process is killed (exit 137, or `OOMKilled` with no
5//! exit status of its own), the detached completion watcher hands the inspected
6//! facts to this module, which restarts the *same* container so `B` continues
7//! on the same filesystem, with the same resource limits (they live in the
8//! container's HostConfig), under the same execution UUID and appending to the
9//! same log file.
10//!
11//! How `B` replaces `A` inside the same container: a container launched with a
12//! recovery command runs a tiny selector as its command. The selector runs `A`
13//! normally, but runs `B` when the marker file exists. Before `docker start`,
14//! the marker (holding the attempt number) is copied into the stopped
15//! container with `docker cp`. Without a recovery command the original command
16//! is simply started again.
17//!
18//! Like `detached_finalize`, the entry point runs as a short-lived child of the
19//! watcher on a host whose CLI invocation is long gone, so it never panics on
20//! bad input and never prints anything.
21
22use std::fs;
23use std::io::Write;
24use std::path::PathBuf;
25
26use chrono::{SecondsFormat, Utc};
27use serde_json::{json, Value};
28
29use crate::cgroup_memory::{
30    cgroup_shell_vars, format_cgroup_memory_log_line, parse_cgroup_memory_sample,
31};
32use crate::detached_finalize::{normalize_bool, normalize_container_error};
33use crate::docker_cleanup::{
34    docker_command, get_docker_container_cleanup_policy,
35    start_detached_docker_completion_watcher_with, DockerWatcherOptions,
36};
37use crate::docker_post_mortem::{
38    format_container_post_mortem, normalize_docker_timestamp, shell_vars, ContainerPostMortem,
39};
40use crate::docker_resource_limits::{
41    build_resource_limits_status_line, read_docker_resource_limits,
42};
43use crate::execution_control::{CommandRunOutput, CommandRunner, SystemCommandRunner};
44use crate::execution_resume::build_launch_options;
45use crate::execution_store::{
46    ExecutionRecord, ExecutionStatus, ExecutionStore, ExecutionStoreOptions,
47};
48use crate::exit_reason::describe_exit_code_str;
49use crate::isolation::isolation_log::shell_quote;
50use crate::recovery_delay::{
51    format_recovery_delay, pick_recovery_delay_ms, record_on_kill_resume_delay, system_random,
52    system_sleep, wait_for_recovery_delay,
53};
54
55/// Hidden argument the detached watcher re-invokes this binary with.
56pub const INTERNAL_RECOVER_FLAG: &str = "--internal-recover-detached-docker";
57
58/// File whose presence makes the selector run the recovery command.
59pub const RECOVERY_MARKER_PATH: &str = "/.start-command-recovery";
60
61/// Environment variable carrying the attempt number into the recovery command.
62pub const RECOVERY_ATTEMPT_ENV: &str = "START_COMMAND_RECOVERY_ATTEMPT";
63
64/// Exit code of a process killed with SIGKILL (the OOM killer's signal).
65pub const KILLED_EXIT_CODE: i32 = 137;
66
67/// POSIX selector run as the container command when a recovery command is set:
68/// `sh -c SELECTOR start-command <shell> <flag> <recovery> <main argv...>`.
69pub fn recovery_selector() -> String {
70    format!(
71        "s=$1; f=$2; r=$3; shift 3; if [ -e {marker} ]; then {env}=$(cat {marker} 2>/dev/null); export {env}; if [ -n \"$f\" ]; then exec \"$s\" \"$f\" -c \"$r\"; fi; exec \"$s\" -c \"$r\"; fi; exec \"$@\"",
72        marker = RECOVERY_MARKER_PATH,
73        env = RECOVERY_ATTEMPT_ENV
74    )
75}
76
77/// Wrap the container command so a later `docker start` can switch to the
78/// recovery command.
79pub fn build_recovery_selector_args(
80    main_args: &[String],
81    shell: &str,
82    shell_flag: Option<&str>,
83    recovery_command: &str,
84) -> Vec<String> {
85    let mut args = vec![
86        "sh".to_string(),
87        "-c".to_string(),
88        recovery_selector(),
89        "start-command".to_string(),
90        if shell.is_empty() { "sh" } else { shell }.to_string(),
91        shell_flag.unwrap_or("").to_string(),
92        recovery_command.to_string(),
93    ];
94    args.extend(main_args.iter().cloned());
95    args
96}
97
98/// Whether the inspected facts describe a killed main process.
99///
100/// Exit 137 (SIGKILL, the OOM killer's signal) always counts. `OOMKilled`
101/// alone does not: Docker sets it when *any* process in the container's
102/// cgroup was OOM-killed (a compiler, a test runner, a child `node`), and it
103/// stays set until the container is started again. A main process that
104/// survived that and then exited 0-127 on its own ran to completion, so the
105/// flag only counts when there is no usable exit status (the watcher's `-1`,
106/// or nothing at all) (issue #178).
107pub fn is_killed_exit(exit_code: &str, oom_killed: &str) -> bool {
108    let code = describe_exit_code_str(exit_code).code;
109    if code == Some(KILLED_EXIT_CODE) {
110        return true;
111    }
112    normalize_bool(oom_killed) == Some(true) && code.is_none_or(|code| code < 0)
113}
114
115/// Shell condition run by the completion watcher: true when the container was
116/// killed and the recovery entry point resumed it. Mirrors `is_killed_exit`:
117/// `OOMKilled` only counts without a non-negative exit code (issue #178).
118pub fn build_recovery_snippet(execution_id: &str) -> String {
119    let executable = std::env::current_exe()
120        .map(|path| path.to_string_lossy().to_string())
121        .unwrap_or_else(|_| "start".to_string());
122    format!(
123        "{{ [ \"${exit}\" = {code} ] || {{ [ \"${oom}\" = true ] && ! [ \"${exit}\" -ge 0 ] 2>/dev/null; }}; }} && {exe} {flag} {id} \"${exit}\" \"${oom}\" \"${started}\" \"${finished}\" \"${error}\" \"${cgroup}\" >/dev/null 2>&1",
124        exit = shell_vars::EXIT,
125        oom = shell_vars::OOM,
126        started = shell_vars::STARTED,
127        finished = shell_vars::FINISHED,
128        error = shell_vars::ERROR,
129        cgroup = cgroup_shell_vars::SAMPLE,
130        code = KILLED_EXIT_CODE,
131        exe = shell_quote(&executable),
132        flag = INTERNAL_RECOVER_FLAG,
133        id = shell_quote(execution_id),
134    )
135}
136
137/// What the `[Recovery k/N]` separator reports.
138pub struct RecoverySeparator<'a> {
139    pub attempt: u64,
140    pub max_attempts: u64,
141    pub exit_code: &'a str,
142    pub oom_killed: &'a str,
143    pub container_name: &'a str,
144    pub command: Option<&'a str>,
145    /// Random `--on-kill-resume-delay` wait before the resume (issue #181).
146    pub delay_ms: u64,
147}
148
149/// The `[Recovery k/N]` separator written into the session log.
150pub fn format_recovery_separator(params: &RecoverySeparator) -> String {
151    let described = describe_exit_code_str(params.exit_code);
152    let code = described
153        .code
154        .map(|code| code.to_string())
155        .unwrap_or_else(|| "unknown".to_string());
156    let signal = described
157        .signal
158        .map(|name| format!(", {}", name))
159        .unwrap_or_default();
160    let what = match params.command {
161        Some(command) => format!("running recovery command: {}", command),
162        None => "running the original command again".to_string(),
163    };
164    let delay = if params.delay_ms > 0 {
165        format!(" after a {} delay", format_recovery_delay(params.delay_ms))
166    } else {
167        String::new()
168    };
169    format!(
170        "\n[Recovery {}/{}] Main process was killed (exit {}{}, oomKilled={}); resuming container {}{}, {}\n",
171        params.attempt,
172        params.max_attempts,
173        code,
174        signal,
175        normalize_bool(params.oom_killed) == Some(true),
176        params.container_name,
177        delay,
178        what
179    )
180}
181
182/// The docker facts the watcher hands over.
183#[derive(Debug, Clone, Default)]
184pub struct RecoveryFacts {
185    pub exit_code: String,
186    pub oom_killed: String,
187    pub started_at: String,
188    pub finished_at: String,
189    pub container_error: String,
190    /// The watcher's last cgroup v2 sample (issue #182).
191    pub cgroup_memory: String,
192}
193
194/// Outcome of a recovery attempt; `recovered` is true only when the container
195/// was started again.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct RecoveryOutcome {
198    pub recovered: bool,
199    pub reason: String,
200    pub attempt: Option<u64>,
201    /// The `--on-kill-resume-delay` wait, when one is configured (issue #181).
202    pub delay_ms: Option<u64>,
203}
204
205impl RecoveryOutcome {
206    fn not(reason: &str) -> Self {
207        RecoveryOutcome {
208            recovered: false,
209            reason: reason.to_string(),
210            attempt: None,
211            delay_ms: None,
212        }
213    }
214}
215
216/// Where the `--on-kill-resume-delay` wait gets its randomness and sleeps;
217/// tests swap both out (issue #181).
218pub struct RecoveryDelayHooks<'a> {
219    pub random: &'a dyn Fn() -> f64,
220    pub sleep: &'a dyn Fn(u64),
221}
222
223impl Default for RecoveryDelayHooks<'_> {
224    fn default() -> Self {
225        RecoveryDelayHooks {
226            random: &system_random,
227            sleep: &system_sleep,
228        }
229    }
230}
231
232fn is_stop_requested(store: &ExecutionStore, execution_id: &str) -> bool {
233    store
234        .get(execution_id)
235        .is_some_and(|record| option_str(&record, "stopRequestedAt").is_some())
236}
237
238/// Starts the completion watcher that follows a resumed container.
239pub type WatcherStarter<'a> = &'a dyn Fn(&str, &ExecutionRecord, &DockerWatcherOptions);
240
241fn append_to_log(log_path: &str, text: &str) {
242    if log_path.is_empty() {
243        return;
244    }
245    // The log is best-effort here: recovery itself must still proceed.
246    if let Ok(mut file) = fs::OpenOptions::new()
247        .create(true)
248        .append(true)
249        .open(log_path)
250    {
251        let _ = file.write_all(text.as_bytes());
252    }
253}
254
255fn failure_detail(result: &CommandRunOutput) -> String {
256    let stderr = result.stderr.trim();
257    if !stderr.is_empty() {
258        return stderr.to_string();
259    }
260    result.error.clone().unwrap_or_else(|| {
261        format!(
262            "exit code {}",
263            result
264                .status
265                .map(|code| code.to_string())
266                .unwrap_or_else(|| "unknown".to_string())
267        )
268    })
269}
270
271fn write_recovery_marker<R: CommandRunner + ?Sized>(
272    container_name: &str,
273    attempt: u64,
274    runner: &R,
275) -> CommandRunOutput {
276    let dir = std::env::temp_dir().join(format!("start-recovery-{}", uuid::Uuid::new_v4()));
277    let file = dir.join("marker");
278    let written = fs::create_dir_all(&dir).and_then(|_| fs::write(&file, format!("{}\n", attempt)));
279    #[cfg(unix)]
280    {
281        use std::os::unix::fs::PermissionsExt;
282        let _ = fs::set_permissions(&file, fs::Permissions::from_mode(0o644));
283    }
284    let result = match written {
285        Ok(()) => runner.run(
286            &docker_command().to_string_lossy(),
287            &[
288                "cp".to_string(),
289                file.to_string_lossy().to_string(),
290                format!("{}:{}", container_name, RECOVERY_MARKER_PATH),
291            ],
292        ),
293        Err(err) => CommandRunOutput {
294            success: false,
295            error: Some(err.to_string()),
296            ..CommandRunOutput::default()
297        },
298    };
299    let _ = fs::remove_dir_all(&dir);
300    result
301}
302
303fn option_u64(record: &ExecutionRecord, key: &str) -> u64 {
304    match record.options.get(key) {
305        Some(Value::Number(n)) => n.as_u64().unwrap_or(0),
306        Some(Value::String(s)) => s.trim().parse().unwrap_or(0),
307        _ => 0,
308    }
309}
310
311fn option_str<'a>(record: &'a ExecutionRecord, key: &str) -> Option<&'a str> {
312    record
313        .options
314        .get(key)
315        .and_then(Value::as_str)
316        .filter(|value| !value.is_empty())
317}
318
319/// Resume a killed execution in its own container.
320pub fn recover_killed_execution<R: CommandRunner + ?Sized>(
321    store: &ExecutionStore,
322    execution_id: &str,
323    facts: &RecoveryFacts,
324    runner: &R,
325    start_watcher: WatcherStarter,
326) -> RecoveryOutcome {
327    recover_killed_execution_with_delay(
328        store,
329        execution_id,
330        facts,
331        runner,
332        start_watcher,
333        &RecoveryDelayHooks::default(),
334    )
335}
336
337/// [`recover_killed_execution`] after the random `--on-kill-resume-delay`
338/// wait (issue #181), with injectable randomness and sleep.
339pub fn recover_killed_execution_with_delay<R: CommandRunner + ?Sized>(
340    store: &ExecutionStore,
341    execution_id: &str,
342    facts: &RecoveryFacts,
343    runner: &R,
344    start_watcher: WatcherStarter,
345    delay: &RecoveryDelayHooks,
346) -> RecoveryOutcome {
347    if execution_id.is_empty() {
348        return RecoveryOutcome::not("missing-arguments");
349    }
350    let Some(mut record) = store.get(execution_id) else {
351        return RecoveryOutcome::not("record-not-found");
352    };
353    let max_attempts = option_u64(&record, "onKillResume");
354    let container_name = option_str(&record, "sessionName").map(str::to_string);
355    let Some(container_name) = container_name
356        .filter(|_| option_str(&record, "isolated") == Some("docker") && max_attempts >= 1)
357    else {
358        return RecoveryOutcome::not("not-configured");
359    };
360    if !is_killed_exit(&facts.exit_code, &facts.oom_killed) {
361        return RecoveryOutcome::not("not-killed");
362    }
363    let log_path = record.log_path.clone();
364    let log = |text: &str| append_to_log(&log_path, text);
365    if option_str(&record, "stopRequestedAt").is_some() {
366        log("\n[Recovery] Not resuming: the session was stopped on request.\n");
367        return RecoveryOutcome::not("stop-requested");
368    }
369    let used = option_u64(&record, "recoveryAttempts");
370    if used >= max_attempts {
371        log(&format!(
372            "\n[Recovery] Not resuming: all {} recovery attempt(s) used.\n",
373            max_attempts
374        ));
375        return RecoveryOutcome::not("attempts-exhausted");
376    }
377
378    let attempt = used + 1;
379    let delay_range = record_on_kill_resume_delay(record.options.get("onKillResumeDelay"));
380    let delay_ms = pick_recovery_delay_ms(delay_range.as_deref(), delay.random);
381    let give_up = |reason: String| {
382        log(&format!(
383            "[Recovery {}/{}] Failed: {}\n",
384            attempt, max_attempts, reason
385        ));
386        RecoveryOutcome {
387            recovered: false,
388            reason: "resume-failed".to_string(),
389            attempt: Some(attempt),
390            delay_ms: None,
391        }
392    };
393
394    let oom_killed = normalize_bool(&facts.oom_killed);
395    log(&format!(
396        "\n{}",
397        format_container_post_mortem(&ContainerPostMortem {
398            container_name: container_name.clone(),
399            exit_code: describe_exit_code_str(&facts.exit_code).code,
400            oom_killed,
401            started_at: Some(facts.started_at.clone()),
402            finished_at: Some(facts.finished_at.clone()),
403            error: normalize_container_error(&facts.container_error),
404        })
405    ));
406    let memory_line = format_cgroup_memory_log_line(&facts.cgroup_memory).unwrap_or_else(|| {
407        let limit = runner.run(&docker_command().to_string_lossy(), &["inspect".into(),"-f".into(),"{{.HostConfig.Memory}}".into(),container_name.clone()]);
408        format!("Memory:     unavailable (local cgroup hidden; remote sampling requires private cgroup namespace and sh) memory.limit={} (HostConfig)", if limit.success {limit.stdout.trim()} else {"unknown"})
409    });
410    log(&format!("{}\n", memory_line));
411    let recovery_command = option_str(&record, "recoveryCommand").map(str::to_string);
412    log(&format_recovery_separator(&RecoverySeparator {
413        attempt,
414        max_attempts,
415        exit_code: &facts.exit_code,
416        oom_killed: &facts.oom_killed,
417        container_name: &container_name,
418        command: recovery_command.as_deref(),
419        delay_ms,
420    }));
421
422    // Executions killed by one host-wide OOM event must not all come back in
423    // the same second (issue #181). `--stop` during the wait cancels the
424    // resume: the container has already exited, so `docker stop` only leaves
425    // the `stopRequestedAt` marker this loop polls for.
426    if delay_ms > 0
427        && wait_for_recovery_delay(delay_ms, delay.sleep, &|| {
428            is_stop_requested(store, execution_id)
429        })
430    {
431        log(&format!(
432            "[Recovery {}/{}] Not resuming: the session was stopped on request during the delay.\n",
433            attempt, max_attempts
434        ));
435        return RecoveryOutcome {
436            recovered: false,
437            reason: "stop-requested".to_string(),
438            attempt: Some(attempt),
439            delay_ms: Some(delay_ms),
440        };
441    }
442
443    // Docker keeps `docker update` limits in the HostConfig across restarts;
444    // they are read back so the log and `--status` show what the resumed run
445    // is held to, and so a later relaunch can re-apply them.
446    let mut resource_limits = crate::resume_resources::restore_base_cpu(
447        read_docker_resource_limits(&container_name, runner).unwrap_or_else(|| {
448            crate::docker_resource_limits::normalize_resource_limits(
449                record.options.get("resourceLimits"),
450            )
451        }),
452        &record,
453    );
454    if record.options.contains_key("cpuPenaltyConfig") {
455        resource_limits =
456            match crate::resume_resources::clamp_cpu_to_daemon(resource_limits, runner) {
457                Ok(limits) => limits,
458                Err(error) => return give_up(error),
459            };
460    }
461    let tail = crate::execution_attempt::read_attempt_log_tail(
462        &record,
463        crate::isolation::isolation_log::FATAL_MARKER_TAIL_BYTES,
464    );
465    let (logged_main, daemon_restart) = crate::exit_evidence::from_log(tail.as_deref());
466    let terminal_oom = !daemon_restart
467        && (logged_main
468            || (facts.oom_killed == "true"
469                && crate::exit_evidence::recent_oom_delta(
470                    &facts.cgroup_memory,
471                    &facts.finished_at,
472                )));
473    let memory_spec = option_str(&record, "onKillResumeMemory").map(str::to_string);
474    let recovery_limits = if terminal_oom {
475        memory_spec.as_ref().map(|spec| {
476            crate::docker_resource_options::resolve(
477                &json!({"memory":spec}),
478                &resource_limits,
479                runner,
480                crate::docker_resource_options::random_fraction,
481            )
482        })
483    } else {
484        None
485    };
486    let resolved = match recovery_limits {
487        Some(Ok((limits, values))) => {
488            log(&format!(
489                "Recovery memory limit: {} -> {} ({})\n",
490                resource_limits
491                    .iter()
492                    .find(|s| s.starts_with("--memory="))
493                    .map(String::as_str)
494                    .unwrap_or("unlimited"),
495                values["memory"],
496                memory_spec.as_deref().unwrap_or("")
497            ));
498            resource_limits = limits;
499            Some(values)
500        }
501        Some(Err(error)) => return give_up(format!("recovery limit failed: {}", error)),
502        None => None,
503    };
504    if let Some(line) = build_resource_limits_status_line(&resource_limits) {
505        log(&format!("{}\n", line));
506    }
507
508    if record
509        .options
510        .get("commandHandoff")
511        .and_then(Value::as_bool)
512        == Some(true)
513    {
514        // `docker start` keeps the creation env; refresh START_ATTEMPT (#230).
515        // Non-fatal: the command still runs, with the previous START_ATTEMPT.
516        let _ = crate::docker_command_handoff::write_execution_env_handoff(
517            &container_name,
518            &crate::execution_env::build_execution_env(
519                Some(&record.uuid),
520                Some(&container_name),
521                crate::execution_attempt::next_attempt_number(&record),
522            ),
523            runner,
524        );
525    }
526
527    if recovery_command.is_some() {
528        let copied = if record
529            .options
530            .get("commandHandoff")
531            .and_then(Value::as_bool)
532            == Some(true)
533        {
534            crate::docker_command_handoff::write_command_handoff(
535                &container_name,
536                &format!(
537                    "export {}={}; {}",
538                    RECOVERY_ATTEMPT_ENV,
539                    attempt,
540                    recovery_command.as_deref().unwrap()
541                ),
542                option_str(&record, "shell").unwrap_or("auto"),
543                record.options.get("keepAlive").and_then(Value::as_bool) == Some(true),
544                runner,
545            )
546            .unwrap_or_else(|error| CommandRunOutput {
547                error: Some(error),
548                ..Default::default()
549            })
550        } else {
551            write_recovery_marker(&container_name, attempt, runner)
552        };
553        if !copied.success {
554            return give_up(format!(
555                "could not mark the container: {}",
556                failure_detail(&copied)
557            ));
558        }
559    }
560
561    let since = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
562    let mut next_attempt = record.attempt.as_ref().map(|_| {
563        crate::execution_attempt::create_attempt(&record, "automatic-recovery", &container_name)
564    });
565    if let Some(attempt) = next_attempt.as_mut() {
566        attempt.started_at = since.clone();
567        let mut lifecycle = record.clone();
568        lifecycle.attempt = Some(attempt.clone());
569        crate::execution_attempt::append_lifecycle(&lifecycle, "resume-started", json!({}));
570    }
571    let described = describe_exit_code_str(&facts.exit_code);
572    record.options.insert(
573        "exitEvidence".into(),
574        crate::exit_evidence::evidence_json(tail.as_deref(), terminal_oom),
575    );
576    if let Some(attempt) = next_attempt.as_mut() {
577        record.status = ExecutionStatus::Executed;
578        record.exit_code = described.code;
579        record.end_time = normalize_docker_timestamp(Some(&facts.finished_at));
580        record.container_started_at = normalize_docker_timestamp(Some(&facts.started_at));
581        record.oom_killed = oom_killed;
582        record.cgroup_memory = parse_cgroup_memory_sample(&facts.cgroup_memory);
583        crate::execution_attempt::archive_attempt(&mut record);
584
585        record.attempt = Some(attempt.clone());
586    }
587    let mut history = record
588        .options
589        .get("recoveryHistory")
590        .and_then(Value::as_array)
591        .cloned()
592        .unwrap_or_default();
593    let delayed = if delay_range.is_some() {
594        format!(", delayMs={}", delay_ms)
595    } else {
596        String::new()
597    };
598    // The killed run's own counters: the resumed run starts a fresh cgroup.
599    let counted = parse_cgroup_memory_sample(&facts.cgroup_memory)
600        .map(|memory| {
601            [
602                ("oomEvents", memory.oom_events),
603                ("oomKills", memory.oom_kills),
604            ]
605            .iter()
606            .filter_map(|(field, value)| value.map(|value| format!(", {}={}", field, value)))
607            .collect::<String>()
608        })
609        .unwrap_or_default();
610    history.push(json!(format!(
611        "{}: exit {}, oomKilled={}{}{}, resumed at {}",
612        attempt,
613        described
614            .code
615            .map(|code| code.to_string())
616            .unwrap_or_else(|| "unknown".to_string()),
617        oom_killed == Some(true),
618        counted,
619        delayed,
620        since
621    )));
622    let options = &mut record.options;
623    if let Some(values) = &resolved {
624        let mut combined = options
625            .get("resolvedLimits")
626            .filter(|v| v.is_object())
627            .cloned()
628            .unwrap_or_else(|| json!({}));
629        for (k, v) in values.as_object().unwrap() {
630            combined[k] = v.clone();
631        }
632        options.insert("resolvedLimits".into(), combined);
633        let mut specs = options
634            .get("resourceLimitSpecs")
635            .filter(|v| v.is_object())
636            .cloned()
637            .unwrap_or_else(|| json!({}));
638        specs["memory"] = json!(memory_spec);
639        options.insert("resourceLimitSpecs".into(), specs);
640    }
641    options.remove("exitEvidence");
642    options.insert("recoveryAttempts".to_string(), json!(attempt));
643    options.insert("recoveryHistory".to_string(), Value::Array(history));
644    options.insert("lastRecoveryAt".to_string(), json!(since));
645    if delay_range.is_some() {
646        options.insert("lastRecoveryDelayMs".to_string(), json!(delay_ms));
647    }
648    if !resource_limits.is_empty() {
649        options.insert("resourceLimits".to_string(), json!(&resource_limits));
650    }
651    record.status = ExecutionStatus::Executing;
652    record.exit_code = None;
653    record.end_time = None;
654    record.exit_reason = None;
655    record.oom_killed = None;
656    record.cgroup_memory = None;
657    record.memory_exhausted = None;
658    record.memory_exhausted_reason = None;
659    record.end_time_source = None;
660    record.observed_at = None;
661    record.stale_detected_at = None;
662    record.container_started_at = None;
663    record.container_state = None;
664    record.container_removed_observed_at = None;
665    let previous = store.get(execution_id).unwrap_or_else(|| record.clone());
666    crate::launch_owner::mark_launch(&mut record);
667    if let Err(error) = store.reserve_launch(&record, &previous) {
668        return give_up(format!("launch reservation failed: {}", error));
669    }
670    if resolved.is_some() || record.options.contains_key("cpuPenaltyConfig") {
671        let mut args = vec!["update".into()];
672        args.extend(
673            resource_limits
674                .iter()
675                .filter(|s| {
676                    [
677                        "--memory=",
678                        "--memory-swap=",
679                        "--memory-reservation=",
680                        "--cpus=",
681                        "--cpu-quota=",
682                        "--cpu-period=",
683                    ]
684                    .iter()
685                    .any(|f| s.starts_with(f))
686                })
687                .cloned(),
688        );
689        args.push(container_name.clone());
690        let updated = runner.run(&docker_command().to_string_lossy(), &args);
691        if !updated.success {
692            let _ = store.save(&previous);
693            return give_up(format!(
694                "docker update failed: {}",
695                failure_detail(&updated)
696            ));
697        }
698    }
699    let started = runner.run(
700        &docker_command().to_string_lossy(),
701        &["start".to_string(), container_name.clone()],
702    );
703    if !started.success {
704        if let Some(attempt) = next_attempt.as_ref() {
705            let mut lifecycle = record.clone();
706            lifecycle.attempt = Some(attempt.clone());
707            crate::execution_attempt::append_lifecycle(
708                &lifecycle,
709                "launch-failed",
710                json!({"error": failure_detail(&started)}),
711            );
712        }
713        let _ = store.save(&previous);
714        return give_up(format!("docker start failed: {}", failure_detail(&started)));
715    }
716
717    if record.options.contains_key("cpuPenaltyConfig") {
718        let mut resolved = record
719            .options
720            .get("resolvedLimits")
721            .filter(|v| v.is_object())
722            .cloned()
723            .unwrap_or(json!({}));
724        resolved["cpus"] = json!(crate::docker_resource_limits::cpu_count(&resource_limits));
725        record.options.insert("resolvedLimits".into(), resolved);
726    }
727    record.options.insert("launchPending".into(), json!(false));
728    if let Some(attempt) = record.attempt.as_mut() {
729        attempt.launch_accepted_at = Some(Utc::now().to_rfc3339());
730    }
731    let persistence_error = store.save(&record).err();
732
733    if next_attempt.is_some() {
734        crate::execution_attempt::append_lifecycle(&record, "launch-accepted", json!({}));
735    }
736    start_watcher(
737        &container_name,
738        &record,
739        &DockerWatcherOptions {
740            since: Some(since),
741            recover_on_kill: true,
742            attempt_number: next_attempt.as_ref().map(|attempt| attempt.number),
743        },
744    );
745    if let Some(attempt) = next_attempt.filter(|_| persistence_error.is_none()) {
746        if let Ok(Some(current)) = store.patch_attempt(
747            &record.uuid,
748            attempt.number,
749            json!({"watcherAttachedAt": Utc::now().to_rfc3339()}),
750        ) {
751            crate::execution_attempt::append_lifecycle(&current, "watcher-attached", json!({}));
752        }
753    }
754    if let Some(error) = persistence_error {
755        let stopped = runner.run(
756            &docker_command().to_string_lossy(),
757            &["stop".into(), container_name.clone()],
758        );
759        if stopped.success {
760            record.status = ExecutionStatus::Executed;
761            record.exit_code = Some(-1);
762            record.end_time = Some(Utc::now().to_rfc3339());
763            record.options.insert("launchPending".into(), json!(false));
764            let _ = store.save(&record);
765        }
766        let warning = json!({"code": "LAUNCH_PERSISTENCE_FAILED", "uuid": execution_id,
767            "containerName": container_name, "running": !stopped.success, "error": error})
768        .to_string();
769        log(&format!("[Recovery] {}\n", warning));
770        return RecoveryOutcome::not(&warning);
771    }
772    RecoveryOutcome {
773        recovered: true,
774        reason: "resumed".to_string(),
775        attempt: Some(attempt),
776        delay_ms: delay_range.map(|_| delay_ms),
777    }
778}
779
780/// The real watcher: a detached shell following the resumed container.
781pub fn start_system_watcher(
782    container_name: &str,
783    record: &ExecutionRecord,
784    watcher: &DockerWatcherOptions,
785) {
786    let log_path = (!record.log_path.is_empty()).then(|| PathBuf::from(&record.log_path));
787    let _ = start_detached_docker_completion_watcher_with(
788        container_name,
789        get_docker_container_cleanup_policy(&build_launch_options(record)),
790        log_path.as_ref(),
791        Some(record.uuid.as_str()),
792        watcher,
793    );
794}
795
796/// Entry point for `--internal-recover-detached-docker <uuid> <exit> <oom>
797/// <started> <finished> <error> <cgroup>`. Returns 0 only when the container was
798/// resumed; any other outcome lets the watcher continue with its normal
799/// cleanup, footer and finalization.
800pub fn run_internal_recover(args: &[String]) -> i32 {
801    let execution_id = match args.first() {
802        Some(value) if !value.is_empty() => value.clone(),
803        _ => return 1,
804    };
805    if std::env::var("START_DISABLE_TRACKING").as_deref() == Ok("true") {
806        return 1;
807    }
808    let field = |index: usize| args.get(index).cloned().unwrap_or_default();
809    let store = ExecutionStore::with_options(ExecutionStoreOptions {
810        app_folder: std::env::var("START_APP_FOLDER").ok().map(PathBuf::from),
811        ..ExecutionStoreOptions::default()
812    });
813    let outcome = recover_killed_execution(
814        &store,
815        &execution_id,
816        &RecoveryFacts {
817            exit_code: field(1),
818            oom_killed: field(2),
819            started_at: field(3),
820            finished_at: field(4),
821            container_error: field(5),
822            cgroup_memory: field(6),
823        },
824        &SystemCommandRunner,
825        &start_system_watcher,
826    );
827    if outcome.recovered {
828        0
829    } else {
830        1
831    }
832}