fix(backup): short-retry ACL denials and abort on a lost VSS snapshot (#3259, #3260) - #3266
fix(backup): short-retry ACL denials and abort on a lost VSS snapshot (#3259, #3260)#3266ToddHebebrand wants to merge 4 commits into
Conversation
…#3259, #3260) Two coupled backup defects found during v0.104.0 release QA on a real Windows Server 2022 agent. The first wastes time; the second turns that wasted time into total data loss. #3259 — plain ERROR_ACCESS_DENIED burned ~30s per file. #3002/#2997 taught the per-file upload retry to fast-skip permanently failing files, but deliberately excluded plain ERROR_ACCESS_DENIED (5) on the grounds that it can be a transient AV/indexer hold. So the single most common cause of an unreadable file — an ordinary NTFS ACL — paid the full 30s backoff. Measured +27-29s per denied file. The reported first choice was to probe the ACL (re-open with FILE_READ_ATTRIBUTES) and classify permanent denials as skip-immediately. Not taken: FILE_READ_ATTRIBUTES and FILE_READ_DATA are distinct rights, and a failed probe still cannot prove the original denial came from the DACL rather than an AV/EDR minifilter. There is no discriminator that cannot produce a false "permanent" verdict, and a false permanent verdict silently drops a healthy file from a backup — the very failure class of #3260. Took the reported option 2 instead. classifyPermanentUploadError (bool) becomes classifyUploadFailure, which returns an uploadRetryPolicy: skipWithoutRetry / retryAfterShortDelay / retryAfterDefaultDelay. A source-attributed permission denial keeps exactly one retry — the thing that actually recovers a transient hold — but on a 1s backoff instead of 30s. Everything unrecognised still gets the full backoff, so a misclassification costs wall-clock, never a file. Matched both via the Win32 table (ERROR_ACCESS_DENIED, for the precise symbolic name in logs) and portably via fs.ErrPermission (EACCES/EPERM). The table is consulted first so ERROR_SHARING_VIOLATION — which Go also maps to fs.ErrPermission — keeps its skipWithoutRetry. #3260 — those stalls outlived the VSS shadow copy and lost everything. With 15 denied files of 40, the accumulated backoff ran ~390s, the shadow copy stopped resolving, and files 14-40 — every one readable — failed with ERROR_PATH_NOT_FOUND. Because PATH_NOT_FOUND is in the fast-skip set, the run drained its remaining file list at memory speed and recorded all of it as per-file failures: 0 of 40 files backed up, status=failed, an error log that blamed the files. Adds a source-liveness probe (source_liveness.go) over the shadow-copy device roots the run reads from, consulted only after a per-file upload has already failed — before the retry sleep and again after it, since the retry is where the reported ACCESS_DENIED -> PATH_NOT_FOUND flip happened. A root that has gone away aborts the run with an explicit error naming the root and the progress counts, instead of condemning every remaining file. Three properties keep the abort from becoming its own false-positive bug: - Self-calibrating: each root is stat'd at construction and only roots that resolved then are watched, so the probe can never fail on a path form os.Stat could not resolve in the first place. - Confirmed: a root must be missing on two looks, separated by a short delay, before a run dies. - Per-volume: the probe is scoped to the failing file's own root, so losing volume D never aborts files still being read from volume C. Only a not-exist answer counts; any other stat error is inconclusive and treated as alive. The abort mirrors abortStopped rather than publishing a partial manifest: with a journal, the remote prefix plus the journal remain this run's resume state, so the uploaded bytes survive. Publishing a manifest would mint a snapshot that looks complete while silently missing every file the loop never reached — a restore point that lies. Not done here, deliberately: no cumulative retry wall-clock budget. A VSS_CTX_BACKUP shadow copy has no TTL, so age does not predict validity and any fixed budget would be an arbitrary guess. Secondary (API) — malformed-payload errors named no field. All three "Malformed backup result payload" sites joined only issue.message and dropped the Zod path, so the reported "Invalid input: expected object, received null" was unactionable. Adds describeZodIssues to lib/zodIssues.ts (reusing the existing flattening, which also unwraps invalid_union noise) and uses it at all three. An empty path renders as <root> rather than being omitted — that is the distinction that makes "the payload is null" legible. Fixes #3259 Fixes #3260 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploying breeze with
|
| Latest commit: |
dfb890e
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://245841a7.breeze-9te.pages.dev |
| Branch Preview URL: | https://toddhebebrand-fix-3259-3260.breeze-9te.pages.dev |
…h boundary Review follow-ups on #3259/#3260, plus the CI rename guard that correctly caught this PR. Silent-failure review raised two ways the new #3260 guard could be off without anyone knowing: - VSS active but no root watchable. newShadowRootLiveness returned nil with only per-root Debug lines, so a run with the guard entirely disabled looked exactly like a run with the guard armed and nothing wrong. Now warns once, naming how many roots were offered. - A root that stats with a persistent non-not-exist error was treated as alive and logged at Debug forever, leaving the guard unable to decide anything about that root for the rest of the run. shadowRootMissing becomes three-way (alive / gone / inconclusive) and the caller warns once per root per run instead of swallowing it. Neither changes the abort decision — only a confirmed not-exist still aborts. They change what an operator can see. Separately, matchShadowRoot matched a bare string prefix. Shadow-copy device paths are numbered, so `...ShadowCopy2` is a prefix of `...ShadowCopy26`: a file rooted under an UNWATCHED ShadowCopy26 would have been checked against ShadowCopy2's root, and losing that unrelated volume would abort a healthy run. Now requires a separator (or exact match) after the root. Longest-first ordering already covered the case where both roots are watched; this covers the case where the more specific one is not. CI: the Windows backup step pins test names in its -run filter and asserts an exact PASS count, precisely so a rename cannot quietly drop coverage. The classifier rename tripped it. Updated the three names (TestWindowsUploadErrnoConstantsMatchWin32, TestClassifyUploadFailure, TestLookupWindowsUploadErrno); the expected count is unchanged at 14 because the rename was one-for-one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The confirmation stat in the shadow-root liveness probe discarded its error, so a second look that neither confirmed nor cleared the root was logged as "briefly failed to resolve but came back". That is a message that misdescribes what happened — the exact complaint #3260 raises about the API-side malformed-payload error. Report the inconclusive case on its own terms. The abort decision is unchanged: only a confirmed not-exist aborts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mapping comments Test-coverage review found the second `sourceLiveness` probe — the one after the retry — was not actually pinned by anything: disabling only it left the whole package green, because the existing regression test kills the snapshot synchronously with the first attempt, so the pre-retry probe always got there first. That is the probe guarding the exact tell #3260 reported: a file whose error flipped ACCESS_DENIED -> PATH_NOT_FOUND between the first attempt and the retry. Adds dyingOnRetryProvider, which denies the first attempt while the snapshot is healthy and kills it while serving the retry, with every later file uploading cleanly so no other failure can re-trigger the pre-retry probe. Verified non-vacuous: with only the post-retry probe removed the new test FAILS (and the pre-existing one still passes), restored it passes. Also corrects a factually wrong claim I wrote in two comments. They said Go maps both ERROR_ACCESS_DENIED and ERROR_SHARING_VIOLATION to fs.ErrPermission, so table-before-portable ordering was what kept a sharing violation on skipWithoutRetry. Checked the stdlib (syscall/syscall_windows.go, Errno.Is): only ERROR_ACCESS_DENIED, EACCES and EPERM map to fs.ErrPermission — ERROR_SHARING_VIOLATION does not, and has no portable fallback at all. The ordering still matters, but for a different reason (the precise symbolic reason string support reads), and the sharing/lock/cloud rows guard against the table being dropped entirely rather than against a reorder. Comments now say what is true. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Review run: Findings: 5 raised → all 5 addressed across
Also corrected a factually wrong comment I had written: only Tests: CI: 54 pass / 0 fail / 1 skipping (55 total). Status: review-clean, CI green, awaiting maintainer merge. Two things flagged for the maintainer rather than fixed here, both argued in the PR body:
|
Fixes #3259
Fixes #3260
Two coupled backup defects from v0.104.0 release QA on a real Windows Server 2022 agent. The first wastes wall-clock; the second turns that wasted wall-clock into total data loss. Same files, one PR.
#3259 — plain
ERROR_ACCESS_DENIEDburned ~30s per file#3002/#2997 taught the per-file upload retry to fast-skip permanently-failing files, but deliberately excluded plain
ERROR_ACCESS_DENIED(5) because it "can be a transient AV/indexer hold". So the single most common cause of an unreadable file — an ordinary NTFS ACL — paid the full 30s backoff. Measured +27-29s per denied file.The ACL probe (the issue's first choice) was NOT taken.
FILE_READ_ATTRIBUTESandFILE_READ_DATAare distinct rights, and a failed probe still cannot prove the original denial came from the DACL rather than an AV/EDR minifilter. There is no discriminator that cannot produce a false "permanent" verdict — and a false permanent verdict silently drops a healthy file from a backup, which is precisely the failure class of #3260. Took the issue's option 2 instead.classifyPermanentUploadError(bool) becomesclassifyUploadFailure, returning anuploadRetryPolicy:skipWithoutRetryretryAfterShortDelayretryAfterDefaultDelayThe retry itself — the thing that actually recovers a transient hold — is preserved; only the wait shrinks. Anything unrecognised still gets the full backoff, so a misclassification costs wall-clock, never a file.
Matched two ways: via the Win32 table (
ERROR_ACCESS_DENIED, so logs carry the precise symbolic name) and portably viafs.ErrPermission(EACCES/EPERM on Unix). The table is consulted first soERROR_SHARING_VIOLATION— which Go also maps tofs.ErrPermission— keeps itsskipWithoutRetry. A Windows-only test pins that ordering.#3260 — the stalls outlived the shadow copy and lost everything
With 15 denied files of 40, the accumulated backoff ran ~390s, the shadow copy stopped resolving, and files 14-40 — every one readable — failed with
ERROR_PATH_NOT_FOUND. Because PATH_NOT_FOUND is in the fast-skip set, the run drained its remaining file list at memory speed and recorded all of it as per-file failures: 0 of 40 backed up,status=failed, an error log that blamed the files.New
agent/internal/backup/source_liveness.goadds a probe over the shadow-copy device roots the run reads from, consulted only after a per-file upload has already failed — before the retry sleep, and again after it, since the retry is exactly where the reportedACCESS_DENIED -> PATH_NOT_FOUNDflip happened. A root that has gone away aborts the run with an explicit error naming the root and the progress counts, instead of condemning every remaining file.Three properties keep the abort from becoming its own false-positive bug:
os.Statcould not resolve in the first place (\\?\GLOBALROOT\...is an unusual shape, and a probe that assumed it stats cleanly would abort every healthy backup if that assumption were wrong).Only a not-exist answer counts; any other stat error is inconclusive and treated as alive.
Abort semantics: mirrors
abortStopped, does not publish a partial manifest. With a journal, the remote prefix plus the journal remain this run's resume state, so the already-uploaded bytes survive and the next run picks them up. Publishing a manifest for the partial set would mint a snapshot that looks complete while silently missing every file the loop never reached — a restore point that lies. Flagging this as the one design call worth a second opinion.No cumulative retry wall-clock budget, deliberately. A
VSS_CTX_BACKUPshadow copy has no TTL, so age does not predict validity and any fixed budget would be an arbitrary guess. The liveness probe is the real defense.Secondary (API) — malformed-payload errors named no field
All three
Malformed backup result payloadsites (jobs/backupWorker.ts,routes/agentWs.ts,services/commandResultHandlers.ts) joined onlyissue.messageand dropped the Zodpath, so the reportedInvalid input: expected object, received nullwas unactionable.Adds
describeZodIssuestoapps/api/src/lib/zodIssues.ts— reusing the existing flattening, which also unwrapsinvalid_unionnoise — and uses it at all three sites rather than adding a fourth ad-hoc.map(i => ...). An empty path renders as<root>rather than being omitted: that is the distinction that makes "the whole payload was null" legible instead of ambiguous.Verification
cd agent && go test -race -count=1 ./internal/backup/...cd agent && go test -race -count=1 ./...GOOS=windows GOARCH=amd64 go build ./...GOOS=windows GOARCH=amd64 go vet ./internal/backup/...unsafe.Pointernotes in untouchedvss_windows.go)GOOS=linux GOARCH=amd64 go build ./...pnpm exec vitest run --no-file-parallelism src/lib/zodIssues.test.ts src/jobs/backupWorker.test.ts src/routes/agentWs.test.ts src/routes/agentWs.terminalResultSchema.test.tspnpm exec tsc --noEmit -p tsconfig.jsonNew coverage: the retry-policy table (incl. the ordering guarantee), the short-vs-long backoff split, the liveness probe's self-calibration / confirmation / per-volume scoping / inconclusive-error handling, an end-to-end "shadow copy dies at file 3 of 6" regression asserting the run stops at 3 attempts rather than walking a dead snapshot, and
<root>vs named-path rendering.Residual risk — likely true root cause of #3260 is NOT fixed here
vss.CreateShadowCopyreleasesIVssBackupComponentsbefore returning (agent/internal/backup/vss/vss_windows.go, thedefer callVtable(backupComponents, vtblRelease)), with a comment asserting Windows only reclaims a non-persistentVSS_CTX_BACKUPcopy on process exit. Microsoft documents the opposite: releasing that object deletes auto-release shadow copies. The existing live test only reads through the device shortly after Release, so it would not catch a delayed reclaim — and a delayed reclaim fits the observed ~390s disappearance at least as well as diff-area exhaustion.This PR does not attempt that fix. Keeping the requester alive on a dedicated locked COM thread across scan + upload + manifest, with
BackupComplete/AbortBackup, is a lifetime rewrite of the vss package — Windows-only, untestable from here, and already tracked as a follow-up on #2999. #3260 names the explicit abort as the required minimum and snapshot refresh as stretch, so this PR delivers the abort and the liveness probe (which is still needed regardless — diff-area exhaustion and external deletion are real). Worth its own issue.Also worth noting: the outer queue schema (
backupProcessResultSchema) guaranteesdata.resultis a non-null object beforeprocessResultsruns, so the reported root-level "received null" most likely arose from a nested null. Either way the new message now names whatever it was.🤖 Generated with Claude Code
Review round (3 agents: code-reviewer, silent-failure-hunter, pr-test-analyzer)
5 findings raised, all addressed. code-reviewer found no correctness bugs. The other two found real gaps:
nilwith only Debug lines — the #3260 guard could be entirely OFF and look identical to "armed, nothing wrong"shadowRootMissingis now three-way (alive / gone / inconclusive); caller warns once per root per runmatchShadowRootused a barestrings.HasPrefix—...ShadowCopy2prefixes...ShadowCopy26, so a file under an unwatched ShadowCopy26 would be checked against ShadowCopy2's root and could abort a healthy runTestMatchShadowRoot_MatchesOnPathBoundaryNotBarePrefixTestCreateSnapshot_ShadowCopyLostDuringRetry_AbortsExplicitly, proven non-vacuousNone of these changed the abort decision — only a confirmed not-exist still aborts. They changed what an operator can see, and closed one genuine false-abort vector (#3 hardening).
A comment of mine was factually wrong and is corrected. I had written that Go maps both
ERROR_ACCESS_DENIEDandERROR_SHARING_VIOLATIONtofs.ErrPermission, making table-before-portable ordering the thing that keeps a sharing violation onskipWithoutRetry. Checked the stdlib (syscall/syscall_windows.go,Errno.Is): onlyERROR_ACCESS_DENIED/EACCES/EPERMmap tofs.ErrPermission.ERROR_SHARING_VIOLATIONdoes not — it has no portable fallback at all. Ordering still matters (for the precise symbolic reason string support reads), and the sharing/lock/cloud rows guard against the table being dropped entirely. Comments now say what is true.CI caught the rename, as designed. The
Test Agent (Windows)step pins test names in its-runfilter and asserts an exact PASS count precisely so a rename can't quietly drop coverage. The classifier rename tripped it; three names updated, count unchanged at 14 (the rename was one-for-one).Known remaining gap (not fixed here)
The manager-level wiring in
RunBackupContext—sourceLiveness = newShadowRootLiveness(vssSession.ShadowPaths)and its threading intocreateSnapshotWithProgress— has no test. Every liveness test calls the leaf functions directly. A refactor that dropped those two lines would pass CI and only resurface as a #3260 recurrence in production. Covering it needs thevss.Providerto become injectable intoRunBackupContext(today it's constructed inline viavss.NewProvider, and the whole branch isruntime.GOOS == "windows"-gated so it never executes in CI's Linux/macOS runs). That's a testability refactor of the manager, deliberately out of scope here — worth its own issue alongside the requester-lifetime one.