Skip to content

fix(compose): run the service's declared command instead of discarding it#367

Open
pofallon wants to merge 1 commit into
mainfrom
fix/t117-compose-override-command
Open

fix(compose): run the service's declared command instead of discarding it#367
pofallon wants to merge 1 commit into
mainfrom
fix/t117-compose-override-command

Conversation

@pofallon

Copy link
Copy Markdown
Contributor

The bug

On the compose path deacon replaced the service's declared command with its keep-alive, so the service's own process never ran. Any compose service whose command matters — a server, a database bootstrap, a queue worker — silently did not start.

It was invisible for the ubiquitous command: ["sleep", "infinity"] idiom, which is why every compose fixture in the suite passed.

The spec says so plainly

overrideCommand … Defaults to true for when using an image Dockerfile and false when referencing a Docker Compose file. … Set to false if the default command must run for the container to function properly.

crates/core/src/compose.rs read self.override_command.unwrap_or(true) — on a code path reached only for compose — under a comment asserting the opposite of the spec.

Measured, not inferred

A service whose command is sh -c 'touch <marker>; sleep infinity':

marker created?
reference (pinned oracle 0.87.0) YES
deacon, before NO
deacon, after YES

The shape, measured across the three cases that distinguish it

entrypoint = [wrapper, "-"] ++ <declared entrypoint>
command    = <declared command>          (or [] when overrideCommand: true)
Situation Behavior Verified against reference
declared command exec "$@" runs it; container lives as long as the service's process
nothing declared "$@" empty → exec no-ops → keep-alive loop holds it open
declared entrypoint PREPENDED to, never replaced, so tini/wrapper scripts still get the command as args ✅ both ran
overrideCommand: true command emptied, not replaced (same entrypoint, Cmd: [])

Reading the declared entrypoint needs the rendered config, so extract_service_entrypoint parses compose config --format json (extends + multi-file merging already applied). A read failure warns and degrades to "a declared entrypoint is not preserved", never to "nothing runs".

Two existing tests asserted the defect

That is why it survived. test_compose_override_command_default_keeps_service_alive claimed a service running echo hello stays alive under the default — only true if the command is discarded. The reference fails that fixture ({"outcome":"error"}, exit 1) because it honors the default and the container exits with its command.

Both were rewritten to the spec's contract, with the measurement recorded in them so the next reader sees what changed. The lifecycle test now sets overrideCommand: true explicitly — which is what the spec's true is for.

The rewritten default test asserts the marker, not up failing. A first draft asserting failure was flaky under parallel load: whether a millisecond-lived container is still present when deacon looks is a race, while "did the declared command run" is the actual claim and is deterministic.

Also: the backoff, now that this path is reachable

The primary-container-id retry doubled uncapped to a 51-second single sleep, ~102 s total. Before this fix that path could not be reached (deacon always kept the container alive); now a user whose service command exits would wait 102 s to be told so. Capped to 2 s per attempt (~13 s total, still ample for startup jitter), and the exhaustion message names the likely cause and its one-line remedy. The rewritten smoke test dropped from 104 s to 12 s as a side effect.

docker stop, measured on both sides

deacon reference
declared long-running command 10,176 ms / exit 137 10,143 ms / exit 137
no declared command 129 ms / exit 0

Identical: exec replaces the wrapper, so SIGTERM handling belongs to the service's own process. The trap still works where it applies.

Verification

Gate Result
cargo fmt --all -- --check pass
cargo clippy --all-targets --all-features -- -D warnings pass (0)
cargo nextest run --profile dev-fast --no-fail-fast 3589 passed
all compose/keep-alive Docker tests (test(compose), up_keepalive_path, integration_compose_*) 162 passed
validate / certify / migration check / snapshot check pass

The guard was demonstrated to fail on the defect (FR-047): reverting the default to unwrap_or(true) makes test_compose_default_runs_the_declared_command fail with "no marker means deacon replaced it, so the service's own process never ran".

