1use std::fs;
22use std::io::Write;
23use std::path::PathBuf;
24
25use chrono::{SecondsFormat, Utc};
26use serde_json::{json, Value};
27
28use crate::detached_finalize::{normalize_bool, normalize_container_error};
29use crate::docker_cleanup::{
30 docker_command, get_docker_container_cleanup_policy,
31 start_detached_docker_completion_watcher_with, DockerWatcherOptions,
32};
33use crate::docker_post_mortem::{format_container_post_mortem, shell_vars, ContainerPostMortem};
34use crate::docker_resource_limits::{
35 build_resource_limits_status_line, read_docker_resource_limits,
36};
37use crate::execution_control::{CommandRunOutput, CommandRunner, SystemCommandRunner};
38use crate::execution_resume::build_launch_options;
39use crate::execution_store::{
40 ExecutionRecord, ExecutionStatus, ExecutionStore, ExecutionStoreOptions,
41};
42use crate::exit_reason::describe_exit_code_str;
43use crate::isolation::isolation_log::shell_quote;
44
45pub const INTERNAL_RECOVER_FLAG: &str = "--internal-recover-detached-docker";
47
48pub const RECOVERY_MARKER_PATH: &str = "/.start-command-recovery";
50
51pub const RECOVERY_ATTEMPT_ENV: &str = "START_COMMAND_RECOVERY_ATTEMPT";
53
54pub const KILLED_EXIT_CODE: i32 = 137;
56
57pub fn recovery_selector() -> String {
60 format!(
61 "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 \"$@\"",
62 marker = RECOVERY_MARKER_PATH,
63 env = RECOVERY_ATTEMPT_ENV
64 )
65}
66
67pub fn build_recovery_selector_args(
70 main_args: &[String],
71 shell: &str,
72 shell_flag: Option<&str>,
73 recovery_command: &str,
74) -> Vec<String> {
75 let mut args = vec![
76 "sh".to_string(),
77 "-c".to_string(),
78 recovery_selector(),
79 "start-command".to_string(),
80 if shell.is_empty() { "sh" } else { shell }.to_string(),
81 shell_flag.unwrap_or("").to_string(),
82 recovery_command.to_string(),
83 ];
84 args.extend(main_args.iter().cloned());
85 args
86}
87
88pub fn is_killed_exit(exit_code: &str, oom_killed: &str) -> bool {
90 describe_exit_code_str(exit_code).code == Some(KILLED_EXIT_CODE)
91 || normalize_bool(oom_killed) == Some(true)
92}
93
94pub fn build_recovery_snippet(execution_id: &str) -> String {
97 let executable = std::env::current_exe()
98 .map(|path| path.to_string_lossy().to_string())
99 .unwrap_or_else(|_| "start".to_string());
100 format!(
101 "{{ [ \"${exit}\" = {code} ] || [ \"${oom}\" = true ]; }} && {exe} {flag} {id} \"${exit}\" \"${oom}\" \"${started}\" \"${finished}\" \"${error}\" >/dev/null 2>&1",
102 exit = shell_vars::EXIT,
103 oom = shell_vars::OOM,
104 started = shell_vars::STARTED,
105 finished = shell_vars::FINISHED,
106 error = shell_vars::ERROR,
107 code = KILLED_EXIT_CODE,
108 exe = shell_quote(&executable),
109 flag = INTERNAL_RECOVER_FLAG,
110 id = shell_quote(execution_id),
111 )
112}
113
114pub struct RecoverySeparator<'a> {
116 pub attempt: u64,
117 pub max_attempts: u64,
118 pub exit_code: &'a str,
119 pub oom_killed: &'a str,
120 pub container_name: &'a str,
121 pub command: Option<&'a str>,
122}
123
124pub fn format_recovery_separator(params: &RecoverySeparator) -> String {
126 let described = describe_exit_code_str(params.exit_code);
127 let code = described
128 .code
129 .map(|code| code.to_string())
130 .unwrap_or_else(|| "unknown".to_string());
131 let signal = described
132 .signal
133 .map(|name| format!(", {}", name))
134 .unwrap_or_default();
135 let what = match params.command {
136 Some(command) => format!("running recovery command: {}", command),
137 None => "running the original command again".to_string(),
138 };
139 format!(
140 "\n[Recovery {}/{}] Main process was killed (exit {}{}, oomKilled={}); resuming container {}, {}\n",
141 params.attempt,
142 params.max_attempts,
143 code,
144 signal,
145 normalize_bool(params.oom_killed) == Some(true),
146 params.container_name,
147 what
148 )
149}
150
151#[derive(Debug, Clone, Default)]
153pub struct RecoveryFacts {
154 pub exit_code: String,
155 pub oom_killed: String,
156 pub started_at: String,
157 pub finished_at: String,
158 pub container_error: String,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct RecoveryOutcome {
165 pub recovered: bool,
166 pub reason: String,
167 pub attempt: Option<u64>,
168}
169
170impl RecoveryOutcome {
171 fn not(reason: &str) -> Self {
172 RecoveryOutcome {
173 recovered: false,
174 reason: reason.to_string(),
175 attempt: None,
176 }
177 }
178}
179
180pub type WatcherStarter<'a> = &'a dyn Fn(&str, &ExecutionRecord, &DockerWatcherOptions);
182
183fn append_to_log(log_path: &str, text: &str) {
184 if log_path.is_empty() {
185 return;
186 }
187 if let Ok(mut file) = fs::OpenOptions::new()
189 .create(true)
190 .append(true)
191 .open(log_path)
192 {
193 let _ = file.write_all(text.as_bytes());
194 }
195}
196
197fn failure_detail(result: &CommandRunOutput) -> String {
198 let stderr = result.stderr.trim();
199 if !stderr.is_empty() {
200 return stderr.to_string();
201 }
202 result.error.clone().unwrap_or_else(|| {
203 format!(
204 "exit code {}",
205 result
206 .status
207 .map(|code| code.to_string())
208 .unwrap_or_else(|| "unknown".to_string())
209 )
210 })
211}
212
213fn write_recovery_marker<R: CommandRunner + ?Sized>(
214 container_name: &str,
215 attempt: u64,
216 runner: &R,
217) -> CommandRunOutput {
218 let dir = std::env::temp_dir().join(format!("start-recovery-{}", uuid::Uuid::new_v4()));
219 let file = dir.join("marker");
220 let written = fs::create_dir_all(&dir).and_then(|_| fs::write(&file, format!("{}\n", attempt)));
221 #[cfg(unix)]
222 {
223 use std::os::unix::fs::PermissionsExt;
224 let _ = fs::set_permissions(&file, fs::Permissions::from_mode(0o644));
225 }
226 let result = match written {
227 Ok(()) => runner.run(
228 &docker_command().to_string_lossy(),
229 &[
230 "cp".to_string(),
231 file.to_string_lossy().to_string(),
232 format!("{}:{}", container_name, RECOVERY_MARKER_PATH),
233 ],
234 ),
235 Err(err) => CommandRunOutput {
236 success: false,
237 error: Some(err.to_string()),
238 ..CommandRunOutput::default()
239 },
240 };
241 let _ = fs::remove_dir_all(&dir);
242 result
243}
244
245fn option_u64(record: &ExecutionRecord, key: &str) -> u64 {
246 match record.options.get(key) {
247 Some(Value::Number(n)) => n.as_u64().unwrap_or(0),
248 Some(Value::String(s)) => s.trim().parse().unwrap_or(0),
249 _ => 0,
250 }
251}
252
253fn option_str<'a>(record: &'a ExecutionRecord, key: &str) -> Option<&'a str> {
254 record
255 .options
256 .get(key)
257 .and_then(Value::as_str)
258 .filter(|value| !value.is_empty())
259}
260
261pub fn recover_killed_execution<R: CommandRunner + ?Sized>(
263 store: &ExecutionStore,
264 execution_id: &str,
265 facts: &RecoveryFacts,
266 runner: &R,
267 start_watcher: WatcherStarter,
268) -> RecoveryOutcome {
269 if execution_id.is_empty() {
270 return RecoveryOutcome::not("missing-arguments");
271 }
272 let Some(mut record) = store.get(execution_id) else {
273 return RecoveryOutcome::not("record-not-found");
274 };
275 let max_attempts = option_u64(&record, "onKillResume");
276 let container_name = option_str(&record, "sessionName").map(str::to_string);
277 let Some(container_name) = container_name
278 .filter(|_| option_str(&record, "isolated") == Some("docker") && max_attempts >= 1)
279 else {
280 return RecoveryOutcome::not("not-configured");
281 };
282 if !is_killed_exit(&facts.exit_code, &facts.oom_killed) {
283 return RecoveryOutcome::not("not-killed");
284 }
285 let log_path = record.log_path.clone();
286 let log = |text: &str| append_to_log(&log_path, text);
287 if option_str(&record, "stopRequestedAt").is_some() {
288 log("\n[Recovery] Not resuming: the session was stopped on request.\n");
289 return RecoveryOutcome::not("stop-requested");
290 }
291 let used = option_u64(&record, "recoveryAttempts");
292 if used >= max_attempts {
293 log(&format!(
294 "\n[Recovery] Not resuming: all {} recovery attempt(s) used.\n",
295 max_attempts
296 ));
297 return RecoveryOutcome::not("attempts-exhausted");
298 }
299
300 let attempt = used + 1;
301 let give_up = |reason: String| {
302 log(&format!(
303 "[Recovery {}/{}] Failed: {}\n",
304 attempt, max_attempts, reason
305 ));
306 RecoveryOutcome {
307 recovered: false,
308 reason: "resume-failed".to_string(),
309 attempt: Some(attempt),
310 }
311 };
312
313 let oom_killed = normalize_bool(&facts.oom_killed);
314 log(&format!(
315 "\n{}",
316 format_container_post_mortem(&ContainerPostMortem {
317 container_name: container_name.clone(),
318 exit_code: describe_exit_code_str(&facts.exit_code).code,
319 oom_killed,
320 started_at: Some(facts.started_at.clone()),
321 finished_at: Some(facts.finished_at.clone()),
322 error: normalize_container_error(&facts.container_error),
323 })
324 ));
325 let recovery_command = option_str(&record, "recoveryCommand").map(str::to_string);
326 log(&format_recovery_separator(&RecoverySeparator {
327 attempt,
328 max_attempts,
329 exit_code: &facts.exit_code,
330 oom_killed: &facts.oom_killed,
331 container_name: &container_name,
332 command: recovery_command.as_deref(),
333 }));
334
335 let resource_limits = read_docker_resource_limits(&container_name, runner).unwrap_or_default();
339 if let Some(line) = build_resource_limits_status_line(&resource_limits) {
340 log(&format!("{}\n", line));
341 }
342
343 if recovery_command.is_some() {
344 let copied = write_recovery_marker(&container_name, attempt, runner);
345 if !copied.success {
346 return give_up(format!(
347 "could not mark the container: {}",
348 failure_detail(&copied)
349 ));
350 }
351 }
352
353 let since = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
354 let started = runner.run(
355 &docker_command().to_string_lossy(),
356 &["start".to_string(), container_name.clone()],
357 );
358 if !started.success {
359 return give_up(format!("docker start failed: {}", failure_detail(&started)));
360 }
361
362 let described = describe_exit_code_str(&facts.exit_code);
363 let mut history = record
364 .options
365 .get("recoveryHistory")
366 .and_then(Value::as_array)
367 .cloned()
368 .unwrap_or_default();
369 history.push(json!(format!(
370 "{}: exit {}, oomKilled={}, resumed at {}",
371 attempt,
372 described
373 .code
374 .map(|code| code.to_string())
375 .unwrap_or_else(|| "unknown".to_string()),
376 oom_killed == Some(true),
377 since
378 )));
379 let options = &mut record.options;
380 options.insert("recoveryAttempts".to_string(), json!(attempt));
381 options.insert("recoveryHistory".to_string(), Value::Array(history));
382 options.insert("lastRecoveryAt".to_string(), json!(since));
383 if !resource_limits.is_empty() {
384 options.insert("resourceLimits".to_string(), json!(resource_limits));
385 }
386 record.status = ExecutionStatus::Executing;
387 record.exit_code = None;
388 record.end_time = None;
389 record.exit_reason = None;
390 record.oom_killed = None;
391 let _ = store.save(&record);
394
395 start_watcher(
396 &container_name,
397 &record,
398 &DockerWatcherOptions {
399 since: Some(since),
400 recover_on_kill: true,
401 },
402 );
403 RecoveryOutcome {
404 recovered: true,
405 reason: "resumed".to_string(),
406 attempt: Some(attempt),
407 }
408}
409
410pub fn start_system_watcher(
412 container_name: &str,
413 record: &ExecutionRecord,
414 watcher: &DockerWatcherOptions,
415) {
416 let log_path = (!record.log_path.is_empty()).then(|| PathBuf::from(&record.log_path));
417 start_detached_docker_completion_watcher_with(
418 container_name,
419 get_docker_container_cleanup_policy(&build_launch_options(record)),
420 log_path.as_ref(),
421 Some(record.uuid.as_str()),
422 watcher,
423 );
424}
425
426pub fn run_internal_recover(args: &[String]) -> i32 {
431 let execution_id = match args.first() {
432 Some(value) if !value.is_empty() => value.clone(),
433 _ => return 1,
434 };
435 if std::env::var("START_DISABLE_TRACKING").as_deref() == Ok("true") {
436 return 1;
437 }
438 let field = |index: usize| args.get(index).cloned().unwrap_or_default();
439 let store = ExecutionStore::with_options(ExecutionStoreOptions {
440 app_folder: std::env::var("START_APP_FOLDER").ok().map(PathBuf::from),
441 ..ExecutionStoreOptions::default()
442 });
443 let outcome = recover_killed_execution(
444 &store,
445 &execution_id,
446 &RecoveryFacts {
447 exit_code: field(1),
448 oom_killed: field(2),
449 started_at: field(3),
450 finished_at: field(4),
451 container_error: field(5),
452 },
453 &SystemCommandRunner,
454 &start_system_watcher,
455 );
456 if outcome.recovered {
457 0
458 } else {
459 1
460 }
461}