Repository navigation
Conversation
|
These are useful fixes. A one-shot that runs silently for more than 30 seconds still appears to hit the inactivity timeout, even after reaching |
Thanks. Both are right, and the DNS one has a cause worth naming before I write the test. One-shot vs. startup. Confirmed — idleTimeout defaults to 30s and applies to the same wait that is being used for "has it started", so a one-shot that runs silently past that window trips it even after reaching .running. Those are two different questions and I'll split them: reaching .running ends the startup wait, and a restart: "no" service then gets waited to completion under its own policy rather than an inactivity clock. Dynamic regression with a one-shot that sleeps quietly past the window and then exits 0. DNS from a dependent container. Agreed, and the reason a host lookup is misleading here is worse than sampling the wrong place. On this branch the domain is still derived from the compose project name: An operator who registered a domain that isn't their project's name gets dnsAvailable == false, so --dns-domain is never passed and the barrier this PR adds never runs at all — it falls back to the /etc/hosts cross-patcher, which is best-effort by design and reports nothing. The cluster comes up looking fine. A host-side container system dns list check passes the whole time, which is exactly the gap you're pointing at. We hit this downstream and fixed it as a separate commit: read the globally configured [dns] domain from container system property list and try it first, keeping the project-derived name as the second candidate so projects that did register their own name are unaffected. Both decisions are pure and unit-tested. Happy to bring it into this PR — the barrier isn't really testable from a dependent container until it runs — or keep it separate if you'd rather review it on its own. Your call. Rebase. Noticed #144 merged, so bba4162 on this branch is now a duplicate of what's on main and will drop on the next rebase. 85a13fc (#145) stays until that one lands. |
`up` launched every service strictly one at a time, so N independent services paid N× the per-service start + readiness cost (the issue reports ~23s for 11 independent services). It now launches each dependency WAVE (from `Service.dependencyLevels`) concurrently: services with no pending dependency start together, while a dependent still waits for its dependencies to reach their required condition. All cross-service launch state — container names, IPs, start states, health, console colors, and the IP-substituted environment — moves into a new `LaunchState` actor so concurrent launches are data-race-free. Those dictionaries were previously stored on the value-type command and mutated from a serial loop, and `@unchecked Sendable` hid that hazard. A barrier between waves guarantees a service's dependencies are fully started before it begins, so `depends_on` ordering and conditions (service_started / service_healthy / service_completed_successfully) are preserved. The new `--sequential` flag restores the original one-at-a-time behavior. Verified on the Apple `container` daemon: 8 independent services took 11.6s sequential vs 6.1s parallel (all reaching running); a web->app->db chain still comes up strictly in dependency order.
`waitUntilServiceStarted` returned `.running` the moment it observed that
status. A one-shot only passes *through* `.running` on its way to
`.stopped`, so an init container still working at the first 0.5s poll was
recorded as running, and every dependent declaring
`service_completed_successfully` was then refused:
Service 'etcd2' depends on 'etcd-init' with condition
'service_completed_successfully', but 'etcd-init' has not completed
successfully.
The bug predates concurrent launch — launching one at a time simply lost
the race almost never, because a `chown -R` init had usually already
exited by the first poll. With a whole wave of VMs booting at once it
became reliable.
Whether a service must be waited out is not readable from the service
itself; it lives on whoever depends on it. `servicesRequiredToComplete`
collects those names, and `configService` passes `awaitCompletion` down
so the readiness wait keeps polling for `.stopped`. `maxWait` still
backstops a compose file that declares the condition on something which
never exits.
The collection is pure and tested. The wiring from it into the readiness
wait is not: that path needs a running daemon, as does
`waitUntilServiceStarted` itself, which has no test today either. It is
covered only by an end-to-end run.
A container reaching `.running` does not mean its name resolves yet.
Apple Container publishes the DNS record a moment after the container is
up; Docker's embedded DNS updates as the container joins the network, so
`depends_on` there implies resolvability and compose files rely on it.
haproxy resolves every `server` line while parsing its config and refuses
to start if one fails, so a proxy in the next wave died on
[ALERT] 'server postgres_replicas/patroni2' : could not resolve
address 'patroni2'.
with patroni2 running and healthy — the same name resolved from another
container seconds later.
After recording a service's IP, poll the host resolver for the name the
daemon registers (`<container>.<dns domain>`, which the host can answer
via /etc/resolver) until it appears. The wave barrier then also gates on
DNS, which is what the next wave actually needs.
Advisory rather than fatal: on timeout it warns and proceeds, since a
compose file may name a container the daemon never registers.
Only the name derivation is unit-tested; the poll needs a live daemon, as
does everything else on this path.
Rejected first: giving haproxy a `resolvers` section with
`init-addr last,libc,none`. haproxy's own resolver does not expand search
domains, so it queried the bare `patroni2` and cached NXDOMAIN, parking
every backend in maintenance permanently — strictly worse than the race
it was meant to fix. The fix belongs where the asymmetry is.
…t to start Two different questions shared one loop and one budget, and a one-shot that does its work quietly failed both of them. **The idle timeout judged a container that had already started.** It exists for a container that is stuck coming up: an image pull streams progress, so silence with nothing running means genuinely stuck rather than slow. Once the container has been seen running that reasoning no longer holds — a `chown -R` over a populated volume prints nothing for minutes, which is the normal case, not a hang. `waitVerdict` now applies the idle budget only before the container starts; `maxWait` still bounds the whole wait, because unbounded is the worse failure. The message for the post-start case no longer says the container timed out "waiting to be running", which it demonstrably was. **`containerWait`'s response timeout was a ten-second cap on the one-shot's runtime.** `containerWait` answers when the container exits, so that response timeout is not a request budget — it is how long the init is allowed to take. Any init doing real work failed with `XPC timeout for request to com.apple.container.apiserver/containerWait`, naming neither the service nor what was being waited for. It now matches the wait loop's ceiling, so one number bounds "how long may a one-shot take". Found the second one by fixing the first: with the idle timeout out of the way, the 45-second regression below still failed, at ten seconds, on the XPC call. Seven static tests on `waitVerdict` cover the split, including that silence before starting still trips the idle budget and that both thresholds are exclusive. The dynamic regression runs a one-shot that sleeps 45 seconds in silence with a dependent gated on `service_completed_successfully`, and asserts `up` both succeeds and actually waited — it takes ~52s, where before it failed at 30 and then at 10.
…container The wave barrier resolves the name with `getaddrinfo` in the compose process, which goes through the host's resolver. A dependent resolves through its own `/etc/resolv.conf` against the daemon's DNS from inside the container network. Those are different paths, so the host lookup the barrier performs does not establish the property the barrier exists to provide. This starts a real dependency pair and asks the dependent: its `resolv.conf` carries the domain, and `getent hosts` resolves the dependency by both its short service name and its registered dotted name. The project is named after a domain discovered from `container system dns list` rather than a hardcoded one, because `up` derives its DNS domain from the project name — under any other name the DNS path is never taken and there is nothing to assert. With no domain registered the test says so and returns. It carries an anti-vacuity guard, because writing it without one produced a convincing false positive: an exec against a container that does not exist also returns nothing, and on the DNS path containers are named `<service>.<domain>` rather than `<project>-<service>`. The first version used the wrong shape, execed into nothing, and read the empty output as a resolution failure. The guard now proves the target is reachable before any miss is interpreted; pointing it at the wrong name again fails with "the assertions below would be vacuous" instead of reporting a defect that is not there.
58b9f21 to
dc89bd8
Compare
|
Pushed, rebased onto current One-shot completion vs. startup. You were right that they were entangled, and fixing the first one exposed a second cap underneath it. The idle timeout exists for a container stuck coming up: an image pull streams progress, so silence with nothing running means genuinely stuck rather than slow. Once the container has been seen running that reasoning stops holding — a With that out of the way the 45-second regression still failed — at ten seconds, on Seven static tests on DNS from a dependent container. Added, and your reasoning holds — the barrier's The project is named after a domain discovered from Worth flagging: writing this produced a convincing false positive first. On the DNS path containers are named Static suite: 259 tests / 25 suites green. Still open from my earlier comment, and still your call: the DNS domain on this branch is derived from the project name, so an operator who registered a domain that isn't their project's name gets |
Summary
Two ways
upreleased a service before its dependents could actually use it. Both predate concurrent launch; launching one at a time simply lost the races almost never.A one-shot recorded as running
waitUntilServiceStartedreturned.runningthe moment it observed that status. A one-shot only passes through.runningon its way to.stopped, so an init container still working at the first 0.5s poll was recorded as running, and every dependent declaringservice_completed_successfullywas then refused:Whether a service must be waited out is not readable from the service itself — it lives on whoever depends on it.
Service.servicesRequiredToCompletecollects those names andconfigServicepassesawaitCompletiondown, so the readiness wait keeps polling for.stopped.maxWaitstill backstops a compose file that declares the condition on something which never exits.With a
chown -Rinit and six VMs booting at once this was reliable rather than occasional.A container running before its name resolves
Docker's embedded DNS updates as the container joins the network, so
depends_onthere implies resolvability and compose files rely on that. Apple Container publishes the record a moment after the container is up.haproxy resolves every
serverline while parsing its config and refuses to start if one fails, so a proxy in the next wave died onwith patroni2 running and healthy — the same name resolved from another container seconds later.
After recording a service's IP,
upnow polls the host resolver for the name the daemon registers (<container>.<dns domain>, answerable via/etc/resolveronce a DNS domain is configured) until it appears. The wave barrier then also gates on DNS, which is what the next wave actually needs.Advisory rather than fatal: on timeout it warns and proceeds, since a compose file may name a container the daemon never registers.
Rejected first
Giving haproxy a
resolverssection withinit-addr last,libc,none, so it would start with unresolvable backends marked down and re-resolve later. haproxy's own resolver does not expand search domains, so it queried the barepatroni2, cached NXDOMAIN, and parked every backend in maintenance permanently — strictly worse than the race it was meant to fix. The asymmetry is between the two runtimes' DNS timing, so the fix belongs here.Verification
Ten new tests cover the two pure pieces: which services must be waited out (list vs map
depends_on, each condition, one init shared by several dependents), and the registration name (plain, already-qualified, multi-label domain).The waits themselves are not covered — both need a live daemon, as does
waitUntilServiceStarted, which has no test today either. They are covered only by an end-to-end run: 23 services, all up, health checks green.