Reconciler: the only actuator for every operator command, and a ladder that paces faults not operators - #1780
Conversation
4e60881 to
b55b510
Compare
Cabecinha84
left a comment
There was a problem hiding this comment.
PR #1780 — deep review
fix/restart-pacing-crash-only → fix/syncthing-first-run-gate · 350 add / 33 del · 10 files · 5 commits · no prior
reviews or comments (the PR is completely un-reviewed, so nothing here is re-litigating past feedback).
What I actually verified
- Ran the two affected unit files at head: 131 passing, 0 failing — matches the claimed 119 → 131.
- Ran the full unit suite at head: 5105 passing, 5 pending, 23 failing — all 23 are in dockerService tests, which need
a live Docker daemon and are untouched by this PR. Environmental, not caused by the change. - Traced every call site of restartWaitMs / recordRestart in ZelBack/ — there are exactly two, both in
appReconciler.js, both updated. Both new params default to true, so nothing else changes. - Simulated the real reconcile loop against the actual (non-stubbed) appsRuntimeState module to confirm the emergent
behaviour, rather than trusting the unit tests' arithmetic. - Cherry-picked the core commit onto origin/development to test the stacking claim.
The core logic is correct
- crashed is computed only after !actual.reachable (appReconciler.js:735) and actual.indeterminate (:744) have already
returned, so it is never derived from a degraded inspect — the two error paths of dockerActual that return exitCode:
null with no oomKilled field are unreachable at that point. - exitCode is gated on everRan (Status !== 'created'), so a never-run container is correctly not read as a fault.
OOMKilled comes from the same inspect — no second Docker call, no TOCTOU. - The sliding window is right. autoRestartWindow is capped at RESTART_BURST_COUNT and burstExceeded reads recent[0] —
the oldest of the last N — so stale entries can never false-trip. Verified: 4 restarts spread over hours plus one fast
one does not trip. - setOperatorStopped(id, false) clears both arrays, and appController calls it before the Docker op on both appStart
and appRestart, so a deliberate restart genuinely starts from clean. - The early return in restartWaitMs skips the stable-run ladder reset, which I initially flagged — but it self-heals:
the ladder is cleared on the first crashed=true call. Verified: 3 crash rungs + weeks of clean restarts + one genuine
crash → wait 0. - prepareCollection's twin-merge carries the new field with the correct cap.
Test quality is genuinely high — the new unit tests are specific and the e2e suite 54 asserts the mechanism rather
than the constant.
Findings
- The burst ceiling has a 12.0-second cliff — and the PR body describes it as "a minute or two" ⟵ the one that
matters
burstExceeded is evaluated before the append, against 5 entries spanning 5 × interval. So the trip condition is 5d ≤
60000 → d ≤ 12.0s. I measured it directly:
spacing=12.000s -> paced after 6 restarts
spacing=12.001s -> NEVER paced (60 restarts, 720s of hammering)
spacing=13s -> NEVER paced
spacing=20s -> NEVER paced
spacing=60s -> NEVER paced
The PR justifies the gap as "A container that merely restarts every minute or two is not a host problem and is
deliberately left alone." But the gap does not start at a minute or two — it starts at 13 seconds. An
exit-0-laundering container dying every 13s now gets ~277 unpaced container creations per hour, forever, each one a
full containerd shim + netns + veth + iptables + cgroup cycle. That container was previously paced to the 30-minute
cap.
The "Note for review" only owns the 77-second Palworld case. The actual behaviour change covers everything from 13s to
the 10-minute stable-run window. I'd ask for either a second, wider rung (e.g. 20 restarts in 10 minutes also
disposes into the ladder) or an explicit, quantified statement that 13s–10min unpaced is accepted — with the config
comment corrected, because "cause-blind backstop" currently oversells what it catches.
- boot-lock.js now fails open where it used to fail closed
if (queue.indexOf(ticket) < BOOT_LOCK_WIDTH) { /* acquired */ }
indexOf returns -1 when our ticket is not in the queue, and -1 < 2 is true — so a missing ticket reads as "you hold
the lock". bootQueue() returns [] whenever readdirSync throws, and run-parallel.sh:94 does rm -rf /tmp/e2e-boot-lock.
If the directory ever disappears mid-gate, every waiter acquires simultaneously and the semaphore is silently defeated
— which is precisely the starvation/contention failure the lock was written to prevent.
The old queue[0] === ticket failed closed (kept waiting, eventually threw the explicit "queue is wedged" error).
One-line fix:
const pos = queue.indexOf(ticket);
if (pos >= 0 && pos < BOOT_LOCK_WIDTH) { ... }
Low probability with the current runner, but a silent over-admission is a bad failure mode for a lock and the fix is
free.
- The stale comment on the failed-start path
appReconciler.js:910-913 still says "pacing is free — the attempt was recorded above, so a persistent failure walks
the backoff ladder instead of hammering." That is no longer directly true: a start failure on a never-run container
has exitCode: null → crashed=false → no ladder entry. It now retries every MANAGED_RETRY_MS (5s) and is bounded only
by the ceiling (5 × 5s = 25s ≤ 60s window, so it does still trip). It works, but the coupling is now implicit and
undocumented: if restartBurstWindowMs is ever tuned below 5 × MANAGED_RETRY_MS — and both are config-driven precisely
so the harness can compress them — a permanently failing start hammers forever. Worth updating the comment and,
ideally, a test pinning that relationship.
- The stacking claim in the description is factually wrong
▎ "no file in this PR is touched by any branch below it, so this retargets development as the five below it land."
Not so. Overlaps with the branches below:
┌──────────────────────────────────────────┬─────────────────────┐
│ file │ also touched by │
├──────────────────────────────────────────┼─────────────────────┤
│ ZelBack/config/default.js │ #1774, #1777, #1778 │
├──────────────────────────────────────────┼─────────────────────┤
│ appMonitoring/appReconciler.js │ #1774, #1777, #1779 │
├──────────────────────────────────────────┼─────────────────────┤
│ test-infra/runner/framework/boot-lock.js │ #1774 │
├──────────────────────────────────────────┼─────────────────────┤
│ test-infra/runner/framework/test-env.js │ #1774, #1779 │
├──────────────────────────────────────────┼─────────────────────┤
│ tests/unit/appReconciler.test.js │ #1777 │
└──────────────────────────────────────────┴─────────────────────┘
I cherry-picked 0a205a6 onto origin/development: it conflicts on ZelBack/config/default.js. The conflict is trivial
(adjacent config keys), and the intent — merge last, after the stack — is fine. But "retarget and go" is not accurate;
this needs to merge in stack order or be rebased.
- 6f7016a's commit message contradicts the PR body
matters
burstExceeded is evaluated before the append, against 5 entries spanning 5 × interval. So the trip condition is 5d ≤
60000 → d ≤ 12.0s. I measured it directly:
spacing=12.000s -> paced after 6 restarts
spacing=12.001s -> NEVER paced (60 restarts, 720s of hammering)
spacing=13s -> NEVER paced
spacing=20s -> NEVER paced
spacing=60s -> NEVER paced
The PR justifies the gap as "A container that merely restarts every minute or two is not a host problem and is
deliberately left alone." But the gap does not start at a minute or two — it starts at 13 seconds. An
exit-0-laundering container dying every 13s now gets ~277 unpaced container creations per hour, forever, each one a
full containerd shim + netns + veth + iptables + cgroup cycle. That container was previously paced to the 30-minute
cap.
The "Note for review" only owns the 77-second Palworld case. The actual behaviour change covers everything from 13s to
the 10-minute stable-run window. I'd ask for either a second, wider rung (e.g. 20 restarts in 10 minutes also
disposes into the ladder) or an explicit, quantified statement that 13s–10min unpaced is accepted — with the config
comment corrected, because "cause-blind backstop" currently oversells what it catches.
- boot-lock.js now fails open where it used to fail closed
if (queue.indexOf(ticket) < BOOT_LOCK_WIDTH) { /* acquired */ }
indexOf returns -1 when our ticket is not in the queue, and -1 < 2 is true — so a missing ticket reads as "you hold
the lock". bootQueue() returns [] whenever readdirSync throws, and run-parallel.sh:94 does rm -rf /tmp/e2e-boot-lock.
If the directory ever disappears mid-gate, every waiter acquires simultaneously and the semaphore is silently defeated
— which is precisely the starvation/contention failure the lock was written to prevent.
The old queue[0] === ticket failed closed (kept waiting, eventually threw the explicit "queue is wedged" error).
One-line fix:
const pos = queue.indexOf(ticket);
if (pos >= 0 && pos < BOOT_LOCK_WIDTH) { ... }
Low probability with the current runner, but a silent over-admission is a bad failure mode for a lock and the fix is
free.
- The stale comment on the failed-start path
appReconciler.js:910-913 still says "pacing is free — the attempt was recorded above, so a persistent failure walks
the backoff ladder instead of hammering." That is no longer directly true: a start failure on a never-run container
has exitCode: null → crashed=false → no ladder entry. It now retries every MANAGED_RETRY_MS (5s) and is bounded only
by the ceiling (5 × 5s = 25s ≤ 60s window, so it does still trip). It works, but the coupling is now implicit and
undocumented: if restartBurstWindowMs is ever tuned below 5 × MANAGED_RETRY_MS — and both are config-driven precisely
so the harness can compress them — a permanently failing start hammers forever. Worth updating the comment and,
ideally, a test pinning that relationship.
- The stacking claim in the description is factually wrong
▎ "no file in this PR is touched by any branch below it, so this retargets development as the five below it land."
Not so. Overlaps with the branches below:
┌──────────────────────────────────────────┬─────────────────────┐
│ file │ also touched by │
├──────────────────────────────────────────┼─────────────────────┤
│ ZelBack/config/default.js │ #1774, #1777, #1778 │
├──────────────────────────────────────────┼─────────────────────┤
│ appMonitoring/appReconciler.js │ #1774, #1777, #1779 │
├──────────────────────────────────────────┼─────────────────────┤
│ test-infra/runner/framework/boot-lock.js │ #1774 │
├──────────────────────────────────────────┼─────────────────────┤
│ test-infra/runner/framework/test-env.js │ #1774, #1779 │
├──────────────────────────────────────────┼─────────────────────┤
│ tests/unit/appReconciler.test.js │ #1777 │
└──────────────────────────────────────────┴─────────────────────┘
I cherry-picked 0a205a6 onto origin/development: it conflicts on ZelBack/config/default.js. The conflict is trivial
(adjacent config keys), and the intent — merge last, after the stack — is fine. But "retarget and go" is not accurate;
this needs to merge in stack order or be rebased.
- 6f7016a's commit message contradicts the PR body
The message ends with "NOT YET VALIDATED AT GATE SCALE." The PR body then reports a full 82/82 gate at that exact SHA
in 70 minutes. The validation happened; the message didn't get amended. Anyone reading git log later inherits a
warning that is no longer true.
- Observed behaviour the description doesn't mention (not a defect)
Simulating the real loop for an exit-0 container dying every 2s gives waits of [0,0,0,0,0,0, 30s, 5m, 0,0,0,0,0, 15m,
...]. Each paced wait itself breaks the burst window, so the container earns another ~6 free restarts before it
re-trips. It does escalate to the 30m cap, and steady state is ~6 restarts per 30 minutes — bounded and perfectly
acceptable. But it is not the monotone ladder the description implies ("the next restart is paced 30s, and if the
hammering resumes it walks to the 30m cap"). One sentence in the body would set expectations correctly for whoever
debugs this from logs later.
Verdict
Not a blind ack — but nothing here is a correctness bug in the reconciler. The change is well-reasoned, the exit-code
asymmetry argument is sound, and the test coverage is unusually good.
What I'd ask for before acking:
- #1 needs an answer — either a wider second rung, or an explicit, quantified acceptance of the 13s–10min gap with
the config comment corrected. This is the substantive one. - #2 — the pos >= 0 guard. One line, take it.
- #4 and #5 — description and commit-message accuracy. Cheap, and #4 matters for whoever does the merge.
- #3 and #6 — comment/description updates; would not block on them alone.
If David pushes back on #1 with "13s–10min unpaced is the intended trade and the customer outcome is worth it," that's
a legitimate call and I'd ack on the strength of the rest — but it should be a stated decision, not a threshold that
reads as tighter than it is.
6f7016a to
6c98bac
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## fix/syncthing-first-run-gate #1780 +/- ##
===============================================================
Coverage ? 65.12%
===============================================================
Files ? 176
Lines ? 33037
Branches ? 0
===============================================================
Hits ? 21516
Misses ? 11521
Partials ? 0 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
6c98bac to
8b4b8b3
Compare
8b4b8b3 to
3581b93
Compare
|
Thanks — the substantive one was right and is fixed, along with three of the others. Branch is now 1. The 12-second cliff — fixed by widening the window to 300 seconds ( The knob is really that one number. 5-in-300 puts the reach at 60 seconds, which is the band the description always claimed. Widening is safer than it looks in the direction that matters: Framing worth stating, because it is the reason this mattered: the ceiling is not about "crashing". A non-zero exit or an OOM kill is recognised as a fault and paced by that path. A container exiting 0 is never treated as a fault at all, even when it is failing and hiding it — so for an image that launders its exit status, the ceiling is the only thing that would ever pace it. A 13-second invisible band matters more than it sounds. Palworld sits outside the new reach: roughly 77 seconds at its worst against a 60-second threshold. That margin is 17 seconds on a number the note itself hedges, so the body now states the boundary and the observation together rather than leaving it to be rediscovered. The reach had no test, and one derived from the same constants cannot notice them being retuned — which is how twelve seconds went unnoticed. Both are pinned now: the mechanism against 2. 3. The stale comment — corrected. It now says what actually happens: a failed start never ran, so it is not a fault and does not walk the ladder directly; it reaches the ladder by filling the burst window. The relationship you flagged is written next to 4. The stacking claim — corrected, and it was worse than you scoped. Six of the ten files are shared, not one: 5. The commit message — not changing it. A commit message is a record of what was true when it was written; we don't amend to keep them current. The PR body carries the current state, and it does. 6. The non-monotone ladder — documented. Each paced wait breaks the burst window, so the container earns roughly six more restarts before it re-trips, settling at about six per 30 minutes. Bounded, as you say, but not the straight walk to the cap the description implied. Verified at the top of the stack: 85 of 85 suites green at |
3581b93 to
afc31cc
Compare
Cabecinha84
left a comment
There was a problem hiding this comment.
PR #1780 — deep review at head afc31cc
Verdict: don't ACK yet. The 12-second cliff from the previous round is genuinely fixed, and the boot-lock fix is
correct. But the burst path has a second-order defect that makes the PR's own headline claim about the bound false,
and it re-opens a laundering bug that this very file's doc comment warns about. It's a small fix, not a redesign.
What I verified (and how)
I built a worktree at afc31cc, ran the real modules, and simulated the actual reconciler loop order (restartWaitMs →
if paced, wait and re-enter; else recordRestart → start → run → die) against the real appsRuntimeState with a fake DB
— not the unit tests' arithmetic.
- Unit suite: 5190 passing, 5 pending, 23 failing — all 23 are dockerService tests requiring a live Docker daemon,
untouched by this PR. appsRuntimeState + appReconciler together: 134 passing, 0 failing. - Lint: clean (0 errors) on every changed lint-covered file. boot-lock.js and suite 54 are under an ignore pattern.
- CI: build (ubuntu-22.04, 20.x, 7.0) passing.
- Call sites: exactly two (appReconciler.js:874, :906), both updated; both new params default to true, so nothing else
changes. - Classification: crashed is computed after the !reachable and indeterminate returns, so it never reads a degraded
inspect. exitCode is gated on everRan; OOMKilled comes from the same inspect (no second call, no TOCTOU). Five
table-driven tests cover it. - The ceiling's premise holds: dockerService.js:1034 creates containers with RestartPolicy: { Name: 'no' }, so every
restart genuinely flows through the reconciler and lands in the window. Nothing restarts behind its back. - The reach is now exactly 60s, as claimed. Measured against the real module: 59s spacing → paced; 60s → paced; 61s →
never paced, ever. Palworld at ~77s stays unpaced. The previous round's finding is properly fixed. - Mutation check: reverting restartBurstWindowMs to 60000 fails exactly one test — "paces a laundered-exit container
dying every 30 seconds". The shipped-decision test is non-vacuous, as the author claimed. - boot-lock pos >= 0: correct and complete. Fail-closed is restored; a vanished ticket now ends at the explicit wedged
error. bootQueue()'s sort is a stable (ms, pid) ordering, so width 2 is a real FIFO semaphore, not a race.
Finding 1 — the burst path launders its own ladder and never reaches the 30-minute cap ⟵ blocker
restartWaitMs resets the ladder when the previous run provably lasted STABLE_RUN_MS:
const lastRestart = history[history.length - 1]; // restartHistory
const lastDeath = Math.max(state.lastDiedAt || 0, lastFinishedAtMs || 0);
if (lastDeath > lastRestart && lastDeath - lastRestart > STABLE_RUN_MS) { reset; return 0; }
Before this PR, restartHistory received every restart, so lastRestart was always the real one and the difference was
the real run duration. This PR makes recordRestart append to restartHistory only when crashed || burstExceeded. The
automatic exit-0 restarts in between are now invisible to that check — so after any rung longer than 600s, the stale
ladder entry sits more than STABLE_RUN_MS behind the current death and the code concludes the container ran stably for
ten minutes. It did not: it restarted six times in twelve seconds.
Traced against the real module, exit-0 container dying every 2s:
t= 1250s wait= 0s ladder=[10,40,350] window=[342,344,346,348,350]
t= 1252s ... 1258s five unpaced restarts, none recorded in the ladder
t= 1260s wait= 0s ladder=[10,40,350] <-- STABLE-RUN RESET (ladder wiped)
t= 1262s wait= 28s ladder=[1260] <-- back to rung 1
Steady state is a closed cycle 30s → 5m → 15m → reset → 30s, forever. The 30-minute rung is unreachable on this path.
┌─────────────────────────────────────┬──────────────┬──────────────────────────────────┐
│ exit-0 container dying every 2s │ starts in 2h │ rungs observed │
├─────────────────────────────────────┼──────────────┼──────────────────────────────────┤
│ base (fix/syncthing-first-run-gate) │ 7 │ 30s, 5m, 15m, 30m cap │
├─────────────────────────────────────┼──────────────┼──────────────────────────────────┤
│ this PR at afc31cc │ 78 │ 30s, 5m, 15m — cap never reached │
├─────────────────────────────────────┼──────────────┼──────────────────────────────────┤
│ PR body's stated steady state │ ~24 │ "escalates to the 30-minute cap" │
└─────────────────────────────────────┴──────────────┴──────────────────────────────────┘
So the body's "It does escalate to the 30-minute cap, and steady state is about six restarts per 30 minutes" is wrong
in both halves — measured is ~19–20 per 30 min and the cap is never reached. That matters because for a laundered-exit
image the ceiling is, by the PR's own argument, the only thing that ever paces it, so what it actually bounds to is
the whole guarantee.
The same applies to the failed-start retry loop (MANAGED_RETRY_MS = 5000): it trips the ceiling as the new comment
promises, but then cycles the same way — ~39 attempts/hour rather than walking to the cap.
Why no test catches it: every new unit test calls restartWaitMs(id, null, false) and never sets lastDiedAt, so
lastDeath is 0 and the reset branch is structurally unreachable in the unit suite. E2E suite 54 asserts only the first
backoff and stops. The interaction between the burst path and the stable-run reset is untested end to end.
Fix — the stability comparison should use the last actual restart attempt, not the last ladder entry:
const lastAttempt = Math.max(lastRestart, ...(state.autoRestartWindow || [0]));
if (lastDeath > lastAttempt && lastDeath - lastAttempt > STABLE_RUN_MS) { ... }
For a genuinely crashing container the two arrays agree, so nothing changes there; a container that really did run 10
minutes still resets. Worth pairing with a unit test that passes death evidence across a full trip → pace → re-trip
cycle, since that is the shape no current test drives.
Finding 2 — the module default still says 60 seconds
const RESTART_BURST_WINDOW_MS = config.fluxapps.restartBurstWindowMs ?? 60 * 1000;
ZelBack/config/default.js ships 300000, but the in-module fallback was not moved with it. Any config that lacks the
key silently reinstates the 12-second reach that the last round existed to fix — and it would fail silently, because
both reach tests derive from the constant, not from the config. Should be ?? 5 * 60 * 1000. One line.
Finding 3 — the PR body is internally inconsistent after the retune
- Overview: "a container restarting faster than five times in sixty seconds" — that is the old framing; it is now five
in 300 seconds, reaching 60s apart. - Changes table still lists restartBurstWindowMs: 60000; the shipped value is 300000.
- The cited SHAs (3581b93, 6704fb9, f6c6bc3) no longer exist on the branch — it was rebased on 2026-08-11 and
head is afc31cc. The "85 of 85 suites green" attestation therefore points at a tree nobody can resolve. Worth
re-anchoring to the current head, since that gate run is the only evidence for the harness commits.
Not blocking, worth a line
- The trip itself is unpaced. When the ceiling first fires, restartWaitMs falls through the early return, finds an
empty restartHistory, and returns 0; pacing starts one restart later. Consistent with the body's [0,0,0,0,0,0, 30s,
...], so it looks deliberate — but the burstExceeded doc comment reads as though the crossing restart is the one
paced, and it is not. - BOOT_LOCK_WIDTH defaults to 2, changing harness parallelism for everyone. Documented, measured, and
E2E_BOOT_LOCK_WIDTH=1 reverts it. Fine. - Suite number 54 is free; seedTestApp genuinely supports { exitCode, exitAfterS } (reconciler-suite.js:418).
afc31cc to
3ff6b5d
Compare
3ff6b5d to
77dce7b
Compare
77dce7b to
1ede14b
Compare
fd9a188 to
9eac210
Compare
Every `config.fluxapps.x ?? literal` in the service layer is a second copy of a value ZelBack/config/default.js already ships - forty keys, and not one where the module owns the default. The literal guards nothing reachable: app.js pins NODE_CONFIG_DIR to ZelBack/config/, and node-config's NODE_CONFIG overlay merges rather than replaces, so the key cannot go missing on a node. What a second copy does is drift, and nothing could catch it. The unit config supplies every key, so no test takes a fallback, and the assertions that depend on these values read the constant rather than config. This walks the service layer, reads each fallback expression and compares it with what config ships. An expression it cannot evaluate is a failure rather than a skip: a guard quietly covering half its cases reads exactly like one covering all of them. config.get() removes the second copy entirely and throws by name on a missing key rather than leaving a mechanism silently disabled. That is 40 call sites here and 108 on the v9 lineage, so it is its own change; this holds the line until then. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stubbed The unit tests stub docker, so they prove a handler writes the right intent and nothing more. What a customer sees is a container's run state and a response string, and neither had a fleet driving it. apprestart is exercised on a container that is already running. The existing test restarts one that was down, which is a start - either implementation satisfies it, and the durable restart generation only decides anything when the container is up. The bounce is asserted on its actuation, then on no second bounce following: the request is a level, and a level nothing marks as reached is a loop. That second assertion is anchored after the bounce rather than over a quiet window, because a window proves nothing if no pass ran - the bounce emits its own die event, so a pass provably falls inside it. appkill has never had a route until now, so nothing has ever driven it. Asserting the container stopped would pass against a graceful stop, so it asserts the forced flag on the actuation - the only difference visible from outside. The privilege that keeps a node operator from ordering a kill is driven from the node's own identity, with both halves: refused a kill, allowed a stop. A check that refused everything would satisfy the first on its own. appstart on a masterSlave instance the election is not electing reports the election rather than claiming a start. It runs first in suite 52, before anything there stops an instance - after that, a non-running holder could be one an operator stopped, which is a different answer reaching the same assertion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ble run Suite 54 ran against production's ladder, so the only rung it could reach inside a sane timeout was the first. Everything past it - including the rung longer than the stable-run window, where the ladder used to clear itself on the wait it had just served - was reachable only by waiting twenty-one minutes, so nothing drove it. That is the shape a defect hid behind through two full gates. The ladder and the stable-run window are compressed together, because what the suite needs is not small numbers but a preserved relationship: a rung that outlasts the window that clears the ladder. Production has that at 15m against 10m; here it is 30s against 20s, and the whole climb takes about a minute. The relationship is asserted rather than assumed - compress one without the other and no rung outlasts the window, the clearing branch is never reached, and the suite goes green having stopped testing what it exists for. Two floors are not preferences. The app runs 2s before exiting and the harness does not compress a container's own runtime, so a rung near 2s would be swamped by it; and the burst window has to outlast five of those lifetimes or the ceiling can never fill. The comments describing the ceiling said the restart crossing the line is the one paced. It is counted, not held: restartWaitMs runs ahead of recordRestart, finds an empty ladder and lets that restart straight back, earning the first rung on its way past. So five recorded restarts means the sixth is counted and the seventh is the first held back - six free restarts from a knob reading as five, which is worth knowing before tuning it. Said in all three places the count is described, and asserted, because that step is the one every one of those comments describes and the one nothing checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mes up Spreading the operator's intent by compose order sent it forward in every direction, so a composed stop or kill took the database down first, while the component writing to it was still running. awaitPass holds each component's pass open before the next id is touched, so that order is the order the containers move in, not an incidental one. Reversed on the mapped ids, which map() has already made a fresh array - not on the spec, whose compose array is shared with whatever fetched it. The base reversed compose itself, which is the mutation this must not reintroduce. The tests asserted with calledWith, which holds whichever way round the components are addressed - so the stop test stayed green under its own "in reverse order" title, and the kill test never mentioned order at all. Both now pin the call index, and the start test pins the forward direction: a blanket reverse would otherwise satisfy the stop test while inverting startup. Both drills bite - disabling the reverse fails stop and kill alone, reversing every direction fails start alone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SB8Saua6GiMLqEYdvkUccG
…y sweep Every failure this file anticipates ends in one of two decisions: pace a retry, or deliberately do not because no retry can fix it. A pass that THROWS made neither - it was logged and forgotten, and with the operator's stop now actuated here rather than inline, /apps/appstop could answer "will be stopped" and leave the container running for an hour. The net goes where every unhandled failure already converges rather than at the one throw site we know about: the sites that can throw are the ones nobody thought to guard, so a per-site catch would miss the next one the same way. Retrying is safe because a pass is level-based - it re-derives desired against actual rather than resuming half-finished work - and the deliberate no-retry decisions all return rather than throw, so the net cannot override them. Bounded, because scheduleRetry is a flat five seconds with no escalation: an unhandled throw may be a permanent fault, and retrying one forever is a log loop. Three attempts, then the sweep owns it; the count clears on the first pass that completes and on forgetDesiredState, so a reinstall under the same name does not inherit it. Docker being unreachable is NOT this case - it is anticipated and defers, which is why a dockerd restart cannot exercise this path (suite 45 covers that one end to end). Three mutation drills, each killing only what it should: removing the retry fails all three tests, unbounding it fails the bound and clear tests, and never clearing fails the clear test alone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SB8Saua6GiMLqEYdvkUccG
…t surfaces appsRuntimeState splits its writes cleanly: history (recordExit, recordRestart) swallows, because losing one costs a log line, and intent (setOperatorStopped, requestRestart) throws, because losing one loses an operator's instruction. recordRestartGeneration was written as a recorder and swallowed like one, but it behaves like intent - it is the only thing that stops the next pass bouncing the container again. It is now on the right side of that split. Swallowed, a failed write read as "recorded" and the following pass found the request still outstanding. On a node whose reads work while its writes do not - a full disk, say - that restarted the app every POST_START_VERIFY_MS for as long as the condition lasted, on the one path deliberately exempt from the backoff ladder. A wholly unreachable mongo was safe by accident: getState returns null, both generations read 0, and nothing bounces. Both call sites now record LAST. The bounce or start has already happened by then, so a write failure must not also cost the actuated event, the peer notification and the post-start attachment check - it is the record that failed, not the restart. The throw reaches the pass-level retry, which paces and bounds it. This is a symptom, not the disease: nothing in FluxOS has a notion of the database being unavailable, so 189 call sites each invent their own tolerance - three different behaviours for one condition in this module alone. The real fix is a node-level degraded state, written up separately; this commit only stops the failure being silent. Drills: restoring the swallow fails the storage-boundary test alone (the reconciler tests stub that function and own a different property); restoring the original ordering fails the bounce-path test while the start path stays green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SB8Saua6GiMLqEYdvkUccG
The operator's lock and the kill/drain flag are two fields of one document, and the stop branch re-read that document for the flag alone. getState returns null for a read failure exactly as it does for "no record", so a second read that failed reported no force flag and turned the operator's "kill now" into a drain they never asked for - the very downgrade the comment above it claimed durability prevented. That comment was true of a crash and false of a read. The window was narrow: reaching that branch needs isOperatorStopped to have already returned true from the same document through the same swallowing function, so it took one read to succeed and the next to fail within a pass. Narrow, but it needed no handling at all - the second read should not exist. operatorStopState answers both from one read, and the flag now travels with the decision that read it. There is nothing later to disagree with it, the comment is true as written, and every operator stop costs one round-trip fewer than it did. isOperatorStopped stays for its other caller. The tests gain from it too: the force cases set one stub where they used to set two that could describe a state production cannot produce. The regression test holds getState empty - which is exactly what the failed read looked like - and asserts the kill still lands; restoring the second read fails it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SB8Saua6GiMLqEYdvkUccG
… front Two the review found, neither behavioural. restartWaitMs documented a third parameter, crashed, that it does not take - removing it was this PR's own decision, explained in the body, so the doc block contradicted both the code and the reasoning for it. boot-lock captured ahead_on_arrival as Math.max(0, indexOf), turning the -1 of "not in the queue" into "arrived to an empty queue". That is the most misleading value available: bootQueue() returns [] exactly when the lock directory cannot be read, which is the defeated-semaphore case the lock exists to expose, so a gate that was not working at all logged as a gate with no contention. df3e034 refused that same conflation for the acquire decision and the wedged-wait error and left the telemetry line behind it; it now reports unknown, as that error already does. Telemetry only - nothing reads either value - and neither is covered: boot-lock has no unit tests and the value exists only in a log line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SB8Saua6GiMLqEYdvkUccG
The mid-backup test now ends where it could not before: the hold is released and the elected primary converges back to running inside a cycle. This is the end-to-end reading of what recordRestart's crashed flag decides. The suite starts and stops this app repeatedly - elects it, fails it over, operator- stops it, restarts it - and the backup's stop phase takes it down once more. None of that is evidence of a fault, so none of it earns a rung, and the ladder the restart is paced against stays empty. The two-minute window is what discriminates. Counting deliberate stops puts the component on the five-minute rung by the time this test runs, so the assertion fails on a tree that counts them and passes on one that does not - the app is either back within a cycle or it is visibly being paced. It sits here rather than with the busy-guard assertions above it because it is this branch's property, not theirs: those prove the election leaves a busy app alone, which holds either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnTSdPPSRdt8jgm4s5Bxtq
…ator's
appownerabove admits the node admin, so an operator could remove any app hosted
on their node that was not vetted. Hosting an app is not owning it: an operator
who can remove one can script that against every install and keep a customer's
app off the node indefinitely, which the customer experiences as an app that will
not stay deployed and cannot diagnose.
appownerorfluxteam is {owner, fluxTeam, fluxSupport} - the same set the
vetted-app branch below admitted, which existed only to keep the operator away
from vetted apps. With the operator out of the gate it can no longer refuse
anyone who reaches it, so it goes, and with it two database reads and a vetted
lookup on every uninstall.
This is user-visible on a reachable route: an operator's uninstall now answers
Unauthorized. The frontend control that offered it is narrowed to fluxteam in
RunOnFlux/fluxos-frontend#150.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard evaluated arithmetic only, so `?? false`, `?? null` or a string fallback came back unreadable and failed with "make it a plain literal" - which for a boolean is not an achievable instruction. A boolean drifts from config exactly as a number does, and declining to compare one leaves a whole class of fallback outside the net the guard exists to hold. Booleans and null are literal comparisons and a quoted string is a regex capture, so the digits-and-operators whitelist that guards Function() is unchanged. The unparseable sentinel is a Symbol rather than undefined, because `?? undefined` is writable and would otherwise be reported as unreadable instead of compared. What remains genuinely unparseable - a named constant, an expression over another variable - now names the action that always exists: drop the fallback, since config ships the key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…omponent A node whose public address moves restarts the apps that stay on it. That was appController.appDockerRestart(app.name), which resolves ONE container by name - and a composed app has none under its bare app name, its containers are `<component>_<app>`. The lookup reads .Id off an undefined result, so the first composed app threw a TypeError with nothing to catch it: the throw left the loop for adjustExternalIP's outer catch, and every app after it kept the old address, the fluxipchanged broadcast never went out, the confirmation transaction never ran and the geolocation update never happened. One log line was the trace. The surviving apps are now handed over as durable restart requests, one per component, which survive a FluxOS restart part way through and queue behind the same slot as every other intent. Each is independent, so one component that cannot be recorded costs the others nothing and leaves the work after the loop reachable. Handed over through a seam, not a require. appUninstaller requires fluxNetworkHelper and appReconciler requires appUninstaller, so reaching upward from the network layer closes a cycle - and twenty-odd app-layer modules import it, so it stays underneath them. fluxNetworkHelper.setOnAddressChanged mirrors appUninstaller.setOnComponentRemoved, and serviceManager wires it, as it wires that one. componentIdsOf is shared with enqueueAll rather than copied: which components an app has, and the docker fallback for one whose specs will not decrypt, is a rule that wants one home. appController.appDockerRestart is deleted - nothing in that module drives a container now. Also corrects two comments that claimed more than the code does: applyIntent's wait is bounded only while the docker calls a pass makes return, and the restart-generation retry is a rate rather than a bound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing could reach the address-change path: a node learns its own address moved by comparing benchmark's getpublicip against userconfig, and the harness had no way to make those differ short of renumbering a running container. The stub now holds a per-node override keyed by the address a request ARRIVES from, so the container keeps its real address and every peer stays reachable - only the answer moves. POST /public-ip sets it, omitting `reported` clears it, and /reset drops the lot. Suite 56 drives it against a COMPOSED app, which is the shape that matters: both components must be seen restarting on their own identifiers, and the confirmation transaction that follows the app loop must arrive, which is what distinguishes a handler that ran to the end from one that stopped at the first app. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The duplicate check asked whether the app was already running at the address being moved to, and treated any answer as another node holding the ports. The node's own registration is one of those answers: it stores its own running-app row locally, at the address benchmark reports, so the row found at that address is usually itself. It removed the app - forced, and broadcast - for being exactly where it belonged. Observed in production: a node force-removed two apps this way, at an address it had held unchanged for years. The address is still compared at IP granularity, which is the question being asked: one instance per IP is what the host port mapping allows, so a UPnP sibling on another port holds the ports just as surely. Own-ness is the full socket address, and that is what tells the two apart - the same socketAddressesMatch test the collision check in this file already uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… end to end A node only notices its address moved when two answers disagree: benchmark's public-IP probe reads the new one while its own reported address, its status and its list entry still say the old. The stub's address control takes a scope for that - `all` for a change already settled everywhere, `publicip` for the state a node is actually in when it has to detect one. Moving them together describes a change nothing has to notice, and leaves the path unreachable. Detection also needs the node to be unreachable to its peers, because it only asks benchmark after a peer has failed to reach it. blockPeerAccess drops inbound traffic to a node's API port from named peers - named rather than the subnet, since the runner reaches the node from the docker gateway on the same /24 and a blanket rule would cut off the client doing the asserting. Suite 56 drives that sequence against a COMPOSED app on three nodes: both components restart on their own identifiers, and a peer accepts the fluxipchanged broadcast, which is what distinguishes a handler that ran to the end from one that stopped at the first app. Peers resolve that message by the OLD address it carries, which is why the list stays where it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…it for The mid-backup test states 'a rung is earned by evidence of a fault' and proves it against an app that traps SIGTERM and exits 0, which is the one class the rule holds for unconditionally. An image that does not trap it exits 143 for the same deliberate stop and is paced for it. Naming that at the assertion stops the suite reading as coverage of a rule it only covers half of. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The uninstall gate moved to appownerorfluxteam on the principle that hosting an app is not owning it. Installing one was left on adminandfluxteam, which is the same decision from the other end and leaves the operator able to put an app on their node but not take it off. The by-name branch resolves an app from the marketplace, the global registry or a permanent message, so an operator reaching it chooses WHICH customer's app runs on hardware they control - and for a g:/r: app the new instance syncs that customer's data down to it. Placement is the spawner's decision. Both entry points move, since testAppInstall resolves by name on the same terms. The temporary-message branch above them is deliberately untouched and stays open to any logged-in user: that is how an app is tested before it is registered, it is addressed by hash rather than by name, and it expires on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four handlers each worked out an app's components themselves, and got it wrong in two directions from the same line. A whole-app command read `compose` off the stored spec. An enterprise app keeps its component names inside an encrypted blob, so its compose is empty: the command addressed nothing, every one of zero components reached stopped, and it reported success. A component command took the name verbatim. A name that is not one of the app's components addresses nothing either, but it still wrote a durable operator lock under it - nothing clears one, and it holds the real component down if one is ever created with that name. Both come from working the answer out at the call site rather than asking. The components now come from appReconciler.componentIdsOf, which is the one place that knows how - decryption and the docker fallback included - and a component name is checked against them. The four handlers lose their duplicated branching and this module no longer reads stored specs at all. containersReachedStopped also read "nothing there" as "stopped": dockerActual reports exists separately and the pair was read as one, so a command against a container that does not exist answered that it had been stopped. It now says what appStart already said - that it is not installed on this node. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… and rewritten requestRestart read the current generation to add one to it. getState answers null for two different things - no record yet, and the read failed - so a failed read counted as zero and wrote generation 1 over one already past it. The reconciler compares the generation against what it last actuated, reads a lower number as nothing pending, and does nothing; the handler then finds the container running and reports a restart that never happened. $inc has no such answer to misread, and creates the field at 1 when there is no record, which is all the read was for. It also counts two concurrent requests as two, where read-then-write had both read the same number and one silently overwrite the other. The guarded write is now shared rather than copied: the loser of a concurrent first upsert throws a duplicate-key error instead of converting to an update, so without the retry a restart request would be dropped exactly as an operator stop lock would. The test fake honours $inc the way mongo does, or a read-then-write implementation passes these tests unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pins the behaviour fixed alongside the component-id work: the volume-unavailable branch honours the operator's stop as well as the controller's, and carries the kill flag rather than downgrading an appkill to a graceful stop. Both directions are pinned separately - reading only the controller reds the stop test, and dropping the force flag reds only the kill one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every part of adjustExternalIP needs to know which node this is: whose registration among those at the new address is our own, which apps are ours to hand over, and what address the fluxipchanged broadcast is moving FROM. localSocketAddress is cleared whenever benchmark hiccups, and a comparison against nothing matches nothing - so acting on it read our own rows as strangers' and uninstalled the apps they belonged to. It returns BEFORE the userconfig write, which is what makes this a deferral rather than a silent drop: that write is what marks the change handled, so leaving it unwritten leaves the change pending. checkMyFluxAvailability already refuses to run while the address is unknown, so nothing reaches here again until benchmark answers - and then it runs with the node knowing itself, exactly once. The four tests that drove adjustExternalIP with no address set were proxyquiring their own module instance, so the beforeEach set it on a module the code under test never read. Fixed at the fixture rather than by softening the guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A peer asked whether it can reach a node probes it and has to answer within the asker's own timeout budget. Dropped packets blackhole, so that probe burns its full timeout and the peer answers too late - the asker times out on the PEER and reads "I could not ask" instead of "I am unreachable", which retries without ever consulting benchmark. The address change is then never detected. Refusing fails the probe instantly, so the answer arrives in time and says what it is meant to say. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t does Suite 56 moves a node for real - the new address onto its interface, the old one off - rather than simulating unreachability. Its peers then fail to reach it at the old address because it genuinely is not there, which is what makes the node ask benchmark whether it moved; and the deterministic list is untouched, so those same peers still recognise it as the sender of the fluxipchanged broadcast that follows. Simulating either half breaks the other: blocking packets makes a peer's answer arrive after the asker has given up, and hiding the node from peers' lists makes them reject its broadcast as coming from a stranger. Two harness assumptions had to go, both of them things an address change necessarily violates: The runner reached a node at a fixed address, so a node that moved went blind to its own client - every request went to where it used to be and the event stream died with the address. nodeClient.followTo re-points them and reconnects, so the observer follows rather than being left behind. The reconnect empties the buffer, so the baseline is taken from what it returns. The daemon stub identified a node by the address its requests arrive from, so a node that moved lost its identity: every answer about it fell back to the not-found defaults, it read its own address as 127.0.0.1, decided it was not in the confirmed list, and skipped the availability check on every cycle. It is now recognised at its reported address too, which is the state an address change puts it in before the chain catches up. The timings are measured, not assumed: a probe to an address that is gone fails EHOSTUNREACH at ~3.1s, inside a peer's 5s probe budget and the asking node's 7s answer budget. Two consecutive runs green, 3m35s and 2m32s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suite 21 booted five ten-node fleets for five scenarios that differ only in the app they register - three of them byte-identical Arcane configs, two identical Legacy ones. A fleet boot is the most expensive thing it does: ten nodes each build their own full index set on the shared mongod, so an identical fleet booted three times spends ~2,800 index builds to learn nothing. It also left the suite running long after it needed to be, with its slowest fleet - Legacy, ~28s against Arcane's ~11s - starting when a gate is at its busiest. That is how its last environment came to overshoot a 90s hook budget by one second and fail a gate it passed on the next run. Each app is still registered in its own before, immediately ahead of its own tests, so no scenario depends on another's app and a deferral is still observed against the app that caused it. Verified with a prior scenario's app installed on the shared fleet. Measured on an idle box: 5 boots and 91.1s of boot time become 2 and 45.5s, wall clock 6m03s to 4m47s, all ten tests passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
componentIdsOf builds its list from two sources - compose for an app whose spec reads, docker's own container names for one that will not decrypt - and docker namespaces every name it holds. The list therefore came back as `api_App` or `fluxapi_App` depending on a condition that has nothing to do with naming. Nothing could see it. Every consumer until now fed the ids into enqueue or applyIntent, both of which canonicalise on ingest, so the prefix was stripped before anything compared it. The first consumer to compare the list against a string rather than pass it on is operatorTargetIds, which tests the component name an operator typed for membership: for an app whose spec will not decrypt it found `fluxapi_App` where the operator wrote `api_App`, and refused every component-level appstart/appstop/apprestart/appkill with "is not installed on this node". The base handler took the name verbatim and reached docker, which applies the prefix itself, so this is a regression against it - and it bites only while the node cannot read the spec, which is when manual control matters most. Stripped at the source rather than at the new consumer, so one spelling reaches everyone; the canonicalising consumers cannot tell the difference. What compose order such an app should stop in is neither recoverable nor addressed here: an unreadable spec has no compose, and docker lists containers in its own order. The unreadable branch was already exercised - the enqueueAll sweep test feeds it `/fluxc1_EntApp` - but only through enqueue, which canonicalises, so that test holds either way. Both new tests fail without the fix and nothing else does: componentIdsOf is asserted directly across both branches, and appStop is driven against an undecryptable app with componentIdsOf unstubbed - the first test in that file to run the real function rather than state what it returns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…done operatorTargetIds resolves what an operator's command should act on, and could answer with an empty list. Every step after it then agreed the command had succeeded: the loop that writes the operator's intent ran zero times, the all-actuated flag was never lowered because only an iteration lowers it, and containersReachedStopped walked no containers and settled. So appstart, appstop, apprestart and appkill answered `Application X stopped` having written no durable intent and touched nothing - and with nothing recorded, no later pass picks the request up either. The app is known to be installed two lines above, so an empty list never means "nothing needs doing". It means this node cannot work out what the app is made of: a spec that will not decrypt, falling back to a docker listing that is empty or that failed outright. Refused, alongside the two refusals the function already makes, and worded for the operator who reads it - HomeUI surfaces the message verbatim in AppControl.vue, so what it has to say is that nothing happened. Reachability, since it bounds what this is worth: every route to an empty list runs through a failed decrypt. A v1-3 app contributes its own name and a readable v4+ app contributes its compose entries, so only the undecryptable branch can contribute nothing - which makes this a guard against a state that should not arise, not a fault with a live trigger. The listing failure behind that branch also drops every undecryptable app from the sweep and from the address-change restart, returning a list no caller can tell from a complete one. It stays lenient - one app's failure must not cost the readable apps their sweep - but it now names what it dropped, at error level. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Which privilege removeAppLocallyApi asks for is the whole of the policy. The two candidates differ by exactly one member: appownerabove admits the node operator, appownerorfluxteam does not, and nothing else stands between an operator and removing a customer's app from hardware they host. The only assertion here was that some privilege had been checked, so swapping one for the other passed. What each privilege admits was already covered - verificationHelperUtils.test.js asserts appownerorfluxteam is "FALSE for the node operator, who OrHigher admits", alongside true for the owner and for the team, and verificationHelper.test.js maps the string to that function. The unproven hop was the first one, whether this route asks for it at all. It is asserted with the app name and as the only privilege check on the path, which is also what the removed vetted-app branch would have added back. The install side was already pinned this way, including a case that discriminates rather than merely fails: the old gate satisfied and the new one refused, with the request rejected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fc61964 to
c24d099
Compare
Overview
Two things, both about who decides a container's run state.
The crash-recovery ladder paces faults, not every stop. It used to pace all of them, so an operator restarting their own app walked the same escalating waits as a container that could not stay up. A customer restarting their Palworld server six times in twenty-one minutes reached the fifteen-minute rung, which from the outside is indistinguishable from the app refusing to come back. That is what the support ticket was about.
Every operator command is desired state the reconciler converges to.
appStart,appStop,appRestartandappKillrecord the operator's intent and let the single actuator converge on it; no route inappControllerdrives a container. SeventeenappDockerStart/Stop/Restart/Killcalls remain outside the reconciler — install, uninstall, redeploy and backup/restore, mount recovery, and the two-hourly sweep that stops containers FluxOS does not own — and those paths are not what this changes.First of three in the stack:
development ← this ← #1781 ← #1782. #1779 below has merged (63327e97f), so this targetsdevelopmentdirectly. #1781 and #1782 are based on this branch and merge in stack order.Why the exit code cannot decide this
The exit code was already persisted —
recordExitwriteslastExitCodebesidelastDiedAt— and nothing read it. It is now an input, but only in the direction it is sound.For a container that went down on its own: non-zero, or
OOMKilled, proves a fault, and zero proves nothing. An image whose entrypoint is a wrapper script ending inexit 0reports a clean stop for a segfault. Palworld's official image does exactly this:Its runtime state on a live node records
lastExitCode: 0for a death confirmed as a SIGSEGV. No init we wrap around the container recovers this: the loss happens inside the image's own script, a level below anything we can inject, so--init/tini would faithfully propagate the zero that was already laundered.Pacing on the code alone would therefore leave that class of container restarting without limit. That is what the ceiling is for, and why it consults nothing but the rate.
The qualifier at the top of this section is load-bearing, and the case it excludes is not yet handled. FluxOS stops containers deliberately — an election moving a
g:primary, a backup taking the app down — and those go out as SIGTERM, so an image that does not trap it exits 143. This lineage reads that as a fault and books a rung, which paces a healthy app for the node's own tidy shutdown. It is an improvement on what it replaces, which paced every restart whatever the cause, but it is not the whole rule: the exit code is an unreliable narrator in both directions, and only one of them is addressed here. Teaching the ladder to recognise the node's own drains needs desired state to decide it rather than the exit code, and that is a follow-up PR rather than a widening of this one.The ceiling, and what it reaches
Five automatic restarts inside five minutes is itself evidence of a fault, whatever Docker reported. It disposes into the same ladder rather than into a failed state a human has to clear — nobody is watching a customer's node at 3am.
It is judged before the restart is recorded, against five entries spanning five gaps, so its reach is
window / count. Measured against the real module:A container exiting in milliseconds trips it in about a second. Anything slower than one restart a minute is deliberately left alone.
The ladder is measured against running, not against waiting
restartWaitMsclears the ladder once a component has genuinely run forSTABLE_RUN_MS, and measures that from its last rung. A component holding rungs therefore earns one for every restart — otherwise the gap being measured is the wait the component just served, and any rung longer than ten minutes clears the ladder every time it fires.The exit code decides what puts a component on the ladder; it does not decide whether it stays.
restartWaitMstakes nocrashedargument for that reason — whether to wait depends only on whether rungs stand.Measured against the real module, driving the reconciler's own loop order for two hours with a container dying two seconds after every start:
An operator restart is recognised by the path it takes, not by its exit code:
appStartandappRestartclear the operator lock, andsetOperatorStopped(id, false)drops the rungs with it. Recognising it by exit code instead hands the same exemption to a laundered segfault, which also exits 0.Operator commands are desired state
appStartprobed docker for "is this container running" and used the answer as a proxy for "should this node be running it". Those diverge both ways: a masterSlave primary whose container is stopped was refused a start though the election names it the writer, and a standby whose container happened to be up was started. It classifiedg:withcontainerData.includes('g:'), whichmountParser's own comment calls wrong —'/data|g:/db'matches while the flag sits in an invalid non-primary position.The election owns that decision and the reconciler consults it on every pass, so the handler clears the lock and lets it decide. A component the election is holding is reported as pending, naming the election, rather than as skipped.
appRestartraises a durable restart generation; the reconciler bounces a running container once the generation passes the one it last actuated, and a start of a stopped container satisfies the same request.appKillrecords a durable force mode which the reconciler honours withappDockerKillwhere it stops the container.Every one of the four reports what happened rather than what was asked for:
Application X stopped, orwill be stopped: docker is not reachable.A command that addresses nothing is refused rather than reported done. The components are resolved before anything is written, and an app that is installed but whose parts this node cannot work out — a spec that will not decrypt, falling back to a container listing that is empty or that failed outright — yields none. Acting on that empty list wrote no intent and settled vacuously against nothing, so all four answered
Application X stoppedhaving touched nothing and recorded nothing for a later pass to pick up. They now say the app was not changed and why, worded for the operator: HomeUI shows the message verbatim.Monitoring follows the container
startAppMonitoringresetsstatsStore, andappStartcalled it on every request — so asking an app to start discarded the series the charts read.appStopstopped the sampler before knowing whether the stop had happened, so an unreachable docker left a running container unmonitored.The reconciler owns both ends. It stops the sampler on each of the four paths where it stops a container, and
ensureAppMonitoringputs one back for a running container that has none without resetting a live one.The kill endpoint
appKillwas implemented, exported and covered by unit tests calling it directly, and no route registered it — on this lineage or on v9. Every route is a string literal, there is no dispatch table, andexecuteAppGlobalCommandbuilds a URL against the same route table, so a peer could not reach it either.routeWiring.test.jsreads the real route table, so its absence now fails a test.appKillandappRemoveboth ask forappownerorfluxteamrather thanappownerabove, which takes the node operator out of both. Hosting an app is not owning it: an operator who can remove one can script that against every install and keep a customer's app off their node indefinitely, which the customer experiences as an app that will not stay deployed and cannot diagnose.There is no operator need on the other side of that. An app cannot exceed what was bought —
Memory,MemorySwap,NanoCpusandStorageOptare set on the container from the spec, so an app inside its allocation is spending cycles the operator sold, and an app outside it is a containment defect to be fixed in the limits rather than papered over on one node. What an operator does keep is the control that is honest about its own cost: stop FluxOS, or drop the node, both of which give up the payment along with the obligation.That reasoning does not stop at these two, and the rest is already decided.
appStart,appStopandappRestartstill ask forappownerabove, and a follow-up PR takes the remaining app control away from the node operator entirely, with the frontend controls narrowed alongside it — the same way this one lands with #150. It is separate only so that each gate ships with the control that exposes it.appownerorfluxteamadmits exactly{owner, fluxTeam, fluxSupport}, which is the same set the vetted-app branch below it admitted. That branch existed to keep the operator away from vetted apps, so with the operator out of the gate above it can no longer refuse anyone who reaches it. It goes, and with it two database reads and a vetted lookup on every uninstall.The same reasoning closes the other end.
installapplocallyandtestappinstallasked foradminandfluxteamon their by-name branch, which resolves an app from the marketplace, the global registry or a permanent message — so an operator reaching it chose which customer's app ran on hardware they control, and for ag:/r:app the new instance syncs that customer's data down to it. Both move tofluxteam. Leaving install open while closing remove would have left the operator able to place an app on their node and unable to take it off.The temporary-message branch above them is deliberately untouched and stays open to any logged-in user: that is how an app is tested before it is registered, it is addressed by hash rather than by name, and it expires on its own via
temporaryAppAllowance.This is user-visible on a reachable route, so the frontend control that offered it is narrowed to fluxteam in RunOnFlux/fluxos-frontend#150. The two land together.
local.vuerenders Uninstall toadmin, so this merging alone leaves a node operator pressing a button that now refuses them. The same PR fixes the panel refresh behind finding 5:AppControl.vuereadresponse.data.statusonce and refetched once, so an accepted command showed a green toast beside a status that had not moved yet. It now refreshes on a spreading schedule (0/2/5/10/20s).Config fallbacks
RESTART_BURST_WINDOW_MSfalls back to the value config ships. Everyconfig.fluxapps.x ?? literalin the service layer is a second copy of a shipped value — forty keys, none of them a default the module owns — andtests/unit/configFallbacks.test.jscompares each againstZelBack/config/default.js. An expression it cannot evaluate fails rather than being skipped.config.get()removes the second copy entirely and throws by name on a missing key. That is 40 call sites here and 108 on the v9 lineage, so it is its own change.The boot-lock harness commits
Three commits touch only
test-infra/runner/framework/boot-lock.jsand its call site, and are named here because they are otherwise unexplained in a reconciler PR: the boot lock says how long it queued and how long it held, a boot's duration is unreadable without the shape of the fleet, and the boot lock admits a bounded number of boots, not one. They are named by subject rather than by hash: this branch is rebased whenever the one below it moves, and every hash in it changes when that happens.Fleet boot is serialised host-wide by a semaphore, and nothing recorded what that cost. The first two make acquire report how long a build queued and how many were ahead of it, release report how long it held, and add the shape of the fleet being built — without which a hold duration mixes one-node and ten-node builds indistinguishably.
The third acts on what the first two measured: the gate was 89% boot-lock-held wall clock, 4,439s of 4,978s across 130 boots, so its duration was very nearly the serial sum of the boots it serialised. The lock now admits
E2E_BOOT_LOCK_WIDTHboots, default 2. Arrival order is untouched — the queue still drains in the order it formed, which keeps starvation structural rather than statistical.Measured across two gates of the same 82 suites at
MAXN=6, the same 130 boots and a near-identical fleet-size distribution:Boots contend and each costs more, but they overlap, and the overlap wins by 786 seconds.
E2E_BOOT_LOCK_WIDTH=1restores the old behaviour without reverting the commit.All three emit TAP comments (
# boot-lock ...), so none can alter a result:run-all.shtallies withgrep -c "^ok ", anchored to line start.An address change no longer stops at the first composed app
adjustExternalIPrestarted the apps that survive an address change by callingappController.appDockerRestart(app.name). That resolves one container by name and a composed app has none under its bare app name — its containers are<component>_<app>— so the first one threw aTypeErrorthat nothing caught, taking with it every app behind it in the loop, thefluxipchangedbroadcast, the confirmation transaction and the geolocation update. One log line was the trace.The surviving apps are now handed to
appReconciler.requestRestartOfas durable per-component restart requests, through a seam (setOnAddressChanged, wired inserviceManager) rather than a require —appUninstallerrequiresfluxNetworkHelperandappReconcilerrequiresappUninstaller, so reaching upward from the network layer closes a cycle, and twenty-odd app-layer modules import it.componentIdsOfis shared withenqueueAllso the rule for what components an app has, including the docker fallback for specs that will not decrypt, has one home.An address change no longer uninstalls the node's own apps
Before restarting an app,
adjustExternalIPasks whether it is already running at the address being moved to, and force-removes it if so — on the sound principle that one instance per IP is what the host port mapping allows, so an instance already there means the ports are gone. It treated any answer as another node's. The node's own registration is one of those answers: it stores its own running-app row locally, at the address benchmark reports, so the row found at that address is usually itself. The app was removed — forced, and broadcast to the network — for being exactly where it belonged.The address is still compared at IP granularity — that is the question being asked, and a UPnP sibling on another port holds the ports just as surely. Own-ness is the full socket address, which is what separates the two, using the same
socketAddressesMatchtest the collision check in that file already uses.A stop is honoured while a data volume is unavailable
The reconciler defers every actuation for a component whose data volume will not mount — a start there writes to the host filesystem instead of the volume. A stop is the exception, because it takes nothing from the app dir, and leaving a container running over a missing volume is the state the mount-safety hold exists to end.
That exception covers the operator's stop as well as the controller's. The operator's outranks the controller's everywhere else in a pass, and an unmountable volume is the state support reaches for a stop in, so a stop that waits for the mount is a stop that never arrives when it is wanted. The force flag is carried through it, so an
appkillthere is a kill rather than a graceful stop.An operator command addresses what the app is made of
The components a command acts on come from
appReconciler.componentIdsOf, which decrypts the spec to enumerate them and falls back to the running containers when it cannot.Reading
composeoff a stored spec answers neither case reliably. An enterprise app keeps its component names inside an encrypted blob, so its stored compose is empty — a whole-app command derived from it addresses nothing, and every one of zero components reaches its desired state, so it reports success. A component name taken verbatim addresses nothing either when it is not one of the app's, and still writes a durable operator lock under it that nothing clears.componentIdsOfreturns one spelling whichever source it used. Docker holds the namespaced name (flux<component>_<app>) where a decrypted compose gives the bare one, so the list carried either depending on whether the spec read. Every consumer until now canonicalised on ingest and so could not tell them apart; the membership check above compares the list against a name an operator typed, and refused every component-level command against an app whose spec would not decrypt. Stripped at the source, so one spelling reaches all three consumers.containersReachedStoppeddistinguishes a container that is absent from one that is stopped.dockerActualreportsexistsseparately fromrunning, and reading the pair as one answered "stopped" for a container that was never there — the answerappStartalready gave correctly.The restart generation is raised by the database
requestRestartissues an atomic increment rather than reading the current generation and writing one more than it.getStateanswers null both for "no record yet" and "the read failed", and treating the second as zero writes a generation below the one already actuated — which the reconciler reads as nothing pending, so the restart is reported and never happens. An increment has no such answer to misread, creates the field at 1 when there is no record, and counts two concurrent requests as two rather than losing one.The upsert retry that protects a first write from a concurrent one is shared with
setFieldsrather than copied, since without it the loser of that race is dropped silently.Changes
appController.jsapp:operatorIntentpublished from inside the reconciler's slot;appDockerRestartdeletedappReconciler.jsrequestRestartOf, andcomponentIdsOfshared withenqueueAllfluxNetworkHelper.jssetOnAddressChanged; the surviving apps are handed over once; the duplicate check excludes the node's own registrationserviceManager.jsappReconciler.requestRestartOfappUninstaller.jsappownerorfluxteamon the uninstall route; the vetted branch it made redundant removedappsRuntimeState.jsrestartGeneration/operatorStopForce; a component holding rungs earns one per restart; twin-merge carries all threeappInspector.jsensureAppMonitoring; a cleared interval no longer looks liveroutes.js/apps/appkill/:appname?verificationHelper*.jsappownerorfluxteamZelBack/config/default.jsrestartBurstCount: 5,restartBurstWindowMs: 300000globalState.stoppingContainersstays. Its remaining consumers are the uninstaller, redeploy, backup/restore and mount recovery, none of which this touches. The network-change restart is no longer one of them.Testing
Unit suite 5,635 passing, 0 failing (18 pending) on the current head. Both fixtures up — without mongo the run skips 682 tests and still exits 0, so a bare pass is not a result.
Every guarantee added here was mutated to prove the test would notice: the force mode ignored, every stop forced, the restart request never actuated, the bounce never recorded, a start not satisfying a pending request, a pending outcome reported as success, the handler driving docker again, the intent event published outside the slot, the route removed, the kill privilege widened, the uninstall privilege widened back to the one that admits the node operator, the component identifier left namespaced by one of its two sources, an empty component list reporting success, the ladder cleared by its own wait, and a fallback diverging from config.
Twenty-nine mutations run, twenty-eight caught. The one that was not is worth stating: it reverted a line measuring stability from the last restart in either array rather than from the last rung. Nothing failed, because a component holding rungs earns one for every restart — so the two are always the same value and the line could not change an outcome. It was removed rather than shipped.
One test was removed for being unable to fail, and one rewritten: it asserted that an operator restart is not paced but expressed that through the exit code, which is the exemption a laundered segfault also receives.
On a fleet
The unit tests stub docker, so they prove a handler writes the right intent and nothing further. What a customer sees is a container's run state and a response string, and until now nothing drove either. Six tests were added to four existing suites, and one new suite covers the address-change path:
32-…-restart-clears-lock32-…-operator-stop-durableforcedflag — "the container stopped" passes against a graceful stop too. And the node operator is refused a kill while still allowed a stop, driven from the node's own identity, both halves.52-masterslave-…54-…-burst-ceiling35-…-masterslave-election56-address-change-restarts-appsfluxipchangedbroadcast — which is what distinguishes a handler that ran to the end from one that stopped at the first app.Suite 54 previously ran against production's ladder, so the only rung reachable inside a sane timeout was the first — everything past it, including the rung this PR's defect lived at, needed a twenty-one minute wait and so was never driven. Its ladder and stable-run window are now compressed together, preserving the relationship that matters (a rung outlasting the window that clears the ladder: 30s against 20s here, 15m against 10m in production) and asserting that relationship holds, because compressing one without the other leaves the suite green having stopped testing what it exists for.
The backoff actuation now carries the rung.
waitMsis what remains of a rung and the worker re-enqueues during a wait, so one rung reports several times with a falling number and two backoffs cannot be compared — which is precisely the question a ladder that resets itself turns on. It reaches the log line too, so support can read how far a component has escalated rather than only how long is left.Suite 56 changes a node's address for real rather than simulating one: the new address goes onto its interface and the old one comes off, and benchmark's public-IP probe reports the move while the deterministic list still says where it was. Its peers then fail to reach it at the old address because it genuinely is not there, which is what makes it ask benchmark whether it moved — a node that is still reachable never asks. The list being untouched is what lets those same peers still recognise it as the sender of the broadcast that follows.
Simulating either half breaks the other, which is why it is done properly: blocking the node's packets makes a peer's answer arrive after the asking node has given up, so it reads as "I could not ask" rather than "you are unreachable" and never reaches benchmark; hiding the node from peers' lists makes them answer promptly and then reject its broadcast as coming from a stranger.
Two harness assumptions had to go with it, both of them things an address change necessarily violates. The runner reached a node at a fixed address, so a node that moved went blind to its own client —
nodeClient.followTore-points it and reconnects. And the daemon stub identified a node by the address its requests arrive from, so a node that moved lost its identity, read its own address as the not-found fallback, and skipped its availability check on every cycle; it is now recognised at its reported address too.Run against images rebuilt from the tree under test and verified from inside the image, not from build output. Suite 56 on the current head: 2 tests, 0 failures, twice consecutively at 3m35s and 2m32s. Suite 35: 4 tests, 0 failures.