feat(daemon): an installation that downloads for an hour, and one that has stopped - #67
Merged
Merged
Conversation
…t has stopped Palworld cannot be shipped until an installation can survive a Steam depot, and two things stood in the way. `waitForExit` was a bare `container.wait()` with no bound at all, so an install that stalled on a dead mirror left the server in `installing` for ever and nothing gave up. And there was no disk check anywhere in the daemon: a depot larger than the node's free space filled the host disk, which takes down every server on that machine. **The deadline measures inactivity, and inactivity is what the container does rather than what it says.** The first cut watched output. That premise is false here: every bundled install script downloads with `curl -sSL`, and `-s` suppresses the progress meter, so the transfer emits nothing at all from beginning to end — a two-gigabyte modpack on a slow uplink is a working install that a silence window would have killed. The second cut added network counters, which was worse in a subtler way. `stats.networks[*].rx_bytes` is a link-layer interface counter: every server on a node shares one bridge, a bridge floods broadcast ARP to every port, and curl keeps TCP keepalives on. A stalled install kept looking alive on exactly the busy nodes where an endless one costs most. What is watched is what the kernel charges the container's own cgroup — CPU time and block I/O — plus its output. A transfer that is moving burns CPU on every packet it takes off the socket; one that is blocked on a dead socket burns none and issues no I/O. A host that keeps neither counter reports nothing rather than zero, because "this host does not measure it" and "this container did nothing" are opposite answers and folding them together would kill every install on such a node. **Docker is bounded once, at the client.** Four successive reviews each found more unbounded round trips — the pull's progress stream, then create, attach and start, then the reclaim's four, then `removeIfExists`, the stats sample, `listImages`. Bounding them one `Promise.race` at a time was the wrong shape: with no rule, every call is a fresh chance to forget. `DockerClient` now wraps its own `modem.dial`, the one seam every dockerode method passes through, so a call added tomorrow is bounded without its author knowing the rule exists. The exceptions are named and tested: the wait for a container to end, and the streams, which are held open by design and whose deadline covers only the answer. Space is checked before the image is pulled, against the volume's own filesystem, and a shortfall is refused rather than warned about — with the numbers and the filesystem named. A reinstall counts what the volume already holds towards the requirement, since nothing wipes it first; without that a forty-gigabyte server could never be reinstalled. Deliberately not here: resuming an install a daemon restart interrupted, and real disk quotas. Those are node-provisioning features that Steam merely exposes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QL3QL3ReEa9Sk68mxJW6Fu
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.
Palworld cannot be shipped until an installation can survive a Steam depot. Two things stood in the way, both verified before starting:
waitForExitwas a barecontainer.wait()with no bound at all — an install stalling on a dead mirror left the server ininstallingfor ever, and nothing gave up;grep -rn "statfs" apps/daemon/srcreturned nothing. A depot larger than the node's free space filled the host disk, which takes down every server on that machine.The deadline measures inactivity — and inactivity is what the container does
This took three attempts, and the two wrong ones are worth recording because both were plausible.
Attempt one watched output. False premise, and the counter-example is in this repository: every bundled install script downloads with
curl -sSL, and-ssuppresses the progress meter, so the transfer emits nothing from beginning to end. Imported Pterodactyl eggs use the same idiom. A 2 GiB modpack on a slow uplink is a working install that a silence window kills — the window becomes exactly the total-duration cap the design had rejected, applied to the one step that legitimately takes hours.Attempt two added network counters. Worse, and subtler.
stats.networks[*].rx_bytesis a link-layer interface counter, not a measure of what the container caused: every server on a node shares one bridge, a Linux bridge floods broadcast ARP to every port, and curl keeps TCP keepalives on. A genuinely stalled install kept looking alive — on exactly the busy nodes where an endless install costs most. The original bug, surviving inside the guard written to close it.What ships is what the kernel charges the container's own cgroup — CPU time and block I/O — plus its output. A transfer that is moving burns CPU on every packet it takes off the socket; one blocked on a dead socket burns none and issues no I/O. The page-cache case is covered by CPU, which moves throughout even when writes have not reached the block layer.
A host that keeps neither counter reports
null, not zero."this host does not measure it"and"this container did nothing"are opposite answers, and folding them together would have killed every installation on such a node.Docker is bounded once, at the client
Four successive reviews each found more unbounded round trips: the pull's progress stream, then
createContainer/attach/start, then the reclaim's four, thenremoveIfExists, the stats sample,listImages. Bounding them onePromise.raceat a time was the wrong shape — with no rule, every call is a fresh chance to forget.DockerClientnow wraps its ownmodem.dial: dockerode is URL-building over docker-modem, and every method on the client, a container, an image or a network ends in exactly onedialper HTTP request. So a call added tomorrow is bounded without its author knowing the rule exists, which no per-call-site race can achieve.Rejected alternatives, both wrong the same way: dockerode's
timeoutoption and an HTTP agent timeout are socket inactivity timeouts and cannot tell a request Docker is ignoring from a stream Docker is deliberately holding open — they would have destroyed the adopted servers' console and stats streams. A Proxy on dockerode's methods would have bounded composites likedocker.run()as a whole and reported any container running over a minute as a Docker fault.Exceptions are named, commented and tested:
POST /containers/{id}/wait, and the streams — for which the deadline covers only the answer, so what flows afterwards passes no clock. Tested against a real socket: "leaves a stream that has gone quiet alone".Space
Checked before the image is pulled, against the volume's own filesystem, and a shortfall is refused rather than warned about — naming the numbers and which filesystem they came from. A reinstall counts what the volume already holds towards the requirement, because nothing wipes it first; without that a 40 GiB server on a node with 5 GiB free could never be reinstalled.
bavail, notbfree: the daemon runs as root, and the reserved blocks are the margin that keeps a full machine repairable.Deliberately not here
Resuming an install a daemon restart interrupted, and real disk quotas. Those are node-provisioning features that Steam merely exposes. An install script writes into an unquotaed bind mount and
docs/security.mdnow says so plainly, rather than implying the preflight is an enforcement.Verification
installGuardsemits no key for a NULL column — the wire payload for the whole existing catalogue is byte-identical.dockerode@5.0.1/docker-modem@5.0.7, not against comments, and tested through a real Unix socket with a server that never answers.null-counter distinction, the single removal, the wait cancellation and thesettledguard each have a test that fails when the guard is removed. Survivors are listed in the review rather than claimed absent.prisma migrate diff --exit-codereports No difference detected.lint,typecheck,test,format:checkgreen — 1255 tests.🤖 Generated with Claude Code
https://claude.ai/code/session_01QL3QL3ReEa9Sk68mxJW6Fu