Fix Up Command Foreground Interactions when sending signals#127
Conversation
Foreground `up` (without --detach) parked in waitForever() with no signal
handling and never noticed when its containers stopped. Combined with the
ContainerCommands signal machinery leaving SIGINT/SIGTERM neutered (SIG_IGN)
after image-pull/network/build calls return, Ctrl-C did nothing and the
process hung even after `container compose down` from another shell.
Replace waitForever() with runForegroundUntilStopped(), matching docker compose:
- SIGINT/SIGTERM (observed via a DispatchSource stream, so they fire despite
the SIG_IGN disposition) gracefully stop the project's containers, then exit
- a second signal force-kills the containers with SIGKILL, then exits
("press Ctrl+C again to force")
- a monitor task exits `up` once all services have run and then stopped —
whether they exit on their own or are stopped via `container compose down`
from another shell (it waits until each container is seen running first, so
it never returns before startup completes)
Also retarget the Mcrich23#27 busy-loop regression test at the new wait function and
make it reliable in the full parallel suite: getrusage(RUSAGE_SELF) is
process-wide, so a 1s window (vs 200ms) lets a real busy-loop's full-core
~1,000,000 µs dominate the transient CPU noise from other parallel suites.
Cyb3rDudu
left a comment
There was a problem hiding this comment.
nice one, the dispatchsource signal handling is the right way around the
SIG_IGN that containerapiservice leaves set, and the two-tap graceful→force
- auto-exit-when-stopped monitor are great.
one change before merging: runForegroundUntilStopped rebuilds names as
<project>-<service>, so it misses explicit container_name and the dotted
<service>.<dnsDomain> names from #97. for those, stopContainers /
killContainers / the monitor do client.get(id: "<project>-<service>") →
nil → skip, so ctrl-c exits container-compose but leaves the containers
running. pass the resolved names instead of service keys —
services.map { containerName(for: $0.serviceName) } — or call
containerName(for:) inside.
minor (non-blocking): the monitor needs to see each container running once
before it'll return, so a fast one-shot that exits before the monitor starts
never satisfies it. the signal path still exits fine.
…to fix/compose-up-foreground # Conflicts: # Tests/Container-Compose-StaticTests/WaitForeverCpuTests.swift
…ice> runForegroundUntilStopped rebuilt names as <project>-<service>, so containers named via explicit container_name or the dotted <service>.<dnsDomain> DNS convention (Mcrich23#97) were never found by the stop/kill/monitor paths — ctrl-c exited container-compose but left those containers running. Pass names resolved through containerName(for:) instead of rebuilding them from service keys. Also port the Mcrich23#126 idle-baseline technique into ForegroundWaitCpuTests (successor of the deleted WaitForeverCpuTests) so the process-wide getrusage flake fixed there isn't reintroduced: assert on incremental CPU over an immediately-preceding idle window rather than an absolute threshold.
) `container-compose up` without `-d` started the first container, then exited 1 with "Error: no runtime client exists: container is stopped", leaving nothing running. Detached `up -d` was unaffected. Root cause: in foreground mode the attached `container run` subprocess runs in a background Task while `waitUntilServiceStarted` polls concurrently. Apple Container reports a container as `.stopped` throughout the image/kernel fetch and only flips to `.running` once init starts, so an early poll caught that pre-running `.stopped`, treated it as a completed one-shot, and threw via `containerWait` (which needs the runtime client, gone once stopped). Fixes: - Track the attached `container run` subprocess with a `ForegroundRunHandle`. In `waitUntilServiceStarted`, only treat `.stopped` as terminal once that subprocess has actually exited; while it is alive, keep polling for `.running`. Detached mode (handle is nil) keeps its existing behaviour. - Seed `waitUntilAllContainersStopped`'s `seenRunning` with all container names. It runs only after `configService` has already confirmed every container started, so a `.stopped` container there is one that exited, not one that hasn't started. Without this, a fast one-shot that exits before the first poll would never be seen `.running` and foreground `up` would hang. This also makes #127's signal handling reachable: previously `configService` crashed before `runForegroundUntilStopped` could install the SIGINT/SIGTERM handler, so Ctrl-C never gracefully stopped the project. Fixes #131
* Add shared ComposeProjectOptions for compose-file/service resolution Introduce a reusable ComposeProjectOptions (compose-file location + decode, project-name derivation, dependency-ordered service resolution, container-name mapping) and a resolved ComposeProject value. New subcommands adopt this instead of re-duplicating the boilerplate that up/down/build/logs each grew. * test(project): cover ComposeProjectOptions file discovery, naming, and ordering Covers composePath (--file precedence, discovery order, default), projectName fallback to the derived cwd name, and orderedServices topological sort/filtering. * feat(project): resolve services to candidate container names, not one guess ComposeProject mapped each service to a single container name (container_name ?? <project>-<service>), which misses the dotted <service>.<dnsDomain> names 'up' uses when the project's DNS domain is registered (#97) — the same bug class fixed for ctrl-c in #127. Replace ServiceTarget.containerName with candidateContainerNames (explicit name first, then both conventions, mirroring ComposeDown.stopOldStuff) and add existingContainer(using:) to resolve which candidate actually exists, so every subcommand built on ComposeProject shares one correct lookup instead of re-deriving names. * feat(project): delegate service selection to Service.selectServices orderedServices hand-rolled its own name filter, which ignored Compose profiles gating and didn't expand depends_on — diverging from the shared selection algorithm up/build/down adopted in #126. Route it through Service.selectServices with the ComposeFileOptions active profiles so commands built on ComposeProject select identically to the existing ones: profile-eligible services plus dependencies by default, requested services plus transitive dependencies (bypassing the profile gate) when named explicitly. * refactor(down): resolve project through shared ComposeProjectOptions First consumer of ComposeProject: drop ComposeDown's own copies of compose-file discovery, project-name derivation, topo-sort/selection, and candidate-container-name construction in favor of the shared API. Behavior is preserved (profile skip note, stop-all-candidates loop for mixed naming-mode leftovers, extra_hosts cleanup) with one refinement: explicit 'down <service>' now expands dependencies via Service.selectServices instead of the topo-sort dependedBy side effect, which only recorded each service's first visitor and so stopped an explicit request's dependencies only when visit order happened to align. * refactor(build): resolve project through shared ComposeProjectOptions Drop ComposeBuild's copies of compose-file discovery, project-name derivation, and service selection in favor of ComposeProjectOptions. Selection semantics are unchanged (Service.selectServices with active profiles, then keep services with a build config); ordering improves from dictionary order to topological, so dependencies build before dependents. * refactor(up): resolve project through shared ComposeProjectOptions Drop ComposeUp's copies of compose-file discovery/decoding, project-name derivation, topo-sort/selection, and the hand-built stop-candidates list in favor of ComposeProjectOptions and ComposeProject.candidateContainerNames. Behavior is unchanged: 'up' keeps deciding the single creation-time name itself (explicit container_name, dotted DNS name when the domain is registered, else <project>-<service>) since that depends on runtime DNS availability, and the pre-start cleanup still stops every name shape a previous run might have used. * refactor(project): simplify shared resolution layer and its consumers Cleanups from a reuse/simplification/efficiency/altitude review of the branch: - down: resolve once instead of loadCompose()+resolve() (the compose file was read and YAML-decoded twice per run); drop the stop loop's remove parameter, which was always false - up: consume resolve() instead of re-composing loadCompose + projectName + orderedServices + candidate names piecewise; this also gives up the same 'No such service' warning down/build already had - move the 'No such service' warning from resolve() into orderedServices() so every selection path warns, not just resolve() - hoist the envFilePath derivation duplicated verbatim in up and build onto ComposeProjectOptions - move sanitizeDnsDomain from ComposeUp to ComposeProject: it is project naming policy, and the shared layer should not reach into a command for it - delete dead code: ServiceTarget.existingContainer (no callers, and its first-match semantics is the exact behavior down's stop-every- candidate loop exists to avoid), ComposeProject.cwd (never read), ComposeError.invalidProjectName (no throwers left), and ComposeUp's vacuous projectName optionality (guards that could never fire once resolution guarantees a name) - narrow supportedComposeFilenames to private * fix(up): resolve healthcheck container name instead of assuming <project>-<service> waitUntilServiceIsHealthy rebuilt the container name by hand, so healthchecks for services created under an explicit container_name or the dotted <service>.<dnsDomain> DNS-mode name (#97) exec'd against a nonexistent container and burned every retry before failing. Use containerName(for:), which reads the resolved name configService records before any healthcheck runs — the same fix #127 applied to the foreground stop paths. * test: cover loadCompose missing-file throw and healthcheck name resolution Closes the two meaningful coverage gaps over this branch's diff: the composeFileNotFound path in ComposeProjectOptions.loadCompose, and a dynamic regression test proving healthchecks exec against the resolved container name — a service created under an explicit container_name previously health-checked the nonexistent <project>-<service> and failed up after exhausting retries.
When hitting ctrl+c while running the up command in the foreground, I noticed unexpected (to me) behavior. I was expecting it to behave like docker compose.
This allows for proper signal listening. SIGINT (first ctrl+c) gracefully kills the stack. SIGKILL (second ctrl+c) force kills the stack.