fix: one state file, many instances — an orphan you cannot stop - #10
Merged
Conversation
`paddock stop` answered "not running" while a paddock held the port, so `paddock stop && paddock start` failed on "port 8787 is already in use". The operator's report was "always have this issue", and it was not a version mix-up: it is one bug with two halves. There is ONE state file per config dir, but paddock is per-PORT — a `PADDOCK_PORT=…` instance, a `--demo` on a spare port, a dev or test server. `recordState` wrote unconditionally, so the second instance to start silently took over the first's record; `removeState` deleted unconditionally, so the first instance to EXIT wiped the file. Whatever still held the dashboard's port was then untrackable for the rest of its life. paddock's own advice walks an operator into it: "port 8787 is already in use … choose another port: PADDOCK_PORT=8788 paddock". Do that, then stop the second instance, and the first is orphaned. (`paddock tunnel` is NOT one of these cases, though an earlier draft of this message said so — it serves the dashboard itself and its preflight refuses to start beside a recorded instance. Verified by running it against a live one.) Reproduced end to end before fixing: start A on one port, start B on another, Ctrl-C B, and A serves on with `status` and `stop` both saying "not running" and `start` refusing A's port. FIRST INSTANCE WINS. `recordState` refuses to write over a `running` record belonging to a different pid, and says so. Only that case blocks the write — a stale record, a mismatch, garbage and this pid's own earlier record must all stay claimable, or one leftover file would lock out every future start. A conflict check that THROWS also refuses, and is caught: this runs at top level right after the bind, where an escaping rejection would kill a paddock that is already serving, and "cannot tell who holds the record" must not become "overwrite it". Servers now clear their record with `removeOwnState`, which deletes only a record it can positively identify as its own; `removeState` stays unconditional for `stop` and `status`, which delete only after deciding the record is theirs to clear. That fixes new orphans. For the ones already out there — a SIGKILL leaves no cleanup either — `status` and `stop` now probe the port when no record exists and name what is serving, instead of reporting a live process as "not running". `stop` returns non-zero there so `stop && start` halts with the reason rather than at the bind. No signal is sent: the pid lived in the record that is gone, and finding a pid from a port is platform-specific, so it hands over the command instead of guessing at a kill. Separately, `paddock update` replaces the binary but cannot restart the process running it — `/proc/<pid>/exe` reads "… (deleted)" and the old build keeps serving. It now reports the pid, port and version still running, with the restart command. Told, NOT done, by the operator's choice: restarting would drop every connected phone mid-session to finish a command they ran for the binary's sake. One existing test changed rather than an assertion: tests/cli.test.ts spawned verbs without PADDOCK_PORT, so they inherited 8787 — where the developer's own paddock listens — and the new probe legitimately found it. It now asks the OS for a free port, the same fix docs/gotchas.md already records for port ranges that "look unused". make check / make check-clean clean bun test 1023 pass, 0 fail, 101 files Verified live: B refuses to record over A and says why, B's Ctrl-C leaves A's record intact, A stays trackable and stoppable. Against the real orphan on this machine, `status` went from "paddock — not running" to naming the untracked instance and the version it was still serving; that instance was then restarted and is tracked again on 0.8.2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lntvan166
force-pushed
the
fix/one-state-file-many-instances
branch
from
August 21, 2026 02:17
872cbd6 to
115b8bd
Compare
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.
paddock stopanswered "not running" while a paddock held the port, sopaddock stop && paddock startfailed on "port 8787 is already in use". Thereport was "always have this issue", and it is not a version mix-up: it is one
bug with two halves.
Root cause
There is ONE state file per config dir, but paddock is per-PORT — a
PADDOCK_PORT=…instance, a--demoon a spare port andpaddock tunnelareall separate serving processes writing the same file.
recordStatewrote unconditionally, so the second instance to startsilently took over the first's record.
removeStatedeleted unconditionally, so the first instance to exitwiped the file.
Whatever still held the dashboard's port was then untrackable for the rest of
its life. Reproduced end to end before fixing:
paddock statuspid A, port 8795pid B, port 8796— A orphanedpaddock — not runningstop→ "not running", A survives;start→ port in useThe fix
First instance wins.
recordStaterefuses to write over arunningrecord belonging to a different pid, and says so. Only that case blocks the
write — a stale record, a mismatch, garbage, and this pid's own earlier record
must all stay claimable, or one leftover file would lock out every future
start. Servers clear their record with
removeOwnState, which deletes only arecord it can positively identify as its own;
removeStatestaysunconditional for
stop/status, which delete only after deciding the recordis theirs to clear.
Existing orphans — a SIGKILL leaves no cleanup either — are no longer
reported as absent.
statusandstopprobe the port when no record existsand name what is serving.
stopreturns non-zero there, sostop && starthalts with the reason instead of at the bind. No signal is sent: the pid lived
in the record that is gone, and finding a pid from a port is
platform-specific, so it hands over the command rather than guessing at a
kill.
paddock updatereplaces the binary but cannot restart the process runningit —
/proc/<pid>/exereads "… (deleted)" and the old build keeps serving. Itnow reports the pid, port and version still running, with the restart command.
Told, not done, by the operator's choice: restarting would drop every
connected phone mid-session to finish a command run for the binary's sake.
Note on one test change
tests/cli.test.tsspawned verbs withoutPADDOCK_PORT, so they inherited8787 — where a developer's own paddock listens — and the new probe
legitimately found it. It now asks the OS for a free port, the same fix
docs/gotchas.mdalready records for port ranges that "look unused". Noassertion was weakened.
Verification
Live: B refuses to record over A and says why; B's Ctrl-C leaves A's record
intact; A stays trackable and stoppable. Against a real orphan,
statuswentfrom
paddock — not runningto naming the untracked instance and the versionit was still serving. The real dashboard was then restarted and is tracked
again on 0.8.2.
🤖 Generated with Claude Code