Restore: acquire before destroying, and stop one dead volume from silencing a node - #1779
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## refactor/monitoring-single-store #1779 +/- ##
====================================================================
+ Coverage 61.45% 64.65% +3.20%
====================================================================
Files 158 174 +16
Lines 30902 32526 +1624
====================================================================
+ Hits 18990 21030 +2040
+ Misses 11912 11496 -416 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
fda76c6 to
2859909
Compare
Cabecinha84
left a comment
There was a problem hiding this comment.
I pulled the PR, diffed it against its actual base (refactor/monitoring-single-store @ d1d0c2e, which is the
merge-base — so what I reviewed is exactly this PR's diff), read all ~880 lines of product change plus the
harness/test additions, verified every cross-module API it calls, and ran the affected unit suites in a throwaway
worktree.
Verdict: safe to ACK
Nothing I found is data-loss-unsafe, and the central safety argument holds under scrutiny. I have four comments I'd
post alongside the ACK — two of them I'd genuinely like addressed, none of them blocking.
State of the PR right now: no human reviews, one codecov bot comment. CI green (build, codecov patch 80.76% / project,
GitGuardian). 33 commits, last pushed 2026-08-07.
Tests I ran myself (worktree on 2859909, since removed — your tree is untouched):
- advancedWorkflows.test.js — 115 passing
- syncthingMonitor + syncthingFolderStateMachine + IOUtils + backupRestoreService + appReconciler — 203 passing
The harness claims (10 suites green on b5a710f, 82/82 at the top of the stack) I could not verify — they need the
docker harness.
What I verified as sound
The load-bearing claims check out against the code, not just the prose:
- Acquire-before-destroy ordering is real. Validate → FDM guard → claim → pause folders → stop and verify containers →
download → verify byte count → inspectTarGz the whole archive → free-space check → only then clear + extract. Every
throw before swapInFlight = target leaves appdata untouched. - The double-claim window is genuinely closed. globalState.restoreInProgress.includes() is re-read with no await
between the check and the push. - The cache writes land where they're read. appCaches.receiveOnlySyncthingAppsCache is literally
globalState.receiveOnlySyncthingAppsCache (same Map object), so the settle/demote writes are visible to the folder
state machine. This was the obvious way for the fix to be silently inert, and it isn't. - apprestart doesn't fan out. executeAppGlobalCommand(..., undefined, true) builds /apps/apprestart/ with no
:global segment → each peer restarts locally only. And bypassMyIp is set. - The mount-safety dedup is not a weakening. The state machine's removed block fired on syncthingAppsFirstRun ||
erroredFolderIds.has(appId) — identical to the monitor's new appsToVerify trigger set — and the monitor now applies
the deeper verifySendReceiveFolderSafety where the folder is currently sendreceive. Strictly stronger. The dropped
mountSafetyBlocked/blockedReason/blockedAt cache fields are read nowhere in the tree. - getVolumeInfo no-match returns false, so if (!volume) correctly guards the volume[0].mount deref.
Number.isFinite(false) is false, so an unknown remote size doesn't trip the short-download check.
downloadFileFromUrl's filename (backup_${component.toLowerCase()}.tar.gz) matches the computed archivePath. - inspectTarGz shell use is safe — execFile argv, path arrives as "$1", never as syntax. The sized === 0 guard against
a different tar column layout is a good catch. - No new circular top-level requires from pulling syncthingFolderStateMachine into advancedWorkflows.
- The Dockerfile signature chain is correct: pinned fingerprint asserted via VALIDSIG on --status-fd, then sha256 read
out of the signed file. The deliberate no-pipefail / no-exit-code choice is right and documented.
Comments I'd post
-
inspectTarGz and getDirectorySizeBytes inherit the 15-minute default child-process timeout. serviceHelper.js:24
sets MAX_CHILD_PROCESS_TIME = 15 * 60 * 1000 and runCommand applies it unless overridden — neither call overrides it.
tar -tzvf inflates the entire archive; on the 35 GB Palworld-class app this PR exists for, that can exceed 15 minutes
on node-grade disks. On timeout you get result.error → "archive is unreadable" → refusal, after the app is stopped and
the archive downloaded. Fail-safe direction, but the largest apps — the ones this was written for — could become
un-restorable with a message that misdescribes why. I'd pass an explicit generous timeout to both. -
The backup gate can't tell "no such folder" from "syncthing didn't answer". getFolderSyncCompletion returns null
for both a 404 and a failed/unreachable API call. The refusal then asserts "${componentName}: no syncthing folder -
this instance has never synced". Combined with force being API-only by design (which I agree with), a transient
syncthing hiccup makes backup impossible from the UI, and the message tells the operator something false about their
data. Worth separating the two, or at least retrying before refusing. -
Cosmetic but it's the message at the moment of blocking: for globalBytes === 0, syncPercentage defaults to 100
while isSynced is false, so the refusal reads "100.00% synced (0/0 bytes)". -
The PR description says "Both call sites are gone" for the restart-required latch — there's a third.
syncthingService.adjustSyncthing() (~line 2358) still does getConfigRestartRequired() → systemRestart(). Same
reasoning applies to it. Startup-only and once, so the harm is far lower than the per-pass loop you removed — but
either drop it too or correct the claim.
Asymmetry worth a question, not a change: backup deliberately refuses to restart g: components ("starting it here
would put a second writer on the shared volume"), while restore ends with a plain appDockerStart(appname), which
starts every component including elected ones. That's pre-existing, and the FDM guard covers the normal case — but the
guard is explicitly skipped when FDM is unreachable (fdmOk === false), which is exactly the path where restore then
starts a g: container here anyway. The PR just established the opposite rule on the backup side.
One operational note: a syncMode: 'none' component held by setControllerDesired(id, 'stopped', 'restore did not
complete') has no automatic release — the only caller of clearControllerDesired is an operator appstop. So the
recovery is stop-then-start. That's the intended trade (the comment argues it well), but the reason only reaches the
log and a stream that has already ended.
Nits: two lint-visible blank-line issues introduced in syncthingMonitor.js (double blank ~611, padded block before }
catch ~824 — CI doesn't run lint, so harmless); getVolumeInfo's new JSDoc says "null when no matching mount is found"
but that path returns false; sudo bash -c is a new binary in the sudo surface (safe as written, just new); and keeping
local/upload archives after restore leaves them inside the syncthing folder root (${appsFolder}${appId}, not
appdata), so they replicate to every peer — deliberate and arguably correct, but a storage-footprint change worth
stating.
Stack caveat: this is fifth in development ← #1774 ← #1775 ← #1778 ← #1777 ← this. An ACK here is an ACK of this diff;
it isn't mergeable to development until the four below it land.
2859909 to
1d82114
Compare
1d82114 to
353d817
Compare
353d817 to
2d55291
Compare
|
Thanks for the ACK and the four notes — all four are addressed. Branch is now 1. The 15-minute child-process timeout — fixed, but not by raising it ( Both now stream. Two things fell out of it. Nothing is held whole any more, so the 2. "No such folder" versus "syncthing didn't answer" — fixed ( The refusal stands in both cases — an archive of a copy nobody verified is the one that looks fine now and loses data when it is restored months later, so "cannot tell" must not open the gate. What changes is what the operator is told. I deliberately did not add a retry: it would hide the condition rather than report it, and the honest message plus the API-side 3. "100.00% synced (0/0 bytes)" — fixed ( 4. The third call site — removed ( One thing the gate caught in my own change. Suite 93 modelled "this instance has never synced" with an unreadable folder status and asserted that wording — so it was pinning the very conflation note 2 removes, and went red the moment the two were told apart. It now covers both shapes as they are ( The asymmetry you raised as a question — restore starting Verified at the top of the stack: 85 of 85 suites green at |
2d55291 to
4b02282
Compare
Cabecinha84
left a comment
There was a problem hiding this comment.
PR #1779 — review of the current state (head 4b02282)
Verdict: ACK-able, but I'd ask for two one-line fixes first. Both are in failure paths, both are the same class of bug
this PR exists to close, and both are cheap. Everything else is nits or follow-ups.
Where it stands vs. your last review
All four of your notes are genuinely fixed — I checked the code, not the reply:
┌─────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────┐
│ your note │ state │
├─────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────┤
│ │ Fixed better than asked. New serviceHelper.runStreamingCommand │
│ 15-min child-process timeout on │ streams output and takes an idleTimeout (5 min of silence, not of │
│ inspectTarGz/getDirectorySizeBytes │ total runtime). sudo bash -c is gone entirely — sudo tar/sudo du as │
│ │ argv, no shell, no maxBuffer ceiling. │
├─────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────┤
│ │ Fixed. probeFolderSyncCompletion pulls the HTTP status out of the │
│ "no such folder" vs "syncthing didn't │ error and only treats a plain 404 as absent; everything else is │
│ answer" │ unknown. Distinct operator messages. Refusal stands in both cases, │
│ │ deliberately. │
├─────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────┤
│ 100.00% synced (0/0 bytes) │ Fixed — empty index now gets its own wording. │
├─────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────┤
│ third restart-required call site in │ Removed. And his correction is right, I verified it: │
│ adjustSyncthing │ syncthingService.js:2535 calls adjustSyncthing() from the sentinel │
│ │ every 8 minutes, not once at startup. Your note undersold the harm. │
└─────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────┘
The lint nits you raised are also gone — npx eslint now exits 0 on all five changed product files.
CI
The red check is not the code. Run 31510268949 failed at "Push hashes to fluxhashes" (error: failed to push some refs
to .../fluxhashes.git) — a repo-permission step that runs before npm install and the test suite. Tests never ran. The
previous commit 2d55291 was green, and the only delta to head is 4b02282, which touches one harness file
(93-backup-sync-gate.js) and no product code.
I ran the suite locally against head: 5175 passing, 23 failing — all 23 in dockerService.test.js, which needs a live
docker daemon and the runonflux/website container CI provides and I don't have. Nothing from this PR fails. Just
re-run the workflow.
Two things I'd ask him to change
- setSyncthingFolderPaused reads any 4xx as "folder absent" — advancedWorkflows.js:~1938
if (response.data?.code === 'ERR_BAD_REQUEST') {
// 4xx: syncthing has no such folder. Nothing is replicating it...
return 'absent';
}
I checked axios 1.13.6: ERR_BAD_REQUEST is the code for 400, 401, 403, 404 and 409 alike (ERR_BAD_RESPONSE starts at
5xx). So a 403 — stale cached API key (the axios instance caches it for 15 minutes), or CSRF — reads as "nothing is
replicating this, proceed". The folder is never added to pausedFolderIds, no refusal is raised, and the restore goes
on to removeDirectory(appDataPath, true) with a live sendreceive folder over it. Those deletions then go out to every
healthy peer. That is the incident mechanism.
What makes this worth blocking on: he already built the correct discriminator two commits later, for your note 2 —
probeFolderSyncCompletion reads the actual HTTP status and only lets a bare 404 mean "absent". This call site just
didn't get it. Same fix, and the message is already available on response.data.message.
(Backup is shielded by the sync gate running first — a 403 there yields unknown → refusal. But under force it falls
through to the same hole.)
- The restore failure path discards changeSyncthingFolderType's return value — advancedWorkflows.js:~2848
In the catch, when a component's appdata was cleared but not fully unpacked:
await changeSyncthingFolderType(swapInFlight.folderId, 'receiveonly'); // returns boolean, ignored
globalState.receiveOnlySyncthingAppsCache.set(...);
appReconciler.setControllerDesired(swapInFlight.folderId, 'stopped', 'restore did not complete');
// ...then every paused folder is resumed
changeSyncthingFolderType pre-reads getConfigFolders() and returns false if that read fails or the folder isn't
matched by path. That's the exact shape the monitor deliberately refuses to use, in its own words at
syncthingMonitor.js:~577: "a safety action must not be conditioned on a fallible read whose failure silently reads as
'nothing to protect' (that exact silent no-op once cost a gate run)". The monitor patches directly by id and checks
the status. The restore's failure path uses the pre-reading variant and checks nothing.
If it returns false, two protections collapse together:
- The folder stays sendreceive, and the loop right below resumes it → the half-restored appdata is broadcast to the
peers. - The container hold dies with it. I traced this: with the folder still sendreceive, manageFolderSyncState
short-circuits on folderAlreadySyncing (syncthingFolderStateMachine.js:1141) straight into ensureContainerRunning,
which for an r: component calls setControllerDesired(appId, 'running', ...) (:1112) — overwriting 'stopped', 'restore
did not complete' on the very next monitor pass. The container starts on the wreckage.
Suggested shape: patch { type: 'receiveonly' } directly by folderId as the monitor does, check the status, and if it
can't be demoted don't resume that folder — leave it paused and log loudly. A paused folder that the monitor later
resumes is a far better outcome than a sendreceive one resumed immediately.
Non-blocking
- getVolumeInfo still returns false on no-match (IOUtils.js:272) while the new JSDoc says "null when no matching
mount is found" — only the catch became null. The doc mismatch you flagged last time is still there. Functionally
harmless: both new call sites use if (!volume) and backupRestoreService now uses !dfInfoData || !dfInfoData.length. - Empty restore selection isn't refused. If every item arrives restore: false, targets is [] and the task still stops
the app, waits, and starts it — an outage that restores nothing. One early throw. - probeFolderSyncCompletion regexes the HTTP status out of axios's message string (/status code (\d{3})/). Correct
for 1.13.6 and it fails safe (everything becomes unknown → refusal) if the wording ever changes, but it's coupling to
a message format. Carrying error.response.status through performRequest would be structural. - runStreamingCommand's idle kill may not reach a sudo child — worth him verifying on a real node, I couldn't test it
here. child.kill() signals the sudo process, which execs with real uid 0; an unprivileged parent usually gets EPERM,
and Node then emits 'error' on the child. The promise still resolves with an error, so the verdict is right and
fail-safe, but the tar/du may keep running. Note this applies equally to the existing runCommand's timeout for
runAsRoot calls — not a regression. - runStreamingCommand's JSDoc was inserted between runCommand's doc block and runCommand itself, so there are now two
stacked doc comments and runCommand is undocumented. Cosmetic. - If onLine throws it escapes a stream 'data' handler — unhandled, and the promise never settles. Both current
callers are throw-free; a try/catch would make that structural rather than incidental. - First-run mount-safety coverage narrowed slightly. The removed startup sweep iterated syncthing's own folder list;
the replacement iterates installed apps. A sendreceive folder whose app spec can't be read (ownedByUnreadableApp) is
now neither verified nor swept on the first pass. Narrow, but it's a real gap that didn't exist before. - Harness only — the sha256 line is grepped out of the raw .asc, not out of gpg's verified output, so text outside
the signed block would also be matched. It fails closed (two lines for one filename makes sha256sum -c fail), so it's
safe as written; gpg --decrypt | grep is the cleaner form. The signature chain itself is correct: pinned fingerprint
asserted via VALIDSIG on --status-fd, and the deliberate no-pipefail choice is right and well documented.
Follow-up, not this PR: the asymmetry he acknowledged — backup refuses to start g: components, restore's
appDockerStart(appname) starts everything — is still there, and the FDM guard that would cover it is explicitly
skipped when fdmOk === false, which is exactly the path where restore then starts a g: container anyway. Pre-existing,
correctly out of scope here, worth an issue.
4b02282 to
dbfba9e
Compare
dbfba9e to
dc491ae
Compare
dc491ae to
a4235f8
Compare
syncthingAppsCore abandoned the whole cycle as soon as any app folder was unmounted, and it set syncthingInitializedSuccessfully only after that point. The finally clause clears syncthingAppsFirstRun only when that flag is set, so an app whose backing image is gone - unrepairable, so it never resolves - held the first-run flag set on every subsequent cycle. masterSlaveApps refuses to elect any g: primary while that flag is set, so a single broken app silently and permanently stopped every masterSlave app on the node from electing a primary. The blast radius is not limited to syncthing apps: checkAppFolderMounts walks every component of every installed app with no containerData filter, so an unrelated plain app is enough to jam the gate. Initialisation now means what it says - syncthing is up and its configuration is readable - and is recorded before any per-app work. An unsafe mount is an app-level fault: the folder is demoted to receiveonly and its container held, as before, and the rest of the pass proceeds. Holding an app out of a pass makes the unused-folder sweep's view incomplete, so a held-out folder is exempt from it - never visited is not the same as unused - and the device sweep stands down while anything is held out, since a peer device cannot be attributed to an app without doing that app's work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It never touched the syncthing first-run flag - it asserts an r: app stays stopped until its folder reaches 100%. The old name claimed coverage that suite 62 actually provides. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…han delete A backup is deliberately taken from a standby - the quiescent copy - so what makes the archive worth keeping is that the copy is COMPLETE. Nothing checked that. tar of an empty directory succeeds, so an instance that had never synced produced a 373-byte "backup" of a 35 GB app, recorded like any other. Restoring it later destroyed the world it was meant to protect. The task now resolves each component's syncthing folder and requires a fully synced index before it archives anything. A folder that is behind, or that syncthing was never configured with at all, is refused; `force` in the body archives what is on disk anyway and says so in the response and the log. The check runs before anything is stopped, so a refusal never costs a healthy app an outage. Freezing the data no longer deletes the folder. Deleting loses the folder config, and only the syncthing monitor's per-app pass ever recreates it - on a node where that pass cannot complete, the app keeps running and silently stops being redundant for good. Pausing stops the folder runner, so nothing writes under the archive, while the config and index stay put; the folders are resumed as soon as the archive is written, and on any failure. Verified against syncthing v2: pausing a folder stops only that folder's runner and never sets the daemon's restart-required flag. The folder ids were wrong as well. Folder ids are docker app identifiers, so a composed app's folder is flux<component>_<app>; stopSyncthingApp was addressing it as flux<app>, which matches nothing. For every v4+ app the freeze silently did not happen and the archive was taken from a live-syncing folder. stopSyncthingApp is left alone - the uninstaller passes it a component identifier, where deleting the folder is the correct thing to do. sendChunk now paces through serviceHelper.delay like every other wait in this file, instead of a bare setTimeout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
62 and 63 are claimed by the app-monitoring and cpu-throttling suites from the monitoring-routes work, which is not yet on development. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er suites 64 is not free. 64-content-capability-recovery.js and 64-placement-registration-gate.js both claim it on unmerged branches. The number is not a unique key to the runner - run-all.sh lexically sorts tests/*.js and duplicate prefixes already run fine on development - but suites are referred to by bare number, and SUITE_GLOB='tests/64*.js' would pull all three. 34 is the gap in the reconciler block and has never been used in any ref, on any branch, ever. It sits two slots from 36-reconciler-syncthing-sync-gate.js, the suite this one was split off from, and lexical order runs it with the suites it shares fixtures and patterns with. Named reconciler-* to match its neighbours. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A restore deleted appdata before it had anything to put back, and then told every other instance to hard redeploy - which removes their app directory and their backing volume. The intent was "make the peers match me, they will resync over syncthing"; the implementation destroyed the peers' only copies first, so an instance holding nothing turned every copy in the network into nothing. That is what an archive of one config file, taken from an instance that had never synced, did to a customer's 35 GB world. Nothing is destroyed now until a complete replacement is known to exist. The archive is fetched and then read end to end - one decompression pass that writes nothing - which establishes that it is whole and readable while the data it would replace is still there, and yields the size the free-space check needs. Only then does appdata make way for it. There is no judgement about how much smaller the archive is than what is on disk: rolling back to an early checkpoint is what a restore is for, and refusing it would block the ordinary case to guard against a wrong one. The peers are no longer told to redeploy, hard or soft. They hold the only other copies, and resuming the paused folder is what carries the restored data to them. Where an r:/s: component is concerned every instance is running and writing, so those peers are restarted - which recreates no volume and clears any operator stop; a g: component's other instances are already stopped and adopt the restored data when the role next moves, and an unsynced component's data never left this node. That distinction now comes from the primary mount's flags, the same predicate syncthing is configured from, rather than a substring search that reported sync on apps that have none. Syncthing folders are paused for the duration instead of deleted, per component, so the freeze finally happens for composed apps - stopSyncthingApp was addressing them by app name, which matches no folder. An unpack that fails leaves a directory that is neither copy, so that component's folder is demoted to receiveonly and its container held: the peers heal it, and the elected role moves away on its own because a demoted folder is not electable. Its cache entry has to record NOT settled or the folder state machine skips the healing path and starts the container on exactly that partial data. type and the component names are validated. Both came from the request body straight into a path that tar interpolates into a shell as root, so an app owner could run arbitrary commands on every node hosting their app. The downloaded archive is removed once the app is back up, not the moment the unpack returns - it is the only thing a later failure can be retried from. An uploaded or local archive is never removed at all: that is the owner's copy, and restoring from it must not consume it. Also: restore of a version <= 3 app reached .compose and threw, so it has never worked; getVolumeInfo answers null rather than false, which makes the existing `=== null` guard in backupRestoreService live for the first time; and tar and gzip failures no longer throw a second error out of the catch that was meant to report them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nitor holds a backup's pause The monitor now publishes syncthing:passComplete once per pass with what it wrote and what it held back for a live backup or restore - inert in production, the harness waits on it. Suite 44 starts a backup, waits for the folder to be paused, then waits for a real pass inside the window (the event proves a pass ran, so the assertion is not vacuously green) and asserts the pass never wrote - and so never un-paused - the folder the backup is holding. NOT YET RUN on cindy (in use by another session). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qWVgYJ4bqaUxdcrp9D1EB
The backup read the app specification and handed it straight to syncedComponentsOfApp, which threw Cannot-read-properties-of-null when the app had none - the restore already guards this and says so. The backup now refuses the same way, before any stop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qWVgYJ4bqaUxdcrp9D1EB
|
All seven serviced. Six of them turned out to be more than one-liners, so each has a test that failed on the code it fixes. 1. The idle kill could crash the process — fixed ( 2. 3. The backup double-claim — fixed architecturally ( 4. The monitor un-pausing a folder mid-operation — fixed ( 5. The kept archive — stated (PR body). Deliberate: the restore removes an archive it downloaded but keeps an uploaded or local one, because that is the owner's own restore point. The body now says plainly that a kept archive sits on the volume against the owner's quota until they remove it, and names a delete affordance as a follow-up rather than folding it in. 6. The double decompress — stated (PR body). Deliberate and correct for this platform: the first pass validates the archive is whole and measures its true uncompressed size while appdata is still intact, so a corrupt or oversized backup is caught before anything is cleared; gzip's ISIZE wraps at 4 GiB and says nothing about integrity, so it can't stand in. The alternatives that decompress once both need old and new on disk at once — up to 2× peak — and disk is the hard per-app quota where CPU is not. The body now states this so it isn't read as a regression. 7. The two minors. The backup's missing-spec path is fixed ( Unit over all six new commits: green. All of it is subject to the full harness gate, which is queued and will run this evening — cindy is in use by another session right now, which is also why the suite-44 assertion above is written but not yet executed. The gate is the real bar here: the branch now carries the reworked claim path, the monitor write-time guard, a new per-pass event, and the |
Cabecinha84
left a comment
There was a problem hiding this comment.
PR #1779 — review of the current head (10121b9)
Verdict: do not ACK yet. One blocking fix, ~3 lines. Everything else is sound.
Note first: the PR body describes the head as bd8749e, but there are 6 newer commits on top (de30f9f → 10121b9)
servicing your 2026-08-24 round. The blocker is in one of them.
What I did
Fetched refs/pull/1779/head, diffed against the real merge-base (383d8ae), read all product changes, traced every
cross-module contract the new code depends on, and ran the affected suites in a throwaway worktree (since removed).
- Unit: 505 passing, 0 failing across advancedWorkflows, syncthingMonitor, syncthingFolderStateMachine, serviceHelper,
IOUtilsMeasurement, fileQueryService, syncthingService, appQueryService, volumeReadersDoNotFollowLinks. - Lint: clean on all changed product files. (The one prefer-destructuring error in globalState.js:70 is pre-existing
on development — not this PR.) - CI: green (build + GitGuardian).
- The 85-suite docker harness I cannot run here — the harness claims remain unverified by me, as before.
🔴 Blocking: bea8f7a killed the masterSlaveApps backup/restore guard
Making the getters hand out a frozen snapshot was the right call for the claim primitives, but it silently broke the
one caller that captures the getter once and keeps it forever.
serviceManager.js:646-647 passes globalState.backupInProgress / restoreInProgress into masterSlaveApps. That function
is a self-recursing loop — it re-invokes itself in its own finally (advancedWorkflows.js:4691) passing the same two
references on for the life of the process. Before this PR they were the live backing arrays, so .some() at
advancedWorkflows.js:4187-4188 reflected current state on every 30-second cycle. Now they are a frozen copy of an
empty array, taken once at boot.
Reproduced directly:
captured at boot: []
tryStartBackup: true
live getter now : [ 'palworld' ]
captured ref now: []
masterSlaveApps backupSkip would be: false ← the guard is dead
frozen? true
Consequence. The g: election no longer skips an app under backup or restore. The good news is that the container start
is backstopped: appReconciler.isManagedElsewhere (appReconciler.js:355-356) re-reads the getter fresh each call, so
the reconciler still refuses to actuate. But requestMasterStartWithPermissionsFix (advancedWorkflows.js:2278) does
real work inline, before it ever hands the intent to the reconciler:
- appReconciler.claimStarting(appname)
- changeSyncthingFolderType(appId, 'receiveonly') — demotes the folder mid-restore
- applyPermissionsFix(appId) — chmod -R 777 recursively over the app directory while the restore is untarring into
appdata - only then setControllerDesired(...)
So a g: app being restored can have its syncthing folder demoted and a recursive chmod raced against the extraction.
Not the 35 GB catastrophe, but it is a deliberate safety guard in the exact incident family this PR exists to close,
now dead by accident — and dead silently, which is the property that makes it worth blocking on.
Why no test caught it: the unit tests call masterSlaveApps with literal arrays
(tests/unit/advancedWorkflows.test.js:626), so they exercise the parameter, never the wiring that feeds it.
Fix. Read the busy set from globalStateParam inside the loop rather than from the captured parameters — the same shape
the monitor already uses at its folder write in aaa59c0 ("the guard belongs at the action it guards"):
const backupSkip = globalStateParam.backupInProgress.some((item) => installedApp.name === item);
const restoreSkip = globalStateParam.restoreInProgress.some((item) => installedApp.name === item);
The two params then become vestigial (drop them, or leave them for the tests). The should skip apps in backup progress
test needs to claim via globalState.tryStartBackup(appName) instead of passing an array — which makes it a better
test anyway, since it would then actually cover the wiring.
Worth a grep pass for the same shape: serviceManager.js:646-647 is the only long-lived capture I found, but the class
of bug is "getter captured once into a long-running loop", and the freeze made every such site silently stale rather
than loudly wrong.
What I verified as correct
Everything else in the six new commits holds up:
- de30f9f — the bare spawn is gone; the idle kill goes through runCommand, which catches its own spawn failure. The
unhandled-'error' crash path you flagged is closed, and closed better than the listener you suggested. - e1a906c — getVolumeInfo → { error, mounts } is done properly. All six callers migrated (fileQueryService:29,
fileSystemManager:319,394, backupRestoreService:87, advancedWorkflows:2455,2688,2758). The two destructive restore
paths refuse distinctly on mountError vs !mounts.length; the backup path gained the guard it never had. No
.length-on-null left anywhere. - bea8f7a (apart from the above) — tryStartBackup/tryStartRestore are genuinely synchronous test-and-set, claimed
last in the pre-try block so a validation throw never leaves a claim standing, released in exactly one finally
reached by success, unauthorized and error alike. addToRestoreProgress/removeFromRestoreProgress were correctly
rewired to the primitives. - aaa59c0 — the mid-pass un-pause you raised as item 4 is properly closed: busyAppNames re-read at the folder write,
busy folders filtered out of foldersToWrite, and the promotedFolderIds reconciliation loop correctly narrowed to
what was actually written. - bd8749e — the supply-chain fix is real. Reading the checksum line from gpg --decrypt output rather than the raw
file defeats append-outside-the-signed-block, and the tarball filename carries the version, so cross-release
substitution fails on the filename too. - 10121b9 — if (!appDetails) throw before anything is stopped. Correct placement.
- The monitor's id derivation is consistent end to end: checkAppFolderMounts builds appIds via
dockerService.getAppIdentifier, the unsafeFolderIds skip tests the same, and unreadableFolderEntries uses folder.id,
which is that identifier. No mismatch. - verifyAppFolderMountWithRepair(…, sending) passes the mount root, which is what syncthing's folder path actually is
— so the deeper phantom-index check walks the same tree the old state-machine block did. Not a behaviour change. - The three restart-required call sites are gone, including adjustSyncthing.
Non-blocking notes
- syncthingMonitor.js — promotedFolderIds aliasing. globalState.promotedFolderIds = sendingFolderIds assigns the same
Set the mount check reads, and the reconciliation loop then mutates it. Benign today (the mount check runs earlier
in the pass, and the next pass rebuilds it), but it makes sendingFolderIds mean two things. A defensive copy on
assign would cost nothing. - runStreamingCommand — the idle message can mask a real error. In finish, idleKilled wins over the passed error, so
if a consumer throws and the idle timer had already fired, the operator is told "produced no output" instead of
what actually failed. Narrow, log-quality only. - PR body is stale. It documents bd8749e as head and the round-two table stops there; the six commits servicing
your last review aren't described. Worth a refresh before merge so the body matches what's being merged. - getDirectorySizeBytes returning null understates room (room = available + (appDataBytes ?? 0)), refusing a restore
that would have fit. This is documented as the deliberate safe direction and I agree with it — noting it only so it
is a stated choice rather than a discovered one.
… a boot capture The frozen getters turned serviceManager's boot-time arguments into an empty photograph, and masterSlaveApps re-passed it to itself forever - the backup/restore guard could never fire again, so the election could demote a folder and chmod appdata against a live restore. State now comes off globalStateParam at each decision, the projection parameters are gone, the skip counts as masterSlave:decision/skippedBusy, and suite 35 proves the guard end-to-end through the real wiring under a live backup hold. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qWVgYJ4bqaUxdcrp9D1EB
… observed globalState.promotedFolderIds was the same Set as the pass-local scan; the end-of-pass reconciliation mutated both through one object, and external readers could see a half-updated scan. Published as a copy: the local set stays the scan-time observation, the reconciliation mutates only what is published. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qWVgYJ4bqaUxdcrp9D1EB
…es a flag finish() had one error slot and the idle verdict won it, so a consumer throw landing after the timer fired was reported as 'produced no output'. A real error now takes the slot, res.idleKilled carries the kill, and the exit the kill itself provoked still reads as the idle cause, not an exit code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qWVgYJ4bqaUxdcrp9D1EB
Node numbers are 1-based in subnet-config; nodeIp(0) is an address nothing occupies, so the new suite-44 test polled an empty write log for 120s while the backup's paused=true landed on the real node - proven in the gate's node-00.log. First execution of a written-but-never-run test doing its job. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qWVgYJ4bqaUxdcrp9D1EB
The strict thinned<full inequality needs full>=3 (thinning a sub-hour series keeps first+newest = 2), but nothing ordered the test against the sampler - it starts with the app and accumulates on its own clock, and the suite arrives whenever the prior tests finish. The precondition is now waited for explicitly; the assertion is untouched and still fails when thinning is gone. Pre-existing on development (e41cef7), caught by this stack's first gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qWVgYJ4bqaUxdcrp9D1EB
…ther seed buildSeedableLegacyApp hardcoded height=2100010 while buildSeedableApp's own comment warns a bare constant goes stale the first time somebody uses one - this was that first time. On a chain starting at 2200000 the seeded app was expired before the fleet's first block and assertAliveOnThisChain refused it. The helper now takes env and derives, and suite 92 passes it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qWVgYJ4bqaUxdcrp9D1EB
…rst test's The stub's folder-write log is cumulative and both tests back the same app up on the same node, so the second test read the first's paused:true/paused:false pair. That made the hold-wait vacuous - satisfied before this test's backup took any hold - and then failed the assertion with the PREVIOUS backup's legitimate resume, which appendBackupTask performs by design before it clears the busy flag. Proven from the gate's node-00.log: one pause pair, both the first test's, and the monitor logged "keeping folder ... unprocessed" on every pass in between. resetFolderWrites() is what suites 87 and 88 already use. afterId moves inside the window for the same reason: taken before the backup started, the passComplete wait could be satisfied by a pass that completed before the folder was ever paused - proving a pass ran, not that one ran while it was held. The assertions themselves are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SB8Saua6GiMLqEYdvkUccG
|
Thanks — the blocker is real, and it is a regression this PR introduced. I verified every link myself rather than taking the reproduction on trust, and the one premise you asserted without proof turned out to be the load-bearing one: the pre-freeze getter did return the live backing array, so this is genuinely ours and not a pre-existing latent bug. I also think the review understates it. A restore stops the app's containers, and "installed The blocker — fixed as architecture, not as the three-line read (
|
| suite | what was actually wrong |
|---|---|
| 44 | the monitor-hold test polled nodeIp(0). Harness node numbers are 1-based, so .9 is an address nothing occupies and its write log reads empty forever. The product did everything right, proven from the captured node log: setSyncthingFolderPaused … paused=true landed on time and the monitor skipped the held app on every 3s pass |
| 62 | the suite's before() waited for exactly 2 samples while the strict thinned < full assertion needs ≥3 (hourly-thinning a sub-hour series keeps first+newest=2). Raced from birth, not a loaded box; it only ever passed when the intervening tests happened to span a sampler tick. The assertion itself is untouched |
| 72 | the Aug-10 signal rework deleted const STAGING_UUID and missed two tests that use it not as a wait signal but to plant fake .flux-op-<uuid> staging dirs as their sweep-actually-ran positive control. Both threw ReferenceError on first-ever execution. The const returns as a documented fixture |
| 92 | buildSeedableLegacyApp hardcoded height = 2100010 while this run's chain started at 2200000, so the seeded legacy app was expired before the fleet's first block — caught by the harness's own assertAliveOnThisChain guard. It now follows env the way buildSeedableApp already did |
Suite 72's fix lands on #1781, which owns the commit that deleted the constant; the other three are on this PR. That keeps each PR's diff self-contained across force-pushes and independent merges.
The targeted re-run found one more, in the same suite. 62, 72 and 92 all went green — including suite 92's restore tests, which had never executed before. Suite 44 got past its fixed wait for the first time and failed on the next assertion down, which is exactly what a first execution is for.
It is a third test defect, not a product one. The stub's folder-write log is cumulative, and suite 44's two tests back up the same app on the same node with no reset between them — so the second test read the first's paused:true/paused:false pair. That made its hold-wait vacuous (satisfied before its own backup took any hold) and then failed its assertion with the previous backup's legitimate resume — the one appendBackupTask performs by design after the archive is written and before it clears the busy flag, so redundancy is restored at the earliest safe moment.
The node log settles it: exactly one pause pair in the window, both the first test's, and on every monitor pass in between FluxOS logged keeping folder … unprocessed this pass. The property the suite exists to prove — the monitor does not un-pause a folder a backup is holding — held throughout, and the pass.data.wrote assertion that tests it directly passed. The fix is resetFolderWrites(), which sibling suites 87 and 88 already call for this reason, plus moving the event-id capture inside the window (taken before the backup started it could be satisfied by a pass that completed before the folder was ever paused). Neither assertion was weakened.
With that fix, suite 44 is 2 of 2 green and the whole set of four is clean:
44: suites_pass=1 suites_fail=0 (2 of 2 tests, both assertions past the old wait)
62: suites_pass=1 suites_fail=0
72: suites_pass=1 suites_fail=0
92: suites_pass=1 suites_fail=0
Suite 44's second test now takes 140 seconds where the failing version took 70, because it genuinely waits through its own backup hold — and the two waits inside it (the hold, and a monitor pass completing after the hold) are fail-loud, so a window that never existed would time out rather than pass quietly. The unit-level mutation drills for the blocker and the idle-kill fix were both run and both bit.
Unit suite on the final head: 5,501 passing, 18 pending, 0 failing.
Cabecinha84
left a comment
There was a problem hiding this comment.
PR #1779 — review of the current head (6067bf2)
Verdict: ACK-able, with one fix I'd ask for first. It is ~3 lines, it is in the success path of the restore, and it
contradicts a rule this PR itself states and enforces on the backup side. Everything else I found is a nit or a
follow-up.
What I did
Fetched refs/pull/1779/head (6067bf2), diffed against the real merge-base (383d8ae) rather than the branch tip,
read all ~950 lines of product change plus the harness additions, traced every cross-module contract the changed code
depends on (globalState getters, folderNeedsUpdate, dockerActual, manageFolderSyncState's return shape,
getVolumeInfo's new shape across all six callers), and ran the full unit suite in a throwaway worktree.
Unit tests: 5,493 passing, 5 pending, 21 failing — all 21 in tests/unit/dockerService.test.js, all pre-existing. I ran
that file alone on development and it fails 23 there, so the PR fixes nothing and breaks nothing in it; the delta is
docker-daemon availability in my environment, not the branch. dockerService.js is not in the diff. Nothing
attributable to this PR fails.
Where it stands vs. my last review
The blocker from 2026-08-24 17:54 is genuinely fixed, and fixed the right way rather than papered over.
3c8a24b removes receiveOnlySyncthingAppsCache, backupInProgress and restoreInProgress from masterSlaveApps's
signature entirely and reads them off globalStateParam at each decision (advancedWorkflows.js:4189-4190, :4263, :4579,
:4619, :4649). The self-re-invocation at :4694 no longer re-passes anything captured. serviceManager.js:641-649
carries a comment stating the constraint at the call site, which is where it needed to be. I verified the thing the
fix rests on: globalState.receiveOnlySyncthingAppsCache (globalState.js:168) returns the live Map, so the
seedCache.designatedLeader = false write at :4619 still mutates real state — a frozen copy there would have been a
silent new bug, and it isn't one.
0e6b8ab and 6026e4b are both correct and both minimal. On the latter, the ordering in finish() is right: a
consumer throw or spawn failure wins the error slot, idleKilled rides separately, and a kill-provoked exit no longer
surfaces as "exited with code 143".
The four commits after that (9fbac88, b69bba2, d84024c, 6067bf2) are harness-only — suite 44, 62 and 92 plus
longer surfaces as "exited with code 143".
The four commits after that (9fbac88, b69bba2, d84024c, 6067bf2) are harness-only — suite 44, 62 and 92
plus seed-helper. No product surface.
The one fix I'd ask for
The restore starts g: containers the election owns — advancedWorkflows.js:2820
await sendChunk(res, 'Starting application...\n');
await appDockerStart(appname);
appDockerStart(appname) with a bare app name enumerates appSpecs.compose and starts every component (:2066-2071).
The backup path, forty lines earlier, deliberately does not do this:
// A g: component's run state belongs to the election, not to this task:
// starting it here would put a second writer on the shared volume.
const componentsToStart = componentsOfApp(appDetails)
.filter((comp) => syncModeOfComponent(comp.containerData) !== 'elected');
The restore has the same hazard and none of that guard. Three ways it bites:
- appDockerStop(appname) stops every component, not just the targets. So restoring component A of a composed app
stops component B — and if B is g: and correctly stopped on this node because the election put the primary
elsewhere, the restore then starts it. A g: component that was never part of the restore gets started on a
non-primary node. - The FDM guard at :2641-2647 only refuses on a positive answer. getMasterIpFromFdm returns { ip: null, fdmOk:
true } when all three regions fail (:263) — it never returns fdmOk: false, so that half of the condition is
vestigial. FDM unreachable → primaryIp null → guard does not fire → restore proceeds → g: container started
blind. - force: true skips the FDM check entirely and then starts the g: container.
The folders are resumed at :2812 before the start, so the window is: a g: container writing into a live
sendreceive folder on a node that may not hold the primary — the exact "two writers on the shared volume" the
backup path names.
The reconciler is a partial backstop but not a reliable one: effectiveDesiredRunning returns awaitingController
and takes no action when controllerDesired is unset (appReconciler.js:371-373), which is precisely the state after
a FluxOS restart. masterSlaveApps converges it within ~30s once the app leaves the busy list — but only if FDM
answers, which is the case where this fires in the first place.
The fix is the backup path's own three lines. appDetails is already in scope at :2820, and componentsOfApp /
syncModeOfComponent are already imported.
Worth a suite-91-shaped test: a restore on an app with a g: component whose primary is elsewhere must leave that
component stopped.
Non-blocking
A failed restore with a failed demotion is un-paused by the next monitor pass. At :2872-2879 the restore leaves an
undemotable folder paused and out of the resume — deliberately, with a loud log, and the comment is honest that
this is damage limitation. But folderNeedsUpdate returns true on existingFolder.paused
(syncthingMonitorHelpers.js:294), the app is no longer busy so the busy-filter at :2814 doesn't hold it, and the
next pass writes paused: false. The partial data then goes out to healthy peers. It takes a double failure
(extract fails and the receiveonly PATCH fails while syncthing was reachable enough to have paused earlier), so I
would not block on it — but the mitigation is asymmetric with how carefully everything else here is sealed. A
durable quarantine the monitor honours would close it.
sendChunk costs 3s per line. :1799-1803 — the restore emits substantially more progress lines than it used to
(per-target pause, download, archive check, extract). On a five-component app that is a minute of pure delay().
Pre-existing design, newly amplified. Not correctness.
fdmOk is dead. getMasterIpFromFdm returns true on every path (:245, :263), so both call sites' fdmOk conditions
(:2645, :4234) test a constant. Pre-existing, not introduced here, but the new call site reads as if it
distinguishes something it cannot.
skipUpdate is a dead return field. manageFolderSyncState returns it at syncthingFolderStateMachine.js:1151;
processContainerData destructures skipProcessing and never reads skipUpdate (syncthingMonitor.js:321).
Pre-existing, but this PR deleted the only branch where the value mattered — it can go.
backup isn't shape-checked. The restore validates Array.isArray(restore) (:2576); the backup does not, so a
non-array backup surfaces as a TypeError from .some() rather than a stated refusal. Cosmetic — the outer catch
handles it.
addToRestoreProgress / removeFromRestoreProgress (:3137, :3145) now wrap the atomic primitives but have no
production callers, only tests. addToRestoreProgress discards tryStartRestore's boolean, which is the one thing
that primitive exists to return. Either delete them or have them return the verdict.
…d the primary The restore ended with appDockerStart(appname), and a bare app name fans out to every component in the compose array - elected ones included. The backup path forty lines earlier filters them out with a comment naming the hazard: starting one here puts a second writer on the shared volume. The restore had the same hazard and no guard, and the folders return to sendreceive a few lines before that start, so the container came up writing into live replicated storage. Three ways in. The stop is the same fan-out, so restoring component A stops component B - and a g: component that was never a target of this restore was stopped and then started here. The FDM check only refuses on a positive answer, and getMasterIpFromFdm never returns fdmOk: false, so an unreachable FDM read as permission. And force skips the check entirely. The fix is not the backup's blanket filter. A g: restore is MEANT to run on the primary - the guard's own comment says restoring anywhere else is quietly undone by the writer - so refusing to start elected components outright would tax every legitimate restore with an election cycle of downtime. The FDM answer is instead kept rather than discarded after the refusal check: confirmed primary starts everything as before, and only an unconfirmed one leaves elected components to the election. That leaves the restore acting on what FDM actually said: a confirmed primary is permission to start a writer, and neither of the other two answers - no primary yet, or no answer at all - is. Four unit tests, and both drills discriminate: the original fan-out fails the three leave-alone cases while the confirmed-primary one still passes, and the blanket filter fails only the confirmed-primary case. An existing test - "removes the copy it downloaded only once the app is back up" - fails under the blanket filter too, so the tree already disagreed with that version. Suite 86 gains the fleet case, on the suite that already owns "refuses on an instance that is not the one holding the live copy": a forced restore on the standby must leave the elected container down, asserted over a window rather than sampled once, with the standby's stopped state checked first so a stopped container afterwards means something. The harness client learns `force`, which is API-only by design and so has no other way to be exercised. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SB8Saua6GiMLqEYdvkUccG
… is not load-bearing Its first execution failed the test three above it rather than itself. It runs a real restore on the STANDBY, which clears that node's appdata, and every test above asserts the standby's copy is exactly what the fixture wrote there - "replaces the primary copy and leaves the peer entirely alone" read the peer's marker as missing and called it a blast-radius regression, which it was not. Declaring it last would have hidden that, not fixed it: the next test appended to the file - the natural place - would fail for reasons that have nothing to do with it, and a comment is not a guard. It now installs its own g: app on the shared fleet and elects the primary for that app alone, so it touches nothing any other test reads and order stops mattering. The fleet is the expensive part and stays shared; a second app is cheap. The test itself passed first time, and the product behaviour it asserts held. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SB8Saua6GiMLqEYdvkUccG
…seconds later sendChunk slept 3000ms before every write, from the original backup/restore feature, with the comment "Adjust the delay as needed" - a placeholder carried through two refactors and never tuned. The flush beside it is what actually delivers a line: Express's compression middleware buffers small res.write calls, and without the flush a progress stream sits in that buffer. The sleep delivers nothing. It is not padding on a progress stream, it is padding on an outage. The restore pauses the folders and stops the app, and every line between there and the start - per-target pause, download, archive check, extract, four of them inside per-target loops - costs three seconds of application downtime, suspended replication, and a held restoreInProgress lease the election is deliberately looking away for. appDockerStop takes sendChunk as its per-line callback, so docker's own stop output stretches the stop itself. 22 call sites across backup and restore. The 24 explicit serviceHelper.delay calls in this file are untouched - those are where timing is the point. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SB8Saua6GiMLqEYdvkUccG
…o primary
getMasterIpFromFdm has two exits and both return fdmOk: true, including the one
reached after all three regions have failed. The flag has never been false in the
history of the repo, which leaves a stand-down in masterSlaveApps that has never
executed:
if (!fdmOk) { log.warn('All FDM services failed ... skipping'); continue; }
So "FDM could not be reached" and "FDM says this app has no primary yet" both
arrive as a null ip, and the election reads the second. It logs "has currently no
primary set", checks whether this node's copy looks synced, and moves to promote
itself - on exactly the observation that was supposed to stop it. A g: component
is one writer on a shared volume, so the failure mode this opens is the one the
surrounding work exists to close.
fdmOk now carries whether any region gave a verdict about the app. A success body
is one, with or without an ip. A 404 is one too, and deliberately: FDM holding no
record of an app is the answer a newly deployed g: app gets, and standing down on
it would leave that app without a first primary for as long as FDM had no row -
a deadlock in place of a guard. A 503 is not a verdict, because it is FDM
reporting itself as not ready to answer, and neither is a body that is not
success. One region answering is enough, so the routine loss of a single region
does not stand the fleet's g: apps down.
The behaviour change is deliberate and is a trade, not a free win: during a total
FDM outage a g: app now stands down instead of self-promoting. It costs
availability across the outage and buys back the guarantee that two nodes never
write one volume.
The comment above primaryConfirmedLocal in the restore path asserted that
masterSlaveApps skips primary selection when every region fails. It proceeds -
the branch that would skip is the dead one above. The restore guard is right for
its own local reason, so it now states that instead of reaching for another
module's behaviour to justify itself.
Four unit tests, one per answer FDM can give plus the partial outage. Every one
of them was mutation-tested and the matrix discriminates: restoring `fdmOk: true`
fails both stand-down tests and neither of the others; dropping the 404 case
fails only the 404 test; dropping the success case fails only the partial-region
test. No mutation survives and no test is vacuous.
The 34 election tests that stub FDM as `{ data: [] }` for "no primary yet" now
send what FDM actually sends, a success body with an empty ips array, which is
what test-infra/fdm-stub returns and what the node treats as an answer. The old
shape reached the same path by accident, through the branch for a response that
is not success at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTSdPPSRdt8jgm4s5Bxtq
…g: app down while it does
The harness could not reach the state the election stands down on. The FDM stub
offered four controls - elect, clear, reset, read state - and clearing a primary
is FDM answering that there is none, which is the second of its three answers,
not the third. Its catch-all route replied `{status:'success', ips:[]}` to any
request it did not recognise, so even an unexpected call came back healthy. No
suite stopped or partitioned the FDM container. The third answer was unreachable
from the harness, and a flag reporting it went dead without a suite noticing.
The stub takes two outage modes. `refuse` closes the listening socket, so the
node's poll gets ECONNREFUSED - the production signature, an error carrying no
response at all. `unavailable` keeps the socket and answers 503, which is FDM
reachable and reporting itself as still starting up. The control API is a second
server on its own port, so it stays reachable to end an outage. /reset ends one
as well as clearing elections, because suites reset in both setup and teardown
and an outage left behind would answer for the next suite.
Suite 97 covers the property the flag exists for. Every fdm-*.runonflux.io name
is an alias on the one stub container, so closing that socket is the whole of FDM
going silent rather than one region failing over to another. The outage starts
before the app is placed, so no cycle in the suite ever sees an answer and the
assertion cannot be satisfied by a promotion that had already happened.
Two tests, because the first alone would prove less than it appears to. It waits
for a holder to report FDM unreachable - positive proof the stand-down branch ran,
where an app that never reached an election would give the same quiet - and then
holds a window with neither holder up, since the election re-runs each cycle and
one sample only speaks for its instant. The second ends the outage and watches a
holder take the primary. That is the vacuity guard: it changes exactly one input,
FDM's answer is still "no primary yet", and the promotion that follows is the one
the silence was suppressing. A wrong fixture or an unsyncable folder would give
the first test its zero and fail here.
Three nodes and two holders, matching the small-fleet suite, so the promotion this
suite must not see is one that suite already proves happens.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTSdPPSRdt8jgm4s5Bxtq
… nothing reads manageFolderSyncState returned skipUpdate: true when a folder was already in sendreceive. No caller has ever read it - processContainerData names two fields off that result and skipUpdate is not one of them - and that was already so at the merge-base, so the field is not newly dead here. What this branch removed is the only other return that carried it, which left it a constant. Deleting it is the right direction rather than the tidy one, because wiring it up would be a bug. A folder already in sendreceive still needs its config rewritten when the app's device list changes, and folderNeedsUpdate is what decides that, by comparing the existing folder against the desired one. A caller that honoured skipUpdate would suppress those writes and leave folders syncing to a stale set of peers. Its test asserted result.skipUpdate and nothing else, so removing the field would have taken the path's only coverage with it while proving nothing was lost. The path's actual work is two side effects: an r: container found stopped is asked to run, and the cache entry the health monitor tracks the folder by is carried forward rather than replaced. Those are what the two tests now assert, and the mutation drill discriminates - dropping the ensureContainerRunning call, building a fresh cache instead of keeping the existing one, and inverting the running check are each caught, the last by both tests. The deleted assertion caught none of the three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnTSdPPSRdt8jgm4s5Bxtq
…those words appendRestoreTask checks Array.isArray(restore) before reading it; appendBackupTask went straight to backup.some(). A caller sending the single component it wants as a bare value reached that call and got the interpreter's own wording - "backup.some is not a function" - written down the progress stream as the refusal, because the outer catch relays error.message to the client verbatim. Nothing else came of it. The throw lands ahead of the claim, in the same synchronous block the claim's comment describes, so no lease is left held and no folder is touched. This is what the caller is told, not what the node does. Both guards are now covered. The restore's had no test of its own, so removing it cost nothing visible - the pair here fails when either check goes, and neither notices the other's removal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnTSdPPSRdt8jgm4s5Bxtq
…ve, not a wrapper that drops the verdict addToRestoreProgress and removeFromRestoreProgress wrapped globalState's tryStartRestore and finishRestore. Nothing in production called either; the only callers were tests. And the wrapper was worse than idle - tryStartRestore returns whether the claim was granted, which is the entire reason it exists, and addToRestoreProgress discarded it. A caller reaching for the obvious-looking name would have started a second restore of an app already being restored and been told nothing, because a refused claim leaves the list looking exactly as the first claim left it. Both are gone, and the one test that had a reason to call them - it needed an app to be mid-restore so redeployComponentAPI would decline - takes the claim through the primitive instead. Their three tests went with them, and that would have quietly cost the claim its only coverage: globalState.test.js had twenty tests and none reached tryStartRestore. Two restores running one archive into one appdata is what this guard prevents, so the tests now sit on the primitive and include the verdict the wrapper was throwing away. The drill discriminates - granting every claim, returning nothing, a release that removes nothing and a release that clears every app are each caught, and all six tests fail under at least one, so none of them is along for the ride. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnTSdPPSRdt8jgm4s5Bxtq
…ry to the branch that fixes it The mid-backup test ended by waiting for the elected primary to be running again within two minutes of the backup releasing its claim. That is not what the test exists to prove, and on this branch it is not what happens. recordRestart here counts every restart, deliberate or not, so each start/stop the suite performs earns a rung on the backoff ladder. By this test the app is on the five-minute rung, and the backup's own stop puts it there again: the election asks for a start every cycle and the reconciler defers each one. The app does come back - after the rung elapses, not inside the window the assertion allowed. The three assertions that remain are the ones the test was written for, and they are unaffected: an election pass ran and skipped the busy app, no start was decided while the hold was live, and no start event was emitted. The counter proof means the no-start assertion still cannot pass vacuously on a cycle that never ran. The convergence assertion belongs with the change that earns it - a deliberate stop that costs no rung - and moves there rather than being widened to a timeout that would accommodate five minutes of pacing without asserting anything about it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnTSdPPSRdt8jgm4s5Bxtq
|
All six items are addressed. The blocking one is fixed differently from your suggestion, and the reason is in the first section. Blocking: the restore started g: containers the election ownsFixed. Your reading of the mechanism is exactly right, including all three routes in — the stop's fan-out reaching a component that was never a restore target, the guard refusing only on a positive answer, and I did not take the backup path's filter, though. A Both versions were mutation-drilled against each other. The original fan-out fails the three leave-alone cases while the confirmed-primary case passes; the blanket filter fails only the confirmed-primary case. An existing test — "removes the copy it downloaded only once the app is back up" — also fails under the blanket filter, so the tree already disagreed with that version. Suite 86 gains the fleet case you asked for: a forced restore on the standby must leave the elected container down, asserted over a window rather than sampled once, with the standby's stopped state established first so a stopped container afterwards means something. fdmOkFixed, and it turned out to be more than cosmetic.
if (!fdmOk) { log.warn('All FDM services failed ... skipping primary selection for this cycle'); continue; }Since the flag was never false, that branch had never run. "FDM could not be reached" and "FDM says this app has no primary yet" both arrive as a null ip, and the election reads the second: it logs "has currently no primary set", checks whether this node's copy looks synced, and moves to promote itself — on the observation that should have stopped it.
The behaviour change is deliberate and is a trade: during a total FDM outage a A correction to something I wrote in the previous round. A comment in the restore path, and the body of the commit that added it, said The FDM stub gained the ability to stop answering — closing its socket for a refused connection, or returning 503 — and suite 97 covers the property: with FDM silent, no holder promotes; when FDM answers again, one does. The four non-blocking itemsQuarantined folder un-paused by the next monitor pass — no change. Verified: sendChunk's 3s per line — removed. The flush beside it is what delivers a line; the delay was padding on an outage, since the app is stopped and the folders paused for most of the lines it delayed. skipUpdate — deleted, along with its export. Worth adding that wiring it up would have been the wrong repair: a folder already in sendreceive still needs its config rewritten when the app's device list changes, and backup not shape-checked — added, in the restore's wording. Both guards are covered, each by a test that fails when its own check is removed and is indifferent to the other's. addToRestoreProgress / removeFromRestoreProgress — deleted rather than fixed, since nothing in production called them. You are right that discarding One scoping change worth flaggingSuite 35's mid-backup test ended by waiting for the elected primary to be running again within two minutes of the backup releasing its claim. That assertion has moved to #1780. It is not this branch's property. The three assertions the test was written for are untouched and pass: an election pass ran and skipped the busy app, no start was decided while the hold was live, and no start event was emitted. The counter is still what stops the no-start assertion passing vacuously on a cycle that never ran. VerificationThe full harness gate runs green on this branch — 85 suites, including the restore family end to end and the new FDM-outage suite. The unit suite is green at 5,515. |
Cabecinha84
left a comment
There was a problem hiding this comment.
Three things I'd raise, none blocking
- d2a79fa deletes a convergence assertion, and the reason is production-visible. Suite 35 used to end by waiting
for the elected primary to be running again within two minutes of the backup releasing its claim. It was removed
because on this branch that doesn't happen: appReconciler:945 calls recordRestart for every restart, deliberate or
not, so the backup's own stop earns a rung on the ladder [0, 30s, 5m, 15m, 30m]. The commit message says the fix
belongs to another branch.
The production exposure is smaller than the harness makes it look — restartWaitMs resets the ladder when the previous
run provably lasted STABLE_RUN_MS (10 min), so a backup of a normally long-running app resets and restarts
immediately. What's left is back-to-back operations, or an app started under 10 minutes earlier. But it is a change
this PR makes: backup and restore no longer restart the g: component themselves and hand it to the election, which
goes through the paced reconciler. The direction is right (starting a g: container the election doesn't own is the
incident class), the pacing is the cost. Worth a line in the PR body next to the other deliberate consequences, and
worth confirming the follow-up branch actually exists.
-
A g: component that is not a restore target is stopped and left down. primaryConfirmedLocal is only computed when a
target is elected, so restoring only the r: sidecar of an app that also has a g: component stops the g: one via the
bare-name fan-out and then declines to start it. There's a test asserting exactly this, so it's a decision and not an
oversight — but the cheap improvement is to run the FDM check when any component of the app is elected rather than
only when a target is. In the common case (FDM names this node) that starts it back immediately instead of waiting an
election cycle plus whatever item 1 costs. -
An FDM outage now compounds silently. With FDM unreachable, the restore declines to start its elected components
and the election declines to act. Both are individually correct; together, a restore performed during an outage leaves
the g: component down for the whole outage with no log line saying so. One log.warn at the end of the restore —
"elected components left to the election; FDM did not answer" — would make it diagnosable from the node's own logs.
Minor, mentioned only for completeness: the fdm-stub's /appips/:app can return success-with-empty-ips or 503, but
never 404 — so the "404 counts as an answer" branch, the one that keeps a newly deployed app electable, has unit
coverage only and suite 97 can't reach it.
|
None of the four changes the code, and three of them are the same item. Items 1, 2 and 3 each describe a 1 — the moved convergence assertionThis is not a finding. It is this PR's own disclosure from the previous round, returned as a review item. The round-five reply raised it unprompted, under "One scoping change worth flagging", and stated the whole of it: that the assertion was removed, that What the note does add is one correct observation, and it argues the exposure down: One claim in it is wrong. "Backup and restore no longer restart the "Worth confirming the follow-up branch actually exists": #1780 is open, is based on this branch, and carries the assertion in The PR body gains the line. 2 — a
|
Overview
On 2026-08-04 a customer's Palworld world was destroyed by a restore. This PR fixes the path that destroyed it — and four further ways data could still be lost, which only became visible once these tests were run against real nodes for the first time.
What went wrong. One node had a single app whose backing volume had been deleted. That one unrepairable volume jammed a node-wide gate in the syncthing monitor, so no syncthing folder was ever configured for any app on that node, and no
g:primary election ever ran there. A customer's Palworld instance was stranded on it — never synced, holding 373 bytes. Someone backed that instance up and restored it the next day. The restore deleted appdata before fetching anything, then fanned out a forced hard redeploy to every other instance, whichrm -rf'd the 35 GB volume on the one node that actually held the world.What this delivers:
g:election guard brought back to life. Details in "Review rounds two to four" below.d2a79fabe), and the full harness gate green on this branch — 85 suites.Four of the five product fixes were found by running the new suites, not by writing them.
First of four in the stack:
development ← this ← #1780 ← #1781 ← #1782. Everything that was below has merged; this targetsdevelopmentdirectly.What this changes
The gate.
syncthingAppsCoreabandoned the whole cycle on any unmounted folder, and setsyncthingInitializedSuccessfullyonly after that point — sosyncthingAppsFirstRunlatched forever, and that flag gates theg:primary election node-wide. One deleted volume stopped every masterSlave app on the node from ever electing. An unsafe mount is now an app-level fault: that app is demoted and held out of the pass, and the rest of the pass proceeds.The backup. Refuses to archive a copy that is not synced (
forceoverrides), checked before any stop. Holds its folders by pausing them rather than deleting them by an app-level id that matched no composed app's folder — the freeze had never once run for a v4+ app.The restore. Acquires before it destroys: validates the request, refuses where the writer isn't, claims the app once, holds the folders, stops and verifies the containers, downloads, reads the whole archive, checks free space after the download, and only then clears and extracts. On success it
apprestartsr:/s:peers — never a redeploy, hard or soft. Two of its inputs reached a root shell throughtar; both are validated.That "reads the whole archive" is a full decompress that writes nothing, and the extract later is a second one — deliberately, not by oversight. The first pass proves the archive is complete and readable and measures its true uncompressed size, all while appdata is still intact, so a corrupt or oversized backup is caught before anything is cleared. Gzip's own size footer can't stand in for it: ISIZE is stored mod 2³², so it wraps at 4 GiB and is simply wrong for a 35 GB volume, and it says nothing about integrity. The alternatives that decompress only once — into a temp dir then swap, or to a plain
.tarthen extract — both need the old and new copies on disk at once, up to 2× peak, and disk is the hard per-app quota on a node where CPU is not. So the restore spends a second decompress to stay within one quota's worth of space rather than risk a restore that cannot fit. On the 35 GB incident volume this roughly doubles restore wall-clock; that is the cost of validating and measuring before destroying, not a regression.A restore keeps the owner's own archive, and only deletes the one it fetched. On success the restore removes an archive it downloaded — a transient copy it pulled for the job — but leaves an uploaded or already-local one in place: that is the owner's restore point, and deleting it after one use would destroy the very thing they may want to restore from again. The deliberate consequence is that an uploaded archive then sits on the app's volume and counts against the owner's disk quota until they remove it themselves. A UI affordance to delete a kept archive is worth adding but is its own change, not folded in here.
Syncthing is no longer restarted on a latch we cannot have set. FluxOS asked
/rest/config/restart-requiredafter changing folder config and restarted the daemon if it said yes. Validated against the syncthing source at v2.0.15 and v2.1.3-rc.2 (the relevant files are byte-identical): that flag is a one-way latch with a single write site and no reset, set only for two Options fields,auditEnabledandauditFile. FluxOS sets neither, and every folder operation it performs is handled in-process bymodel.restartFolder. The endpoint never answered "did my change need a restart" — it answers "has anything, ever, needed one", so anytrueFluxOS saw belonged to something else, hours earlier, and never cleared. On a normal node it returned false and did nothing; set once, it would have restarted the daemon on every pass — and once per folder instopSyncthingApp— until syncthing's own supervisor gave up after four restarts in sixty seconds and exited. All three call sites are gone. Two were in the monitoring path; the third was inadjustSyncthing, which the sentinel calls every eight minutes rather than once at startup, so it had the same per-pass shape as the other two.Mount safety is one implementation instead of three. The monitor's block, a startup sweep and the folder state machine each derived the same verdict on the same trigger over the same folders. One authority now, with the deeper phantom-index check where it belongs and the promotion-count reset it was missing. The check before a flip to sendreceive stays separate and uncached — that answers a different question at a different moment.
What running the tests found
Four product defects, none reachable by reading, unit tests or a rebase. All are in the restore's failure handling.
The first is a direct consequence of rebasing onto this stack: the monitor's exemption for suspended apps was correct against code that deleted its own folders, and this branch's pause-instead-of-delete rewrite silently invalidated its premise. A textual merge cannot show that.
A fifth was found by the full gate, in this PR's own new code rather than in FluxOS: the container-stop guard read an unreachable docker daemon as "the container is still running", so a backup running across a dockerd restart was refused — and refusing releases the lease it was holding, handing the app back to the reconciler mid-operation. That is precisely what suite 44 exists to catch, and it caught it. The guard now uses
appReconciler.dockerActual, which probes the daemon rather than pattern-matching an inspect error, so "could not ask" is no longer read as an answer.The last two share a shape worth naming. Both protections were conditioned on something incidental — one on the sync mode, one on nothing at all — when what they guard against is simply an app running on data that is neither the old copy nor the new one. The gating on the fourth was actively backwards: a component that syncs has peers to be put right by, so holding it costs minutes; one that does not sync has no repair path at all.
Every precondition of a restore is now checked while the data it protects is still on disk.
Review rounds two to four
Round two — eight commits (servicing the 2026-08-11 review)
Each with a test that failed on the code it fixes:
ee0bf46d4ERR_BAD_REQUESTspans every 4xx, so a 403 from a stale api key opened the gate a 404 is for; only a bare HTTP 404 means absent now17762eee67cbdc214a9eabd4025d973baceabf26b35dconLineconsumer settles the run with its error instead of hanging the awaiting operation;runCommand's doc block returns to its function4011973c3getVolumeInfo's doc states its real three-way contract — the code was left alone deliberately, three callers read.lengthunguarded andnullwould throw wherefalsedoes notbd8749e88Round three — six commits (servicing the 2026-08-24 13:36Z review)
de30f9f6aspawn, whose own spawn failure had noerrorlistener and took the process down; it now goes through the wrapped command runner, which catches ite1a906c9fgetVolumeInforeturns{ error, mounts }— one shape instead of three. All six callers migrated; the two destructive restore paths refuse distinctly onmountErrorvs an empty mount list, and the backup path gained the guard it never hadbea8f7aa0finallyreached by success, unauthorized and error alikeaaa59c036foldersToWrite, and the reconciliation loop is narrowed to what was actually writtenb65da19f510121b9a1TypeErrordeep in the flowRound four — three commits (servicing the 2026-08-24 17:54Z review)
3c8a24bd0bea8f7aa0made the busy-list getters hand out frozen snapshots, andserviceManagercaptured them once at boot intomasterSlaveApps— a function that re-invokes itself from its ownfinallyforever, re-passing the same empty photograph. Theg:election's backup/restore guard could never fire again. All three state projections are now gone from the signature and read offglobalStateat the point of each decision0e6b8ab8aSetthe end-of-pass reconciliation then mutates, so one name meant two things mid-pass — across a module boundary, sinceappQueryServicereads the published set and the mount-safety gate reads the local one. It publishes a copy now6026e4b01runStreamingCommand's idle message could overwrite a real error. A real error now wins the error slot, a kill-provoked exit stays idle-reported rather than surfacing as "exited with code 143", and the idle kill travels as its own flag instead of competing for the slotWhy that guard is load-bearing. A restore stops the app's containers — and "installed
g:app, nobody running it" is exactly the state that makes the election want to promote a new primary.requestMasterStartWithPermissionsFixthen does real work inline before the reconciler backstop ever sees the intent: claim, demote the syncthing folder toreceiveonly, then a recursivechmodover the app directory — against a live restore's extraction. So the guard is exercised precisely when it matters, and the constraint it rests on is now stated at the signature: the busy-list getters return snapshots, so anything captured at call time is a photograph. They are read at the point of each decision.Tests
Unit: 5,515 passing, 18 pending, 0 failing on the current head. Every fix has a mutation that kills its test and nothing else.
Harness: the full gate is green on this branch — 85 suites, run on a dedicated box.
Nine harness suites, six of them new, covering both ends of the incident:
348687888990remoteandupload— the branch the incident actually took919293Suite 89 is the first thing to boot a real syncthing daemon in this harness (
createTestEnv({ syncthing: 'binary' }), pinned to v2.0.15, the version the fleet runs). It exists because the stub moves no files: a green 86–88 proves nothing was destroyed, not that anything arrived.Suite 93 covers the other end of the incident. The archive that destroyed the world was 373 bytes, taken from an instance that had never synced; the gate that refuses to make such an archive had unit tests and no end-to-end coverage at all — suite 44 drives a backup, but tests the lease. It asserts the incident's own shape, a partial copy, the
forceoverride, and, because the gate deliberately runs before anything is stopped, that a refusal leaves the app running.Harness additions
--status-fd, not gpg's exit code: the release carries a second signature from a retired key, sogpg --verifyexits 2 on a perfectly good release.SYNCTHING_PATHnow means what FluxOS means by it. It was set on every harness node including the legacy ones, so FluxOS's own supervision path had never been exercised by any suite.buildSeedableLegacyAppemits a v≤3 spec.seed-helperbuilt v8 and only v8, so no suite could produce an app that takes the legacy branch.Notes for review
g:component is paced back up after a backup or restore on this branch, and that is deliberate. Neither task writes desired state, so the reconciler is what restarts the component it stopped — butrecordRestarthere counts every restart, so the task's own stop earns a rung on the backoff ladder and the restart waits it out. The ladder resets when the previous run provably lastedSTABLE_RUN_MS, so a long-running app is unaffected; back-to-back operations are not. Reconciler: the only actuator for every operator command, and a ladder that paces faults not operators #1780 is where a rung is earned by evidence of a fault rather than by any stop.forceis API-only by design. The UI never checks HTTP status, so a refusal renders as a green success toast and the caption truncates at 50 characters; refusal messages lead with the verdict for that reason. The UI can never sendforce, which is the right shape for a destructive override.forcea wrongly-blocked restore would be a dead end. The numbers are logged. What stops a bad archive existing is the backup gate.buildSeedableLegacyAppand the artifact store are additive — no existing suite changes behaviour because of them.