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