Skip to content

Reconciler: the only actuator for every operator command, and a ladder that paces faults not operators - #1780

Merged
Cabecinha84 merged 40 commits into
developmentfrom
fix/restart-pacing-crash-only
Aug 29, 2026
Merged

Reconciler: the only actuator for every operator command, and a ladder that paces faults not operators#1780
Cabecinha84 merged 40 commits into
developmentfrom
fix/restart-pacing-crash-only

Conversation

@MorningLightMountain713

@MorningLightMountain713 MorningLightMountain713 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

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, appRestart and appKill record the operator's intent and let the single actuator converge on it; no route in appController drives a container. Seventeen appDockerStart/Stop/Restart/Kill calls 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 targets development directly. #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 — recordExit writes lastExitCode beside lastDiedAt — 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 in exit 0 reports a clean stop for a segfault. Palworld's official image does exactly this:

"${STARTCOMMAND[@]}"     # the game — segfaults here, $? = 139
LogAction "Ending Server"
exit 0                   # unconditional; the game's status is discarded

Its runtime state on a live node records lastExitCode: 0 for 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:

every  59s   paced
every  60s   paced
every  61s   never paced, however long it continues

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

restartWaitMs clears the ladder once a component has genuinely run for STABLE_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. restartWaitMs takes no crashed argument 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:

container starts in 2h rungs reached
reports exit 0 for a segfault 12 30s, 5m, 15m, 30m cap
exits non-zero 7 30s, 5m, 15m, 30m cap
healthy, runs for an hour 2 never paced
operator presses restart never paced, ladder cleared