Unblocks the two conformance cases held back in #366.

🤖 Generated with Claude Code

https://claude.ai/code/session_017UCGzJEFZd85HJqz1Wx2kE

…g it

On the compose path deacon replaced the service's declared `command` with its
keep-alive, so the service's own process never ran. Any compose service whose
command matters — a server, a database bootstrap, a queue worker — silently did
not start.

The spec is explicit. `overrideCommand` "Defaults to `true` for when using an
image Dockerfile and `false` when referencing a Docker Compose file", and spells
out why: "Set to `false` if the default command must run for the container to
function properly." `compose.rs` read `unwrap_or(true)` on a compose-only path,
under a comment asserting the opposite of the spec.

Measured against pinned oracle 0.87.0, not inferred. A service whose command is
`sh -c 'touch <marker>; sleep infinity'`: the marker exists under the reference
and not under deacon. `docker inspect` shows the mechanism — the reference puts
its keep-alive in `Entrypoint` ending in `exec "$@"` and leaves `Cmd` as the
compose command; deacon put its keep-alive in `Cmd`, discarding the command.

deacon now uses the reference's shape, which was measured across all three
cases that distinguish it rather than assumed:

    entrypoint = [wrapper, "-"] ++ <declared entrypoint>
    command    = <declared command>          (or [] when overrideCommand: true)

- declared command → `exec "$@"` runs it; the container lives as long as the
  service's own process, which is what `overrideCommand: false` means.
- nothing declared → `"$@"` is empty, `exec` no-ops, and the keep-alive loop
  holds the container open.
- declared entrypoint → PREPENDED to, never replaced, so a multi-stage
  entrypoint (tini, a wrapper script) still receives the command as args. The
  reference concatenates identically: a fixture declaring both an entrypoint and
  a command runs BOTH, verified on each side.
- `overrideCommand: true` → the command is EMPTIED rather than replaced, which
  is again what the reference does (same entrypoint, `Cmd: []`).

Reading the declared entrypoint needs the RENDERED compose config, so
`extract_service_entrypoint` parses `compose config --format json` — extends and
multi-file merging already applied. A read failure warns and degrades to "a
declared entrypoint is not preserved", never to "nothing runs".

Two existing tests asserted the defect, which is why it survived:
`test_compose_override_command_default_keeps_service_alive` claimed a service
running `echo hello` stays alive under the default, and the lifecycle test relied
on the same. That premise is only true if the command is discarded. The reference
FAILS that fixture (`{"outcome":"error"}`, exit 1) because it honors the default
and the container exits with its command — so the tests were rewritten to the
spec's contract, with the measurement recorded in them. The lifecycle test now
sets `overrideCommand: true` explicitly, which is what the spec's `true` is for.

The rewritten default test asserts the MARKER rather than `up` failing. A first
draft asserting failure was flaky under parallel load: whether a millisecond-lived
container is still present when deacon looks is a race, while "did the declared
command run" is the actual claim and is deterministic.

Also caps the primary-container-id backoff. Uncapped doubling reached a
51-second single sleep and ~102 s total, and this fix makes that path reachable
for the first time — a user whose service command exits should not wait 102 s to
be told so. Capped to 2 s per attempt (~13 s total, still ample for startup
jitter), and the exhaustion message now names the likely cause and its one-line
remedy.

`docker stop` timing was measured on both sides for the new shape: with a
declared long-running command deacon takes 10,176 ms / exit 137 and the reference
10,143 ms / exit 137 — identical, because `exec` replaces the wrapper and the
SIGTERM handling belongs to the service's own process. With no declared command
deacon is 129 ms / exit 0, so the trap still works where it applies.

Unblocks T117's two held-back conformance cases (see #366).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017UCGzJEFZd85HJqz1Wx2kE
@github-actions github-actions Bot added the fix Bug fix label Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant