Skip to content

Recover shielded cache and cancel stale synchronization - #109

Merged
HDauven merged 8 commits into
mainfrom
fix/shielded-sync-recovery
Sep 15, 2026
Merged

HDauven merged 8 commits into
mainfrom
fix/shielded-sync-recovery

Conversation

@HDauven

@HDauven HDauven commented Sep 7, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #106, fixes #107, fixes #108.

  • Anchor each profile's shielded cache to a block hash; rebuild invalidated/legacy caches from genesis. Report a behind/unverifiable node rather than silently accepting an invalid cursor.
  • Commit notes and matching cursor/anchor together; clear invalidated data atomically while preserving pending reservations. Metadata initialization now rechecks absence inside its write transaction so it cannot overwrite a concurrently committed checkpoint.
  • Clear spent-only legacy caches even when metadata is missing or the cursor is zero. Without an anchor, reconciliation must not restore orphaned spent notes under a newly valid checkpoint; pending reservations remain preserved.
  • Persist the next note position. Stop only after the consumer commits its target chunk—not when producer prefetch reports it. This fixes a reproducible incomplete-scan regression under slower RPC responses.
  • Surface reconciliation and interrupted-stream failures, with retryable progress.
  • Cancel stale preflight/stream/cache work and suppress abandoned unlock metadata errors/notifications after session or sync changes.

Published SDK compatibility

The published @dusk/w3sper 1.7.0-rc.0 drops upstream read failures. Its reader is already fixed upstream but no newer SDK version is published. Backport that upstream module verbatim, retaining its MPL notice, through the existing Vite SDK integration for Chrome, Firefox and Tauri. The hook is scoped to the installed SDK file, checks the package version, and excludes SDK dev prebundling. Remove the backport when upgrading to a release containing the fix. No new dependencies, protocol/WASM changes, disabled validation, or larger chunk-size default.

Validation

Current rebased candidate: 4772b5a

Rebased the recovery work and spent-only-cache follow-up onto main 1f05366, retaining commit author identities, author dates, messages and trailers; original history is backed up locally. Only the changelog conflicted, and both sets of entries were retained.

  • Fresh npm ci --no-audit --no-fund, then 720 tests / 60 files passed with configured utility-subset coverage thresholds.
  • 11 typed-data approval renderer checks passed through the repository's development-server harness.
  • Chrome and Firefox production builds and the Tauri frontend build passed.
  • A further publication-time focused run passed 77 tests / 4 files (engine, store, stream backport and build configuration).
  • Eight permanent spent-only-cache cases cover missing/zero metadata × ordinary/forced sync × replacement/empty chains. They assert canonical spendable contents, no restored orphan, retained pending reservations, cursor/anchor state and a later reconciliation.
  • On the rebased source without the predicate fix, all eight cases failed on orphan resurrection; restoring the fix passed all eight. A separate pre-rebase mutation removing pending preservation failed all eight on reservation loss.
  • Executed PR CI 35021294436 passed 720 tests / 60 files, 11 renderer checks, and Chrome/Firefox builds. Both jobs used current-base test merge 9948212, whose tree equals this candidate. The existing non-gating Codecov upload still failed for a missing token (Token required because branch is protected), despite its successful action-step conclusion.

The engine/store regressions use the actual repository functions with mocked W3sper/network and fake-indexeddb. Renderer checks and frontend builds do not establish native-browser cache recovery, real extension signing, genuine chain scanning, proof generation, consensus-induced reorg behavior or native-desktop execution. The spent-only finding is conditional on the seeded persisted state, not a claim that every wallet naturally reaches it or that an invalid note could be accepted on-chain.

Earlier validation — before this rebase and spent-only follow-up

These results describe the original recovery branch through fbae2ed, against the older 8906af8 baseline. They were not rerun on 4772b5a and are not current-candidate E2E evidence.

  • 615 tests passed, including prefetch ordering, late metadata failures after lock/profile/network/new sync, concurrent checkpoint creation, stream error/EOF/concurrency/cancellation, build-hook wiring, and real Vite development version-query requests.
  • Chrome, Firefox and Tauri production builds passed. Firefox/Tauri runtime coverage is not claimed.
  • Original benchmark: 48 repeated production-Chromium restore/warm-sync pairs, crossing 12/24 words and 8/4096 owned notes in 10k/100k-note genesis trees.
  • Updated production build passes the formerly failing 100k-note scan with 500 ms added HTTP response delay; complete cursor/count/balance and warm sync verified.
  • Cutting a genuine local note response now produces a visible error (no unhandled runtime exceptions); forced retry restores the exact balance.
  • Delayed genuine unlock metadata completion after UI lock no longer republishes status.
  • Native Chromium IndexedDB concurrency control: baseline loses the checkpoint, updated implementation preserves it in all ten cases.
  • Updated build passes UI lock/profile switching during real partial scans and actual local-node shutdown/restart/retry.
  • Two real local Phoenix transfers (small/large 12-word senders to 24-word recipients) pass proof generation, node execution, exact fees/change/balances and receipt. After an offline node snapshot rollback, all four wallets recover their original balances/counts. This is administrative branch replacement, not a consensus-induced reorg.
  • Earlier isolated, unfunded Testnet browser checks remain separate. No public-network funding or broadcasts were performed.
  • Inherited unlock-input loss remains separately tracked in Preserve unlock password input across passive rerenders #110; lifecycle tests reopen/settle the locked view before typing.

