diff --git a/src/orchard.rs b/src/orchard.rs index 0061142..c9c5fbb 100644 --- a/src/orchard.rs +++ b/src/orchard.rs @@ -154,7 +154,7 @@ fn beds_launchd(services: &[Service], exec_sets: &[(usize, ExecSet)], config: &C for (idx, exec) in exec_sets { let svc = &services[*idx]; let label = service_label(config, &svc.name); - let deps = build_dep_gates(svc, services); + let deps = build_dep_gates(config, svc, services); let mut arts = Vec::new(); if !deps.is_empty() || exec.stop.is_some() || exec.post_stop.is_some() { diff --git a/src/orchdi.rs b/src/orchdi.rs index cae3b0b..09fbbe2 100644 --- a/src/orchdi.rs +++ b/src/orchdi.rs @@ -50,6 +50,9 @@ pub struct SuperviseSpec { pub post_stop: Option, #[serde(default)] pub deps: Vec, + /// Absolute path of the ready marker a oneshot service writes on success (#45). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ready_marker: Option, /// Seconds to wait for graceful stop before SIGKILLing the process group. pub stop_timeout_secs: u32, } @@ -123,7 +126,17 @@ pub fn run(spec_path: &Path) -> i32 { match child.try_wait() { Ok(Some(status)) => { run_optional(&spec.post_stop); - return status.code().unwrap_or(0); + let code = status.code().unwrap_or(0); + // ONESHOT: on success, write the ready marker so REQUIRES deps + // waiting for completion can proceed (#45). Remove on failure. + if let Some(ref marker) = spec.ready_marker { + if code == 0 { + let _ = std::fs::write(marker, ""); + } else { + let _ = std::fs::remove_file(marker); + } + } + return code; } Ok(None) => std::thread::sleep(Duration::from_millis(100)), Err(e) => { @@ -267,7 +280,8 @@ pub fn parse_duration_secs(s: &str) -> Option { } else if let Some(n) = s.strip_suffix('m') { n.parse::().ok().map(|v| v * 60) } else { - s.parse().ok() + // Grammar: duration ::= integer ( 's' | 'm' ) — a unit is required (#50). + None } } @@ -289,12 +303,21 @@ pub fn build_supervise_spec( } else { 10 }); + // A oneshot service writes its ready marker on success; REQUIRES deps poll it (#45). + let ready_marker = if service.oneshot { + let path = ready_marker_path(config, &service.name); + let _ = std::fs::create_dir_all(config.state_dir.join("ready")); + Some(path) + } else { + None + }; SuperviseSpec { label: service_label(config, &service.name), pre_start: exec_set.pre_start.clone(), start: exec_set.start.clone(), stop: exec_set.stop.clone(), post_stop: exec_set.post_stop.clone(), + ready_marker, deps: deps .iter() .map(|d| DepSpec { @@ -308,15 +331,33 @@ pub fn build_supervise_spec( } /// Build the dependency readiness gates for `service`: for each REQUIRES/AFTER -/// dependency that is enabled and has a HEALTHCHECK, a poll the supervisor runs -/// before starting. Deps without a healthcheck are skipped. -pub fn build_dep_gates(service: &Service, all: &[Service]) -> Vec { +/// dependency that is enabled, a poll the supervisor runs before starting. +/// REQUIRES is enforced even without a HEALTHCHECK (oneshot marker / process +/// up); AFTER only polls when a HEALTHCHECK exists and never blocks (#48). +pub fn build_dep_gates(config: &Config, service: &Service, all: &[Service]) -> Vec { let lookup = |name: &str| all.iter().find(|s| s.name == name && !s.disabled); let mut gates = Vec::new(); for (names, required) in [(&service.requires, true), (&service.after, false)] { for dep_name in names { if let Some(dep) = lookup(dep_name) { - if let Some(hc) = &dep.healthcheck { + // REQUIRES is a hard requirement: even without a HEALTHCHECK, wait + // for the dep to start (oneshot: its ready marker; else the process + // is up). AFTER stays ordering-only and never enforces (#48). + if required { + let poll_cmd = match &dep.healthcheck { + Some(hc) => healthcheck_to_cmd(hc), + None => oneshot_marker_or_up(config, dep), + }; + gates.push(DepGate { + poll_cmd, + timeout_secs: dep + .readiness_timeout + .as_deref() + .and_then(parse_duration_secs) + .unwrap_or(90), + required, + }); + } else if let Some(hc) = &dep.healthcheck { gates.push(DepGate { poll_cmd: healthcheck_to_cmd(hc), timeout_secs: dep @@ -333,6 +374,30 @@ pub fn build_dep_gates(service: &Service, all: &[Service]) -> Vec { gates } +/// Poll target for a oneshot dependency: its ready marker, else "process up". +/// A oneshot dep signals readiness by exiting 0 and creating its ready marker +/// (spec ONESHOT behavior). A non-oneshot dep is ready once its supervisor pid +/// is alive. Fall back to `true` so a marker-only poll still succeeds. +fn oneshot_marker_or_up(config: &Config, dep: &Service) -> String { + let marker = ready_marker_path(config, &dep.name); + if dep.oneshot { + format!("test -f {marker}") + } else { + format!("true") + } +} + +/// Path of a service's ready marker: `/ready/