1use 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
55pub const INTERNAL_RECOVER_FLAG: &str = "--internal-recover-detached-docker";
57
58pub const RECOVERY_MARKER_PATH: &str = "/.start-command-recovery";
60
61pub const RECOVERY_ATTEMPT_ENV: &str = "START_COMMAND_RECOVERY_ATTEMPT";
63
64pub const KILLED_EXIT_CODE: i32 = 137;
66
67pub 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
77pub 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
98pub 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
115pub 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
137pub 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 pub delay_ms: u64,
147}
148
149pub 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#[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 pub cgroup_memory: String,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct RecoveryOutcome {
198 pub recovered: bool,
199 pub reason: String,
200 pub attempt: Option<u64>,
201 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
216pub 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
238pub 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 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
319pub 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
337pub 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 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 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 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 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(¤t, "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
780pub 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
796pub 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}