Historical performance and limits

The following measurements use the earlier recovery branch and 8906af8 baseline, not the rebased candidate.

At 100k notes the original three-run medians were approximately 74–79s for this PR versus 145–154s on reviewed main. This is the normal two-profile configuration: the PR scans the selected profile, while main checks both yet does not advance the other profile's cursor. It is a workflow improvement, not a 2× faster ownership algorithm. Results do not establish mainnet-scale, cross-device or cross-browser performance.

Per-chunk header checks remain. SDK prefetch overlaps them with ownership work; removing validation showed no clear benefit at 0/100 ms added delay. Full-rescan recovery and waiting for a behind node have real availability costs. Anchors are node-provided consistency checks, not independent chain verification. Already committed transactions cannot be undone by cancellation.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

There are verified runtime-breaking bugs in the updated sync/store code paths (undefined controller references in sync cancellation handling, and a possible null object-spread in putNotesMap() when writing sync meta).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR hardens shielded synchronization by anchoring cached scan state to a verifiable chain hash, rebuilding invalid/legacy caches on reorgs, and preventing stale async sync work from writing status/cache after lock/profile/network changes (issues #106#108).

Changes:

  • Anchors shielded cache metadata to a (blockHeight, blockHash) and rebuilds caches from genesis when the anchor is invalid or unverifiable.
  • Commits discovered notes and their cursor/anchor atomically in IndexedDB, and propagates a shared AbortController through preflight/reconciliation/streaming to cancel stale work.
  • Adds/updates tests covering reconciliation failure surfacing, rollback/reorg recovery, atomic cache commits, and cancellation semantics.
File summaries
File Description
src/shared/walletEngine.js Adds chain-anchored cache validation, shared abort/cancellation, and revised sync/reconciliation flow.
src/shared/walletEngine.test.js Expands test coverage for rollback, reconciliation failures, and cancellation/epoch-guard behavior.
src/shared/shieldedStore.js Adds abort-signal guards, atomic clear operations, and transactional note+meta commits.
src/shared/shieldedStore.test.js Adds tests for transactional note+meta commits and aborted-write behavior.
src/build.worker-config.test.js Updates expectation to match the refactored createAddressSyncer(network) usage.
CHANGELOG.md Documents the fixes for #106#108.
Review details

Suppressed comments (2)

src/shared/walletEngine.js:2326

  • controller.abort() is referenced inside the ReadableStream stale-epoch handling, but the local controller was removed when switching to the shared signal. This will throw a ReferenceError whenever isStale() becomes true.

This issue also appears on line 2366 of the same file.

              if (isStale()) {
                try {
                  controller.abort();
                } catch {}
                try {

src/shared/walletEngine.js:2369

  • In the async-iterator scan path, the stale-epoch branch still calls controller.abort(), but the local controller was removed in this refactor. That will throw a ReferenceError when isStale() becomes true.
            }
            await processChunk(value);

            if (shouldStop) break;
  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +235 to +249
if (!entries.length && !syncMeta) return 0;
const previousMeta = syncMeta ? await getShieldedMeta(networkKey, walletId, profileIndex) : null;
signal?.throwIfAborted();

// Discovered notes must never be committed without their matching chain anchor.
await new Promise((resolve, reject) => {
const tx = db.transaction([STORE_NOTES], "readwrite");
const tx = db.transaction(syncMeta ? [STORE_NOTES, STORE_META] : [STORE_NOTES], "readwrite");
tx.oncomplete = () => resolve(true);
tx.onerror = () => reject(tx.error || new Error("Failed to write notes"));

if (syncMeta) {
tx.objectStore(STORE_META).put({
...previousMeta, ...syncMeta, ownerKey: ok, networkKey: String(networkKey),
walletId: String(walletId || ""), profileIndex: Number(profileIndex), updatedAt: Date.now(),
});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed the abort-only transaction issue: aborting after the write request succeeds reproduced a hanging promise. Write transactions now handle onabort as well as onerror; the regression passes. The null-spread claim is a false positive: { ...null } is valid JavaScript. That expression is unchanged, and an explicit first-notes/no-existing-metadata regression and the Chrome full-rebuild check pass. Also removed the stale local-controller references mentioned in the review summary; their ReferenceErrors were caught, and shared invalidation already aborts the operation. Reader cleanup now lives in finally, with a blocked-reader cancellation test. Full suite: 601 passing.

Comment thread src/shared/walletEngine.test.js Outdated
Comment on lines +485 to +490
console.log("rollback probe", {
force, tip: "9", status: engine.getShieldedStatus().state,
cursor: engine.getShieldedStatus().cursorBookmark,
scannedFrom: notes.mock.calls.map(([, options]) => options.from.asUint().toString()),
cachedNotes: (await store.getNotesMap(NETWORK_KEY, WALLET_ID, 0)).size,
});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Removed the probe logging and strengthened this test to verify the lagging-node error, no scan, and preservation of both the cached note and persisted cursor.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

IndexedDB write transactions in shieldedStore.js are not aborted when the sync AbortSignal is cancelled, so stale cache clears/writes can still commit after lock/profile/network changes.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

src/shared/shieldedStore.js:244

  • putNotesMap() writes notes+meta in an IndexedDB transaction, but aborting the passed AbortSignal after the transaction starts will not stop the pending IDB requests. That means a lock/profile/network change can still commit stale cache writes after cancellation. Hook the signal to tx.abort() and clean up the listener on completion/error so the transaction is reliably cancelled.
  await new Promise((resolve, reject) => {
    const tx = db.transaction(syncMeta ? [STORE_NOTES, STORE_META] : [STORE_NOTES], "readwrite");
    tx.oncomplete = () => resolve(true);
    tx.onerror = tx.onabort = () => reject(tx.error || new Error("Failed to write notes"));

  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread src/shared/shieldedStore.js
Comment thread src/shared/shieldedStore.js
@HDauven

HDauven commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Follow-up review addressed in f744217: cancellation now calls native IDBTransaction.abort() for every signal-bearing sync write/clear and removes listeners on completion/abort. A regression cancels after an individual write request succeeds but before the transaction commits; it failed before the fix and now confirms notes and anchor both roll back. Transactions that have already committed cannot be undone by AbortSignal. Full suite: 602 passing.

@HDauven
HDauven requested a lite review from Copilot September 7, 2026 03:50
@HDauven
HDauven marked this pull request as ready for review September 7, 2026 03:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

It makes substantial changes to shielded synchronization, caching, and cancellation semantics where a final human review should validate the safety/performance tradeoffs and edge cases.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

src/shared/walletEngine.js:2295

  • assertAnchor() performs a network.query(...) round-trip and is invoked for every notes chunk (await assertAnchor() inside processChunk). If w3sper emits many small chunks, this becomes an RPC-per-chunk pattern and can materially slow sync / stress the node. Consider reducing validation frequency (e.g., validate once before scanning and once after, or at a coarser interval / on detected inconsistencies) while still ensuring you never commit notes under an unverified anchor.
  • Files reviewed: 6/6 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@HDauven

HDauven commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Final advisory review: the per-chunk header round trip is an intentional safety-first tradeoff, now documented in f406bd5. The real Testnet full-scan check is not a large-chain throughput benchmark. Batching verified commits is the upgrade path if latency dominates; this still needs human review before merge.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The Vite load() hook in w3sperStreamCompat() uses strict id equality that can miss real Vite ids (e.g., query-suffixed/normalized paths), which can prevent the backport from being applied.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 10/12 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread vite.local-w3sper.js
Comment on lines +14 to +16
load(id) {
if (id !== target) return null;
const pkg = JSON.parse(readFileSync(path.join(path.dirname(entry), "../package.json"), "utf8"));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed the version-query case through the real Vite development transform pipeline: it returned the original reader before this fix. The loader now normalizes path separators and accepts version/HMR/import queries while leaving raw, URL and other asset requests alone. The regression passes; all 615 tests and Chrome/Firefox/Tauri builds pass. Emitted production JS bundles remain byte-identical to the already browser-tested build.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

It changes core shielded-sync correctness, persistence, and build-time SDK patching in ways that warrant careful final human review despite strong test coverage.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

vite.local-w3sper.js:26

  • w3sperStreamCompat().load() does synchronous disk IO and JSON parsing on every module load (reads package.json and the backported source each time). In Vite dev/HMR this hook can be hit repeatedly, so this adds avoidable overhead; the version check and backport source can be read once when the plugin is constructed and then reused.
  • Files reviewed: 10/12 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

An empty unspent store and zero cursor do not establish that a legacy cache is empty. Reset both note stores before recovery so reconciliation cannot restore orphaned spent notes under a new anchor, while preserving pending reservations.

Cover missing and zero-cursor metadata, ordinary and forced sync, empty and replacement chains, and subsequent reconciliation.

Refs #106, #109.
@HDauven
HDauven force-pushed the fix/shielded-sync-recovery branch from fbae2ed to 4772b5a Compare September 15, 2026 20:42
@HDauven
HDauven merged commit 0d8bcf7 into main Sep 15, 2026
2 checks passed
@HDauven
HDauven deleted the fix/shielded-sync-recovery branch September 15, 2026 21:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants