Skip to content

feat(daemon): an installation that downloads for an hour, and one that has stopped - #67

Merged
aaldersondev merged 1 commit into
mainfrom
feat/watchable-installs
Aug 7, 2026
Merged

feat(daemon): an installation that downloads for an hour, and one that has stopped#67
aaldersondev merged 1 commit into
mainfrom
feat/watchable-installs

Conversation

@aaldersondev

Copy link
Copy Markdown
Contributor

Palworld cannot be shipped until an installation can survive a Steam depot. Two things stood in the way, both verified before starting:

  • waitForExit was a bare container.wait() with no bound at all — an install stalling on a dead mirror left the server in installing for ever, and nothing gave up;
  • grep -rn "statfs" apps/daemon/src returned 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 -s suppresses 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_bytes is 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, 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: dockerode is URL-building over docker-modem, and every method on the client, a container, an image or a network ends in exactly one dial per 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 timeout option 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 like docker.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, not bfree: 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.md now says so plainly, rather than implying the preflight is an enforcement.

Verification

  • Can an install that works today start failing? Walked every bundled template and a parkervcp modpack egg against what the kernel accounts to the cgroup during its slowest step. Neither new column is populated by anything that ships, the importer sets neither, and installGuards emits no key for a NULL column — the wire payload for the whole existing catalogue is byte-identical.
  • The bounding mechanism was checked against the installed 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.
  • Mutation-tested throughout. The premise fix, the null-counter distinction, the single removal, the wait cancellation and the settled guard each have a test that fails when the guard is removed. Survivors are listed in the review rather than claimed absent.
  • Migration chain deploys to a throwaway PostgreSQL; prisma migrate diff --exit-code reports No difference detected.
  • lint, typecheck, test, format:check green — 1255 tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QL3QL3ReEa9Sk68mxJW6Fu

…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
@aaldersondev
aaldersondev merged commit 490abe6 into main Aug 7, 2026
3 checks passed
@aaldersondev
aaldersondev deleted the feat/watchable-installs branch August 7, 2026 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant