Skip to main content

fakecloud_ecs/runtime/
task_lifecycle.rs

1//! `EcsRuntime` `task_lifecycle` family — extracted from service.rs by audit-2026-05-19.
2
3use super::*;
4
5impl EcsRuntime {
6    /// Spawn the task asynchronously. Returns immediately after transitioning
7    /// the task to `PENDING`; the background task advances it to `RUNNING`
8    /// once the container is created and to `STOPPED` once the container
9    /// exits.
10    pub fn run_task(self: Arc<Self>, state: SharedEcsState, task_id: String, account_id: String) {
11        let rt = self.clone();
12        tokio::spawn(async move {
13            if let Err(err) = rt.run_task_inner(&state, &task_id, &account_id).await {
14                tracing::warn!(%err, task = %task_id, "ecs task execution failed");
15                // Also surface on stderr so nextest's captured-output for a
16                // failed E2E shows the reason instead of just "empty logs".
17                eprintln!("[ecs] task {task_id} failed: {err}");
18                finalize_failure(&state, &account_id, &task_id, &err.to_string());
19                rt.emit_state_change(
20                    &state,
21                    &account_id,
22                    &task_id,
23                    "STOPPED",
24                    Some(("TaskFailedToStart", err.to_string())),
25                );
26                // Persist the STOPPED/failed transition so a restart sees the
27                // terminal status instead of a stale PENDING/RUNNING row.
28                rt.persist_snapshot().await;
29            }
30        });
31    }
32
33    pub async fn run_task_inner(
34        &self,
35        state: &SharedEcsState,
36        task_id: &str,
37        account_id: &str,
38    ) -> Result<(), RuntimeError> {
39        if self.k8s.is_some() {
40            return self.k8s_run_task_inner(state, task_id, account_id).await;
41        }
42        // Build a per-container launch plan up-front so we hold the read
43        // lock once. Each entry carries everything needed to compose a
44        // `docker run` invocation for one container in the task.
45        let plans = build_container_plans(state, account_id, task_id, self.server_port)?;
46        if plans.is_empty() {
47            return Err(RuntimeError::ContainerStart(
48                "task has no containers".into(),
49            ));
50        }
51
52        // Resolve secrets for each plan. Failures fail the whole task to
53        // match real ECS's "failed to retrieve secret" behaviour — there's
54        // no point starting a sidecar when the app container will fail.
55        let mut resolved_plans: Vec<ResolvedContainerPlan> = Vec::with_capacity(plans.len());
56        for plan in plans {
57            let mut env = plan.env.clone();
58            for (name, value_from) in &plan.secrets_refs {
59                match self.resolve_secret(account_id, value_from) {
60                    Some(v) => env.push((name.clone(), v)),
61                    None => {
62                        return Err(RuntimeError::ContainerStart(format!(
63                            "failed to resolve secret {name} from {value_from}"
64                        )));
65                    }
66                }
67            }
68            // The agent/metadata endpoints live on fakecloud (the host);
69            // the container reaches them via the platform host alias —
70            // `host.docker.internal` for docker, `host.containers.internal`
71            // for podman (issue #1539).
72            let host_alias = &self.net.host_alias;
73            if plan.has_task_role {
74                env.push((
75                    "AWS_CONTAINER_CREDENTIALS_FULL_URI".into(),
76                    format!(
77                        "http://{host_alias}:{}/_fakecloud/ecs/creds/{}",
78                        self.server_port, task_id
79                    ),
80                ));
81            }
82            env.push((
83                "ECS_CONTAINER_METADATA_URI".into(),
84                format!(
85                    "http://{host_alias}:{}/_fakecloud/ecs/v3/{}",
86                    self.server_port, task_id
87                ),
88            ));
89            env.push((
90                "ECS_CONTAINER_METADATA_URI_V4".into(),
91                format!(
92                    "http://{host_alias}:{}/_fakecloud/ecs/v4/{}",
93                    self.server_port, task_id
94                ),
95            ));
96            resolved_plans.push(ResolvedContainerPlan { plan, env });
97        }
98
99        // Pull every distinct image up-front so a second container's pull
100        // failure surfaces before we leave the first container running.
101        mark_pull_started(state, account_id, task_id);
102        let mut run_images: Vec<String> = Vec::with_capacity(resolved_plans.len());
103        let mut image_digests: Vec<Option<String>> = Vec::with_capacity(resolved_plans.len());
104        for rp in &resolved_plans {
105            // Rewrite ECR URIs to fakecloud's local registry at the sibling
106            // host (`127.0.0.1` on the host, `host.docker.internal` when
107            // fakecloud is containerized) so the daemon/sibling can reach
108            // fakecloud's published registry port (issue #1539, bug 0.8).
109            let local_pull_uri = fakecloud_core::ecr_uri::translate_to_local_at(
110                &rp.plan.image,
111                &self.net.sibling_host,
112                self.server_port,
113            );
114            let pull_uri = local_pull_uri.as_deref().unwrap_or(&rp.plan.image);
115            let pull_out = self
116                .cli_command()
117                .args(["pull", pull_uri])
118                .output()
119                .await
120                .map_err(|e| RuntimeError::ImagePull(e.to_string()))?;
121            if !pull_out.status.success() {
122                let err = String::from_utf8_lossy(&pull_out.stderr).to_string();
123                return Err(RuntimeError::ImagePull(err));
124            }
125            // Retag the local pull URI to the AWS URI so `docker run` finds
126            // the image under the user-facing name. Digest-pinned refs
127            // can't be `docker tag` targets, so we fall through and run
128            // under the local URI in that case.
129            let run_image = if let Some(ref local_uri) = local_pull_uri {
130                if fakecloud_core::ecr_uri::is_digest_ref(&rp.plan.image) {
131                    local_uri.clone()
132                } else {
133                    let _ = self
134                        .cli_command()
135                        .args(["tag", local_uri, &rp.plan.image])
136                        .output()
137                        .await;
138                    rp.plan.image.clone()
139                }
140            } else {
141                rp.plan.image.clone()
142            };
143            // Best-effort image digest extraction so DescribeTasks emits
144            // the resolved digest the way real ECS does. Failures here
145            // (e.g. CLI without RepoDigests) are silent — digest stays
146            // `None` rather than failing the task.
147            let digest = self.lookup_image_digest(pull_uri).await;
148            run_images.push(run_image);
149            image_digests.push(digest);
150        }
151        mark_pull_stopped(state, account_id, task_id);
152
153        // For awsvpc network mode, create a per-task docker network so
154        // containers share an isolated bridge. Clean it up when the task
155        // stops. Network creation is best-effort: on failure we fall back
156        // to the default bridge and continue.
157        let awsvpc_network = resolved_plans
158            .iter()
159            .any(|rp| rp.plan.network_mode.as_deref() == Some("awsvpc"));
160        let network_name = format!("fakecloud-ecs-{}", task_id);
161        let network_created = if awsvpc_network {
162            let create = Command::new(&self.cli)
163                .args([
164                    "network",
165                    "create",
166                    "--driver",
167                    "bridge",
168                    "--label",
169                    &format!("fakecloud-ecs-task={}", task_id),
170                    // Ownership label so the startup reaper can prune the
171                    // per-task awsvpc network after an ungraceful restart,
172                    // matching the container label.
173                    "--label",
174                    &super::fakecloud_instance_label(),
175                    &network_name,
176                ])
177                .output()
178                .await;
179            match create {
180                Ok(out) if out.status.success() => {
181                    tracing::info!(
182                        task = %task_id,
183                        network = %network_name,
184                        "created awsvpc docker network"
185                    );
186                    true
187                }
188                Ok(out) => {
189                    let err = String::from_utf8_lossy(&out.stderr);
190                    tracing::warn!(
191                        task = %task_id,
192                        network = %network_name,
193                        error = %err,
194                        "awsvpc network creation failed; falling back to default bridge"
195                    );
196                    false
197                }
198                Err(e) => {
199                    tracing::warn!(
200                        task = %task_id,
201                        network = %network_name,
202                        error = %e,
203                        "awsvpc network creation failed; falling back to default bridge"
204                    );
205                    false
206                }
207            }
208        } else {
209            false
210        };
211
212        if network_created {
213            let eni_id = format!(
214                "eni-{}",
215                uuid::Uuid::new_v4()
216                    .to_string()
217                    .replace('-', "")
218                    .get(..17)
219                    .unwrap_or("")
220            );
221            let mac = format!(
222                "02:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
223                rand::random::<u8>(),
224                rand::random::<u8>(),
225                rand::random::<u8>(),
226                rand::random::<u8>(),
227                rand::random::<u8>()
228            );
229            let ip = format!("10.0.{}.{}", rand::random::<u8>(), rand::random::<u8>());
230            let mut accounts = state.write();
231            if let Some(st) = accounts.get_mut(account_id) {
232                if let Some(task) = st.tasks.get_mut(task_id) {
233                    task.attachments.push(crate::state::TaskAttachment {
234                        id: eni_id.clone(),
235                        attachment_type: "eni".into(),
236                        status: "ATTACHED".into(),
237                        details: vec![
238                            crate::state::AttachmentDetail {
239                                name: "subnetId".into(),
240                                value: "subnet-fakecloud".into(),
241                            },
242                            crate::state::AttachmentDetail {
243                                name: "privateIPv4Address".into(),
244                                value: ip.clone(),
245                            },
246                            crate::state::AttachmentDetail {
247                                name: "macAddress".into(),
248                                value: mac.clone(),
249                            },
250                        ],
251                    });
252                }
253            }
254            tracing::info!(
255                task = %task_id,
256                eni = %eni_id,
257                ip = %ip,
258                "populated awsvpc ENI attachment"
259            );
260        }
261
262        // Launch every container detached, in topological order. Before
263        // each `docker run` we honour the dependent's `dependsOn[]` by
264        // polling docker until each upstream container reaches the
265        // requested condition (START/COMPLETE/SUCCESS/HEALTHY). If any
266        // fails to start (or an upstream gate times out), kill the
267        // already-started containers and bail — partial-launch state is
268        // harder to reason about than a clean failure.
269        // Register the task in `self.containers` BEFORE the launch loop so a
270        // concurrent StopTask / scale-down / DeleteService that arrives
271        // during the multi-second pull+run window can find the entry and stop
272        // whatever has been launched so far. (`stop_task` is a no-op when the
273        // key is absent — the orphan-on-race bug.) We append each container
274        // id to this entry as it starts, mirroring the k8s backend which
275        // registers the Pod name before `create_pod`.
276        self.containers
277            .write()
278            .entry(task_id.to_string())
279            .or_default();
280        let mut started: Vec<RunningContainer> = Vec::with_capacity(resolved_plans.len());
281        for (idx, (rp, run_image)) in resolved_plans.iter().zip(run_images.iter()).enumerate() {
282            // Wait for every dependsOn[] entry on this container. Upstreams
283            // declared in the same task always show up earlier in the
284            // launch order thanks to topo_sort_plans, so we only ever look
285            // backwards into `started`.
286            for dep in &rp.plan.depends_on {
287                let upstream = match started.iter().find(|c| c.name == dep.container_name) {
288                    Some(u) => u,
289                    // Upstream not in this task definition (we ignored it
290                    // during topo-sort too). Skip the gate — this matches
291                    // the existing "ignore unknown dependency" behaviour.
292                    None => continue,
293                };
294                // Whether the upstream has a healthCheck configured —
295                // governs the HEALTHY shortcut: AWS treats HEALTHY as
296                // immediately satisfied when the upstream has no probe.
297                let upstream_has_health_check = resolved_plans
298                    .iter()
299                    .find(|p| p.plan.container_name == dep.container_name)
300                    .is_some_and(|p| p.plan.health_check.is_some());
301                if let Err(err) = self
302                    .wait_for_depends_on(upstream, dep.condition, upstream_has_health_check)
303                    .await
304                {
305                    self.cleanup_partial_start(&started, task_id);
306                    return Err(err);
307                }
308            }
309            let argv = build_run_argv(
310                &rp.plan,
311                &rp.env,
312                task_id,
313                &self.net.host_alias,
314                self.net.add_host_arg.as_deref(),
315                run_image,
316                network_created,
317            );
318            let mut cmd = Command::new(&self.cli);
319            cmd.args(&argv);
320            let run_out = cmd.output().await.map_err(|e| {
321                // Cleanup already-started containers on launch failure.
322                self.cleanup_partial_start(&started, task_id);
323                RuntimeError::ContainerStart(e.to_string())
324            })?;
325            if !run_out.status.success() {
326                let err = String::from_utf8_lossy(&run_out.stderr).to_string();
327                self.cleanup_partial_start(&started, task_id);
328                return Err(RuntimeError::ContainerStart(err));
329            }
330            let container_id = String::from_utf8_lossy(&run_out.stdout).trim().to_string();
331            // Append to the runtime map immediately so a StopTask landing
332            // between this container and the next one in the launch loop can
333            // reach it.
334            self.containers
335                .write()
336                .entry(task_id.to_string())
337                .or_default()
338                .push((rp.plan.container_name.clone(), container_id.clone()));
339            started.push(RunningContainer {
340                name: rp.plan.container_name.clone(),
341                container_id,
342                essential: rp.plan.essential,
343                exit_code: None,
344                network_bindings: network_bindings_for(&rp.plan),
345                image_digest: image_digests.get(idx).cloned().unwrap_or(None),
346            });
347        }
348
349        // A StopTask / scale-down / DeleteService that raced the launch may
350        // have flipped this task's desired_status to STOPPED while we were
351        // pulling/running (and may have already issued `docker stop` against
352        // the containers it could see in the runtime map). If so, stop every
353        // container we launched and finalize as a clean stop instead of
354        // marking the task RUNNING with a live workload the user asked to
355        // kill. Mirrors the k8s backend, which observes a 404 from the
356        // StopTask-deleted Pod and finalizes the same way.
357        if task_desired_stopped(state, account_id, task_id) {
358            for rc in &started {
359                let _ = Command::new(&self.cli)
360                    .args(["stop", "--time", "10", &rc.container_id])
361                    .output()
362                    .await;
363                let _ = Command::new(&self.cli)
364                    .args(["rm", "-f", &rc.container_id])
365                    .output()
366                    .await;
367            }
368            if network_created {
369                let _ = Command::new(&self.cli)
370                    .args(["network", "rm", &network_name])
371                    .output()
372                    .await;
373            }
374            self.containers.write().remove(task_id);
375            let stopped: Vec<RunningContainer> = started
376                .iter()
377                .map(|rc| RunningContainer {
378                    exit_code: Some(rc.exit_code.unwrap_or(137)),
379                    ..rc.clone()
380                })
381                .collect();
382            finalize_stopped_multi(
383                state,
384                account_id,
385                task_id,
386                &stopped,
387                137,
388                "",
389                "UserInitiated",
390                Some("Task stopped during launch".into()),
391            );
392            self.deregister_lb_targets(state, account_id, task_id);
393            self.emit_state_change(
394                state,
395                account_id,
396                task_id,
397                "STOPPED",
398                Some(("UserInitiated", "Task stopped during launch".into())),
399            );
400            self.persist_snapshot().await;
401            return Ok(());
402        }
403
404        mark_running_multi(state, account_id, task_id, &started);
405        self.register_lb_targets(state, account_id, task_id);
406        self.emit_state_change(state, account_id, task_id, "RUNNING", None);
407        // Persist the PENDING->RUNNING transition so DescribeTasks reports
408        // RUNNING after a restart without waiting on restart reconciliation.
409        self.persist_snapshot().await;
410
411        // Wait for the first essential container (or, if none are
412        // essential, any container) to exit. ECS task lifetime is
413        // bounded by the first essential exit, after which all remaining
414        // containers are stopped. While polling we also refresh each
415        // container's `healthStatus` from `docker inspect` so
416        // DescribeTasks reflects HEALTHCHECK transitions in near real
417        // time.
418        let wait_outcome = self
419            .wait_for_task_exit_with_health(state, account_id, task_id, &started)
420            .await?;
421
422        // Stop and reap any sidecars still running. Best-effort — failures
423        // here shouldn't keep the task from transitioning to STOPPED.
424        let mut final_containers = started.clone();
425        for (i, rc) in started.iter().enumerate() {
426            if Some(i) == wait_outcome.exited_index {
427                final_containers[i].exit_code = Some(wait_outcome.exit_code);
428                continue;
429            }
430            // Try to grab the exit code if the container already exited
431            // on its own (non-essential exits don't stop the task), then
432            // fall back to `docker stop` for stragglers.
433            let inspect = Command::new(&self.cli)
434                .args(["inspect", "-f", "{{.State.ExitCode}}", &rc.container_id])
435                .output()
436                .await;
437            let still_running = match inspect {
438                Ok(out) if out.status.success() => {
439                    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
440                    // `docker inspect` returns 0 for not-yet-exited
441                    // containers, so we additionally check `State.Running`.
442                    let running = Command::new(&self.cli)
443                        .args(["inspect", "-f", "{{.State.Running}}", &rc.container_id])
444                        .output()
445                        .await
446                        .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
447                        .unwrap_or(false);
448                    if !running {
449                        if let Ok(code) = s.parse::<i64>() {
450                            final_containers[i].exit_code = Some(code);
451                        }
452                    }
453                    running
454                }
455                _ => false,
456            };
457            if still_running {
458                let _ = Command::new(&self.cli)
459                    .args(["stop", "--time", "10", &rc.container_id])
460                    .output()
461                    .await;
462                let wait_out = Command::new(&self.cli)
463                    .args(["wait", &rc.container_id])
464                    .output()
465                    .await;
466                if let Ok(out) = wait_out {
467                    let code: i64 = String::from_utf8_lossy(&out.stdout)
468                        .trim()
469                        .parse()
470                        .unwrap_or(-1);
471                    final_containers[i].exit_code = Some(code);
472                }
473            }
474        }
475
476        // Capture combined stdout+stderr from every container so the
477        // introspection endpoint shows logs from sidecars too.
478        let mut captured = String::new();
479        for rc in &started {
480            let logs_out = Command::new(&self.cli)
481                .args(["logs", &rc.container_id])
482                .output()
483                .await
484                .map_err(|e| RuntimeError::Wait(e.to_string()))?;
485            captured.push_str(&format!("[{}] ", rc.name));
486            captured.push_str(&String::from_utf8_lossy(&logs_out.stdout));
487            captured.push_str(&String::from_utf8_lossy(&logs_out.stderr));
488        }
489
490        // Reap every container we own.
491        for rc in &started {
492            let _ = Command::new(&self.cli)
493                .args(["rm", "-f", &rc.container_id])
494                .output()
495                .await;
496        }
497        // Reap task-scoped docker volumes (anonymous "Docker volumes" and
498        // `dockerVolumeConfiguration` with scope=task), matching AWS, which
499        // deletes them when the task stops. Host binds, EFS/FSx stubs and
500        // scope=shared volumes are left in place — they outlive the task.
501        // `volume rm` is best-effort and no-ops on a volume still in use.
502        let mut task_volumes: std::collections::BTreeSet<String> =
503            std::collections::BTreeSet::new();
504        for rp in &resolved_plans {
505            for vm in &rp.plan.volume_mounts {
506                if vm.cleanup_on_stop {
507                    task_volumes.insert(vm.source.clone());
508                }
509            }
510        }
511        for vol in &task_volumes {
512            let _ = Command::new(&self.cli)
513                .args(["volume", "rm", "-f", vol])
514                .output()
515                .await;
516        }
517        // Clean up the per-task docker network for awsvpc.
518        if network_created {
519            let _ = Command::new(&self.cli)
520                .args(["network", "rm", &network_name])
521                .output()
522                .await;
523        }
524        self.containers.write().remove(task_id);
525
526        // Forward logs BEFORE flipping the task to STOPPED so a client
527        // that polls DescribeTasks and immediately queries
528        // DescribeLogStreams can't observe the STOPPED transition before
529        // the awslogs group/stream has been materialised.
530        self.forward_awslogs_if_configured(state, account_id, task_id, &captured);
531        let exit_code = wait_outcome.exit_code;
532        finalize_stopped_multi(
533            state,
534            account_id,
535            task_id,
536            &final_containers,
537            exit_code,
538            &captured,
539            wait_outcome.stop_code,
540            None,
541        );
542        self.deregister_lb_targets(state, account_id, task_id);
543        self.emit_state_change(
544            state,
545            account_id,
546            task_id,
547            "STOPPED",
548            Some((wait_outcome.stop_code, format!("Exit code {}", exit_code))),
549        );
550        // Persist the terminal STOPPED transition + captured exit codes so a
551        // restart reflects the completed task instead of a stale RUNNING row.
552        self.persist_snapshot().await;
553        Ok(())
554    }
555
556    /// Wait for the task to reach a stop condition (any essential
557    /// container exits, or every container exits when none are
558    /// essential) while also polling `docker inspect .State.Health.Status`
559    /// on every iteration to push the latest `healthStatus` onto each
560    /// task container — so DescribeTasks shows live HEALTHCHECK
561    /// transitions instead of the boot-time `UNKNOWN`. Returns the
562    /// index into `started` of the container whose exit determined the
563    /// task lifetime, its exit code, and the stopCode.
564    pub(super) async fn wait_for_task_exit_with_health(
565        &self,
566        state: &SharedEcsState,
567        account_id: &str,
568        task_id: &str,
569        started: &[RunningContainer],
570    ) -> Result<TaskExitOutcome, RuntimeError> {
571        let any_essential = started.iter().any(|c| c.essential);
572        let mut working: Vec<RunningContainer> = started.to_vec();
573        let mut first_exited: Option<usize> = None;
574        loop {
575            // Refresh health status before checking exits so a container
576            // that goes UNHEALTHY -> exits in the same iteration leaves
577            // its final health state on the task before we transition to
578            // STOPPED.
579            self.refresh_health_status(state, account_id, task_id, started)
580                .await;
581            for (i, rc) in started.iter().enumerate() {
582                if working[i].exit_code.is_some() {
583                    continue;
584                }
585                let inspect = Command::new(&self.cli)
586                    .args(["inspect", "-f", "{{.State.Running}}", &rc.container_id])
587                    .output()
588                    .await;
589                let running = match inspect {
590                    Ok(out) if out.status.success() => {
591                        String::from_utf8_lossy(&out.stdout).trim() == "true"
592                    }
593                    _ => false,
594                };
595                if running {
596                    continue;
597                }
598                let wait_out = Command::new(&self.cli)
599                    .args(["wait", &rc.container_id])
600                    .output()
601                    .await
602                    .map_err(|e| RuntimeError::Wait(e.to_string()))?;
603                if !wait_out.status.success() {
604                    let err = String::from_utf8_lossy(&wait_out.stderr).to_string();
605                    return Err(RuntimeError::Wait(err));
606                }
607                let exit_code: i64 = String::from_utf8_lossy(&wait_out.stdout)
608                    .trim()
609                    .parse()
610                    .unwrap_or(-1);
611                working[i].exit_code = Some(exit_code);
612                if first_exited.is_none() && (rc.essential || !any_essential) {
613                    first_exited = Some(i);
614                }
615            }
616            if task_should_stop(&working) {
617                let idx = first_exited
618                    .or_else(|| working.iter().position(|c| c.exit_code.is_some()))
619                    .unwrap_or(0);
620                let exit_code = working[idx].exit_code.unwrap_or(-1);
621                return Ok(TaskExitOutcome {
622                    exited_index: Some(idx),
623                    exit_code,
624                    stop_code: if any_essential {
625                        "EssentialContainerExited"
626                    } else {
627                        "TaskCompleted"
628                    },
629                });
630            }
631            sleep(Duration::from_millis(200)).await;
632        }
633    }
634
635    /// Block the launch of a dependent container until its upstream
636    /// reaches the requested `dependsOn[].condition`. We poll
637    /// `docker inspect` at a small interval; the wait is bounded by an
638    /// AWS-style timeout (120s by default — long enough for image
639    /// startup but short enough to surface bugs as a clean
640    /// `ContainerStart` failure).
641    ///
642    /// `upstream_has_health_check` is needed for the `HEALTHY` branch:
643    /// when the upstream has no healthCheck, AWS treats `HEALTHY` as
644    /// immediately satisfied (otherwise the dependent would block
645    /// forever, since docker reports `Health.Status` only when the
646    /// container has a HEALTHCHECK directive).
647    pub(super) async fn wait_for_depends_on(
648        &self,
649        upstream: &RunningContainer,
650        condition: DependsOnCondition,
651        upstream_has_health_check: bool,
652    ) -> Result<(), RuntimeError> {
653        // Bounded wait — chosen to comfortably cover slow init scripts
654        // without letting a wedged dependency stall a task indefinitely.
655        const WAIT_TIMEOUT: Duration = Duration::from_secs(120);
656        const POLL_INTERVAL: Duration = Duration::from_millis(200);
657
658        // HEALTHY against an upstream without a healthCheck: AWS treats
659        // this as immediately satisfied because there's no probe to
660        // observe. Skip the polling loop entirely so the dependent isn't
661        // wedged forever waiting for a status that docker will never set.
662        if matches!(condition, DependsOnCondition::Healthy) && !upstream_has_health_check {
663            return Ok(());
664        }
665
666        let deadline = std::time::Instant::now() + WAIT_TIMEOUT;
667        loop {
668            let inspect = inspect_container_state(&self.cli, &upstream.container_id).await;
669            if let Some(state) = inspect {
670                if condition_is_met(condition, &state) {
671                    return Ok(());
672                }
673                // SUCCESS specifically: if the container exited with a
674                // non-zero code, the gate can never be satisfied. Bail
675                // immediately rather than waiting for the timeout — this
676                // matches ECS's "stoppedReason: dependency failed" path.
677                if matches!(condition, DependsOnCondition::Success)
678                    && state.exited
679                    && state.exit_code != 0
680                {
681                    return Err(RuntimeError::ContainerStart(format!(
682                        "dependency on container {} ({}) failed: upstream exited with code {}",
683                        upstream.name,
684                        DependsOnCondition::Success.as_aws_str(),
685                        state.exit_code,
686                    )));
687                }
688            }
689            if std::time::Instant::now() >= deadline {
690                return Err(RuntimeError::ContainerStart(format!(
691                    "timed out waiting for container {} to reach condition {}",
692                    upstream.name,
693                    condition.as_aws_str(),
694                )));
695            }
696            tokio::time::sleep(POLL_INTERVAL).await;
697        }
698    }
699
700    /// Best-effort cleanup of containers we already started when a later
701    /// container in the task failed to launch. Without this, half-launched
702    /// tasks leak docker containers. `task_id` mirrors the value used at
703    /// network creation so `network rm` targets the right name —
704    /// deriving it from a container_id prefix was wrong (container ids
705    /// are docker-assigned, not task-shaped).
706    pub(super) fn cleanup_partial_start(&self, started: &[RunningContainer], task_id: &str) {
707        let cli = self.cli.clone();
708        let ids: Vec<String> = started.iter().map(|c| c.container_id.clone()).collect();
709        let network = format!("fakecloud-ecs-{task_id}");
710        // Drop the runtime map entry we pre-registered before the launch loop
711        // so a failed launch doesn't leave a stale (now-empty) key that
712        // future StopTask calls would treat as a live task.
713        self.containers.write().remove(task_id);
714        tokio::spawn(async move {
715            for id in ids {
716                let _ = Command::new(&cli).args(["rm", "-f", &id]).output().await;
717            }
718            let _ = Command::new(&cli)
719                .args(["network", "rm", &network])
720                .output()
721                .await;
722        });
723    }
724
725    /// Kill every container behind a task with the configured stop
726    /// timeout. Returns true if at least one container was killed. Called
727    /// synchronously from `StopTask`; the wait loop in `run_task_inner`
728    /// observes the exits and transitions the task to `STOPPED`.
729    pub async fn stop_task(&self, task_id: &str, reason: &str) -> bool {
730        if let Some(k) = &self.k8s {
731            tracing::info!(task = %task_id, reason = %reason, "ecs task stop requested (k8s)");
732            return k.stop_task(task_id).await;
733        }
734        let containers = self.containers.read().get(task_id).cloned();
735        let Some(list) = containers else {
736            return false;
737        };
738        if list.is_empty() {
739            return false;
740        }
741        // `docker stop` sends SIGTERM then SIGKILL after a timeout.
742        for (_name, id) in &list {
743            let _ = Command::new(&self.cli)
744                .args(["stop", "--time", "10", id])
745                .output()
746                .await;
747        }
748        tracing::info!(task = %task_id, reason = %reason, "ecs task stop requested");
749        true
750    }
751
752    /// Kill every running container the runtime owns. Called on reset /
753    /// shutdown so docker state matches fakecloud state after a fresh
754    /// boot.
755    pub async fn stop_all(&self) {
756        if let Some(k) = &self.k8s {
757            k.stop_all().await;
758            return;
759        }
760        let ids: Vec<String> = self
761            .containers
762            .read()
763            .values()
764            .flat_map(|list| list.iter().map(|(_, id)| id.clone()))
765            .collect();
766        for id in ids {
767            let _ = Command::new(&self.cli).args(["kill", &id]).output().await;
768            let _ = Command::new(&self.cli).args(["rm", &id]).output().await;
769        }
770        self.containers.write().clear();
771    }
772}