An operator restart is recognised by the path it takes, not by its exit code: appStart and appRestart clear the operator lock, and setOperatorStopped(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

appStart probed 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 classified g: with containerData.includes('g:'), which mountParser'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.

appRestart raises 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. appKill records a durable force mode which the reconciler honours with appDockerKill where it stops the container.

Every one of the four reports what happened rather than what was asked for: Application X stopped, or will 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 stopped having 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

startAppMonitoring resets statsStore, and appStart called it on every request — so asking an app to start discarded the series the charts read. appStop stopped 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 ensureAppMonitoring puts one back for a running container that has none without resetting a live one.

The kill endpoint

appKill was 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, and executeAppGlobalCommand builds a URL against the same route table, so a peer could not reach it either. routeWiring.test.js reads the real route table, so its absence now fails a test.

appKill and appRemove both ask for appownerorfluxteam rather than appownerabove, 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, NanoCpus and StorageOpt are 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, appStop and appRestart still ask for appownerabove, 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.

appownerorfluxteam admits 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. installapplocally and testappinstall asked for adminandfluxteam on 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 a g:/r: app the new instance syncs that customer's data down to it. Both move to fluxteam. 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.vue renders Uninstall to admin, 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.vue read response.data.status once 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_MS falls back to the value config ships. Every config.fluxapps.x ?? literal in the service layer is a second copy of a shipped value — forty keys, none of them a default the module owns — and tests/unit/configFallbacks.test.js compares each against ZelBack/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.js and 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_WIDTH boots, 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:

width 1 width 2
gate wall clock 4,978s 4,192s
queue wait, p50 87.8s 24.1s
queue wait, total 11,697s 3,747s
lock held, p50 32.6s 40.2s
lock held, total 4,439s 5,746s

Boots contend and each costs more, but they overlap, and the overlap wins by 786 seconds. E2E_BOOT_LOCK_WIDTH=1 restores the old behaviour without reverting the commit.

All three emit TAP comments (# boot-lock ...), so none can alter a result: run-all.sh tallies with grep -c "^ok ", anchored to line start.

An address change no longer stops at the first composed app

adjustExternalIP restarted the apps that survive an address change by calling appController.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 a TypeError that nothing caught, taking with it every app behind it in the loop, the fluxipchanged broadcast, the confirmation transaction and the geolocation update. One log line was the trace.

The surviving apps are now handed to appReconciler.requestRestartOf as durable per-component restart requests, through a seam (setOnAddressChanged, wired in serviceManager) rather than 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. componentIdsOf is shared with enqueueAll so 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, adjustExternalIP asks 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 socketAddressesMatch test 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 appkill there 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 compose off 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.

componentIdsOf returns 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.

containersReachedStopped distinguishes a container that is absent from one that is stopped. dockerActual reports exists separately from running, and reading the pair as one answered "stopped" for a container that was never there — the answer appStart already gave correctly.

The restart generation is raised by the database

requestRestart issues an atomic increment rather than reading the current generation and writing one more than it. getState answers 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 setFields rather than copied, since without it the loser of that race is dropped silently.

Changes

file change
appController.js all four operator handlers record intent and probe the outcome; app:operatorIntent published from inside the reconciler's slot; appDockerRestart deleted
appReconciler.js honours the restart generation and the force mode; owns monitoring on its stop and steady-state paths; requestRestartOf, and componentIdsOf shared with enqueueAll
fluxNetworkHelper.js setOnAddressChanged; the surviving apps are handed over once; the duplicate check excludes the node's own registration
serviceManager.js wires that seam to appReconciler.requestRestartOf
appUninstaller.js appownerorfluxteam on the uninstall route; the vetted branch it made redundant removed
appsRuntimeState.js restartGeneration / operatorStopForce; a component holding rungs earns one per restart; twin-merge carries all three
appInspector.js ensureAppMonitoring; a cleared interval no longer looks live
routes.js /apps/appkill/:appname?
verificationHelper*.js appownerorfluxteam
ZelBack/config/default.js restartBurstCount: 5, restartBurstWindowMs: 300000

globalState.stoppingContainers stays. 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:

suite test
32-…-restart-clears-lock apprestart bounces a container that is already running, and bounces it once. The suite's existing test restarts one that was down, which is a start — either implementation satisfies it, and the restart generation only decides anything when the container is up.
32-…-operator-stop-durable appkill stops it, asserted on the forced flag — "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-… appstart on an instance the election is not electing reports the election rather than claiming a start. Runs first in that suite, 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.
54-…-burst-ceiling the ladder is not cleared by the wait it just served, reaching the rung that outlasts a stable run.
35-…-masterslave-election a backup's stop costs the elected primary no rung: the hold is released and it converges back to running inside a cycle. The same assertion fails on the branch below, where every restart earns a rung and the component is on the five-minute one by then — so it discriminates against the behaviour this PR changes, proven by running it on both.
56-address-change-restarts-apps new. Three nodes, one COMPOSED app. Both components restart on their own identifiers after the node's address moves, 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.

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. waitMs is 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.followTo re-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.

@Cabecinha84 Cabecinha84 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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. #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. #2 — the pos >= 0 guard. One line, take it.
  3. #4 and #5 — description and commit-message accuracy. Cheap, and #4 matters for whoever does the merge.
  4. #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.

@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (fix/syncthing-first-run-gate@dc491ae). Learn more about missing BASE report.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@MorningLightMountain713
MorningLightMountain713 force-pushed the fix/restart-pacing-crash-only branch from 6c98bac to 8b4b8b3 Compare August 10, 2026 10:47
@MorningLightMountain713
MorningLightMountain713 force-pushed the fix/restart-pacing-crash-only branch from 8b4b8b3 to 3581b93 Compare August 11, 2026 15:12
@MorningLightMountain713

Copy link
Copy Markdown
Collaborator Author

Thanks — the substantive one was right and is fixed, along with three of the others. Branch is now 3581b93ad.

1. The 12-second cliff — fixed by widening the window to 300 seconds (6704fb900). Your arithmetic was correct: the ceiling is judged before the append against five entries spanning five gaps, so its reach is window / count, which at 5-in-60s was 12.0 seconds and nothing slower. I reproduced it against the real module with a control case that must trip, so a false "never paced" would have shown up.

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: setOperatorStopped(id, false) clears both arrays before the docker operation, so an operator restarting an app while debugging cannot walk into it, and a wrong trip costs a wait that the next genuine crash or operator start clears.

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 window / count, and the shipped decision in seconds. The latter fails against the old window.

2. pos >= 0 — taken (3581b93ad). Proven with a probe against the real module before and after: with the directory intact a full queue correctly refuses the waiter; with it removed the old code admitted one, and now both fail closed.

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 restartBurstWindowMs, where someone tuning it would see it. The 300-second window also takes that margin from 2.4x to 12x.

4. The stacking claim — corrected, and it was worse than you scoped. Six of the ten files are shared, not one: ZelBack/config/default.js (#1774, #1775, #1777, #1778), appReconciler.js (#1774, #1777, #1779), tests/unit/appReconciler.test.js (#1774, #1777), test-env.js (#1774, #1779), boot-lock.js (#1774) and tests/unit/globalconfig/default.js (#1774). You missed #1775 on the config file, #1774 on the reconciler test, and the globalconfig file entirely. The body now says it merges in stack order.

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 f6c6bc399.

@Cabecinha84 Cabecinha84 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@Cabecinha84
Cabecinha84 force-pushed the fix/restart-pacing-crash-only branch from afc31cc to 3ff6b5d Compare August 13, 2026 09:50
@MorningLightMountain713
MorningLightMountain713 force-pushed the fix/restart-pacing-crash-only branch from 3ff6b5d to 77dce7b Compare August 15, 2026 06:07
@MorningLightMountain713
MorningLightMountain713 force-pushed the fix/restart-pacing-crash-only branch from 77dce7b to 1ede14b Compare August 21, 2026 06:45
@MorningLightMountain713 MorningLightMountain713 changed the title Reconciler: a clean exit is not a crash, and a burst window catches the ones that lie Reconciler: the only actuator for every operator command, and a ladder that paces faults not operators Aug 21, 2026
@Cabecinha84
Cabecinha84 force-pushed the fix/restart-pacing-crash-only branch from fd9a188 to 9eac210 Compare August 24, 2026 08:44
MorningLightMountain713 and others added 27 commits August 29, 2026 10:53
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>
@Cabecinha84
Cabecinha84 force-pushed the fix/restart-pacing-crash-only branch from fc61964 to c24d099 Compare August 29, 2026 09:53
@Cabecinha84
Cabecinha84 merged commit 0760300 into development Aug 29, 2026
2 checks passed
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.

2 participants