fix(compose): run the service's declared command instead of discarding it#367
Open
pofallon wants to merge 1 commit into
Open
fix(compose): run the service's declared command instead of discarding it#367pofallon wants to merge 1 commit into
pofallon wants to merge 1 commit into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
On the compose path deacon replaced the service's declared
commandwith 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
crates/core/src/compose.rsreadself.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':The shape, measured across the three cases that distinguish it
exec "$@"runs it; container lives as long as the service's process"$@"empty →execno-ops → keep-alive loop holds it openoverrideCommand: trueCmd: [])Reading the declared entrypoint needs the rendered config, so
extract_service_entrypointparsescompose 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_aliveclaimed a service runningecho hellostays 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: trueexplicitly — which is what the spec'strueis for.The rewritten default test asserts the marker, not
upfailing. 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 sidesIdentical:
execreplaces the wrapper, so SIGTERM handling belongs to the service's own process. The trap still works where it applies.Verification
cargo fmt --all -- --checkcargo clippy --all-targets --all-features -- -D warningscargo nextest run --profile dev-fast --no-fail-fasttest(compose),up_keepalive_path,integration_compose_*)validate/certify/migration check/snapshot checkThe guard was demonstrated to fail on the defect (FR-047): reverting the default to
unwrap_or(true)makestest_compose_default_runs_the_declared_commandfail 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