Skip to content

Commit c1f4af9

Browse files
author
Roy Lin
committed
feat(compose): support depends_on condition service_completed_successfully
Docker Compose's service_completed_successfully waits for a dependency to run to completion (exit 0) before starting the dependent; a3s-box rejected it at config time. Now accepted, with boot-time gating: ComposeProject::completed_wait_deps lists such dependencies and compose up waits for them before booting the dependent. A dependency is 'completed' once it is no longer active — using a new zombie-aware process::is_process_exited (the detached shim becomes a zombie under compose-up when its VM halts, and kill(pid,0) would wrongly report it alive); a recorded non-zero exit code fails the wait. Verified on Linux: a dependent only starts after its init service has exited.
1 parent 1503fd2 commit c1f4af9

3 files changed

Lines changed: 167 additions & 3 deletions

File tree

src/cli/src/commands/compose.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,27 @@ async fn execute_up(
304304
println!(" ✓");
305305
}
306306

307+
// Wait for dependencies that must run to completion (exit 0) first.
308+
let completed_deps = project.completed_wait_deps(svc_name);
309+
if !completed_deps.is_empty() {
310+
print!(
311+
" [~] Waiting for {} to complete...",
312+
completed_deps.join(", ")
313+
);
314+
if let Err(error) =
315+
wait_for_completed(project_name, &completed_deps, up_args.timeout).await
316+
{
317+
return rollback_compose_up(
318+
&mut state,
319+
&started_services,
320+
&created_networks,
321+
error,
322+
)
323+
.await;
324+
}
325+
println!(" ✓");
326+
}
327+
307328
let mut box_config = match project.build_box_config(svc_name, Some(&default_net)) {
308329
Ok(config) => config,
309330
Err(error) => {
@@ -663,6 +684,73 @@ async fn wait_for_healthy(
663684
}
664685
}
665686

687+
/// Wait for dependency services to run to completion (Docker's
688+
/// `service_completed_successfully`).
689+
///
690+
/// A dependency is "completed" once it is no longer active — preferring the
691+
/// record's terminal status (set by the monitor) and falling back to shim-PID
692+
/// liveness for the daemonless case. If an exit code was recorded and is
693+
/// non-zero, the dependency failed and the wait errors.
694+
async fn wait_for_completed(
695+
project_name: &str,
696+
service_names: &[String],
697+
timeout_secs: u64,
698+
) -> Result<(), Box<dyn std::error::Error>> {
699+
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
700+
701+
loop {
702+
if std::time::Instant::now() > deadline {
703+
return Err(format!(
704+
"Timed out waiting for services to complete: {}",
705+
service_names.join(", ")
706+
)
707+
.into());
708+
}
709+
710+
let state = StateFile::load_default()?;
711+
let mut all_done = true;
712+
for svc_name in service_names {
713+
let records = state.find_by_label(LABEL_SERVICE, svc_name);
714+
let Some(record) = records
715+
.iter()
716+
.find(|r| r.labels.get(LABEL_PROJECT).map(String::as_str) == Some(project_name))
717+
else {
718+
all_done = false;
719+
continue;
720+
};
721+
722+
// A detached box's shim becomes a zombie under this process when its
723+
// VM halts; is_process_exited is zombie-aware (is_process_alive /
724+
// kill(pid,0) is not), so a completed dependency is detected.
725+
let exited = !status::is_active(record)
726+
|| record
727+
.pid
728+
.map(crate::process::is_process_exited)
729+
.unwrap_or(true);
730+
if !exited {
731+
all_done = false;
732+
continue;
733+
}
734+
735+
if let Some(code) = record.exit_code {
736+
if code != 0 {
737+
return Err(format!(
738+
"dependency service '{}' did not complete successfully (exit code {})",
739+
svc_name, code
740+
)
741+
.into());
742+
}
743+
}
744+
}
745+
746+
if all_done {
747+
return Ok(());
748+
}
749+
750+
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
751+
}
752+
}
753+
666754
// ============================================================================
667755
// compose down
668756
// ============================================================================

src/cli/src/process.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,38 @@ pub fn is_process_alive(pid: u32) -> bool {
2828
unsafe { libc::kill(pid as i32, 0) == 0 }
2929
}
3030

31+
/// Whether `pid` has exited, treating a zombie (an exited-but-unreaped child)
32+
/// as exited. Unlike [`is_process_alive`], whose `kill(pid, 0)` succeeds for a
33+
/// zombie, this inspects `/proc/<pid>/stat` on Linux so a detached box's shim —
34+
/// which becomes a zombie under its parent the moment the VM halts — is detected
35+
/// as completed rather than appearing to run forever.
36+
#[cfg(target_os = "linux")]
37+
pub fn is_process_exited(pid: u32) -> bool {
38+
match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
39+
// Format: "<pid> (<comm>) <state> ...". comm may contain spaces/parens,
40+
// so scan past the final ')'. Z (zombie) or X (dead) => exited.
41+
Ok(stat) => match stat.rfind(')') {
42+
Some(idx) => matches!(
43+
stat[idx + 1..].trim_start().chars().next(),
44+
Some('Z') | Some('X')
45+
),
46+
None => false,
47+
},
48+
// No /proc entry => the process is gone.
49+
Err(_) => true,
50+
}
51+
}
52+
53+
#[cfg(all(unix, not(target_os = "linux")))]
54+
pub fn is_process_exited(pid: u32) -> bool {
55+
!is_process_alive(pid)
56+
}
57+
58+
#[cfg(not(unix))]
59+
pub fn is_process_exited(pid: u32) -> bool {
60+
!is_process_alive(pid)
61+
}
62+
3163
#[cfg(windows)]
3264
pub fn is_process_alive(pid: u32) -> bool {
3365
use windows_sys::Win32::Foundation::STILL_ACTIVE;
@@ -192,6 +224,14 @@ mod tests {
192224
assert!(!is_process_alive(99999));
193225
}
194226

227+
#[test]
228+
fn test_is_process_exited_current_and_missing() {
229+
// The running test process has not exited.
230+
assert!(!is_process_exited(std::process::id()));
231+
// A PID with no process is treated as exited.
232+
assert!(is_process_exited(0x7fff_fffe));
233+
}
234+
195235
#[cfg(unix)]
196236
#[test]
197237
fn test_is_process_alive_parent_process() {

src/runtime/src/compose.rs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,23 @@ impl ComposeProject {
281281
}
282282
}
283283

284+
/// Dependencies this service must wait to run to completion (exit 0) before
285+
/// starting — `depends_on: { dep: { condition: service_completed_successfully } }`.
286+
pub fn completed_wait_deps(&self, service_name: &str) -> Vec<String> {
287+
let Some(svc) = self.config.services.get(service_name) else {
288+
return vec![];
289+
};
290+
291+
match &svc.depends_on {
292+
a3s_box_core::compose::DependsOn::Map(map) => map
293+
.iter()
294+
.filter(|(_, cond)| cond.condition == "service_completed_successfully")
295+
.map(|(name, _)| name.clone())
296+
.collect(),
297+
_ => vec![],
298+
}
299+
}
300+
284301
/// Get the health check config for a service, if defined.
285302
pub fn healthcheck(&self, service_name: &str) -> Option<HealthCheckSpec> {
286303
let svc = self.config.services.get(service_name)?;
@@ -419,10 +436,10 @@ fn validate_depends_on_conditions(
419436

420437
for (dep_name, condition) in map {
421438
match condition.condition.as_str() {
422-
"service_started" | "service_healthy" => {}
439+
"service_started" | "service_healthy" | "service_completed_successfully" => {}
423440
other => {
424441
return Err(BoxError::ConfigError(format!(
425-
"Service '{}' depends on '{}' with unsupported condition '{}' (supported: service_started, service_healthy)",
442+
"Service '{}' depends on '{}' with unsupported condition '{}' (supported: service_started, service_healthy, service_completed_successfully)",
426443
service_name, dep_name, other
427444
)));
428445
}
@@ -1056,6 +1073,25 @@ services:
10561073
assert!(project.healthcheck("db").is_none());
10571074
}
10581075

1076+
#[test]
1077+
fn test_service_completed_successfully_condition_accepted() {
1078+
let yaml = r#"
1079+
services:
1080+
web:
1081+
image: nginx
1082+
depends_on:
1083+
init:
1084+
condition: service_completed_successfully
1085+
init:
1086+
image: busybox
1087+
"#;
1088+
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
1089+
let project = ComposeProject::new("myapp", config).unwrap();
1090+
assert_eq!(project.completed_wait_deps("web"), vec!["init".to_string()]);
1091+
// `init` itself has no completion wait.
1092+
assert!(project.completed_wait_deps("init").is_empty());
1093+
}
1094+
10591095
#[test]
10601096
fn test_unsupported_depends_on_condition_rejected() {
10611097
let yaml = r#"
@@ -1064,7 +1100,7 @@ services:
10641100
image: nginx
10651101
depends_on:
10661102
db:
1067-
condition: service_completed_successfully
1103+
condition: service_bogus_condition
10681104
db:
10691105
image: postgres
10701106
"#;

0 commit comments

Comments
 (0)