Skip to content

Fix synced queue wipe replication and preserve dirty file session buffers#372

Merged
arul28 merged 2 commits into
mainfrom
cursor/critical-correctness-bugs-0cf0
May 28, 2026
Merged

Fix synced queue wipe replication and preserve dirty file session buffers#372
arul28 merged 2 commits into
mainfrom
cursor/critical-correctness-bugs-0cf0

Conversation

@cursor
Copy link
Copy Markdown
Contributor

@cursor cursor Bot commented May 27, 2026

Bug and impact

Synced PR queue wipe deleted in-flight queue state on peers that had not upgraded
The stacked-PR queue_landing_state overhaul migration ran DELETE while CRR triggers were active, so upgrading Mac A replicated DELETE tombstones to Mac B before B ran the migration. B could lose an active merge-queue landing state without upgrading.

Files tab silently dropped large unsaved buffers on project/lane reentry
Session caching excluded dirty tabs over 8 MiB when snapshotting scope changes. Switching tabs/lanes and returning restored a session without those tabs; replaceDirtyBuffersForWorkspace then cleared the in-memory dirty map, so unsaved edits were lost with no prompt.

Root cause

  • Queue wipe lived inside migrate() while cr-sqlite was loaded and queue_landing_state was already CRR-registered from prior opens.
  • cacheableSessionTabs() applied the 8 MiB cap to dirty tabs for memory bounds, but session restore treated the filtered list as authoritative.

Fix

  • Strip CRR metadata, delete queue_landing_state locally, then re-register CRR in ensureCrrTables (wipe runs after migrations, before CRR marking).
  • Always retain dirty tabs in the Files session snapshot; size limits apply only to clean tabs.

Validation

  • npx vitest run src/main/services/state/kvDb.migrations.test.ts (pass)
  • kvDb.sync.test.ts adds regression coverage when cr-sqlite is available (macOS CI)

Does not overlap open draft PRs #363#371 (CRR retrofit, orchestration, App Control routing).

Open in Web View Automation 

ADE   Open in ADE  ·  cursor/critical-correctness-bugs-0cf0 branch  ·  PR #372

Greptile Summary

This PR fixes two correctness bugs: a CRR replication hazard where the queue_landing_state wipe ran inside migrate() while cr-sqlite triggers were active (causing DELETE tombstones to propagate to unupgraded peers), and a session-cache bug where dirty tabs over 8 MiB were silently dropped when switching lane scopes.

  • Sync fix: The queue wipe is moved out of migrate() into a new wipeQueueLandingStateForStackedOverhaulIfNeeded function that runs post-migration and pre-ensureCrrTables, manually strips CRR metadata before deleting rows, and filters the upgrade marker key from both CRDT export and import on desktop and iOS.
  • Files fix: cacheableSessionTabs now always retains dirty tabs regardless of size; the 8 MiB cap is removed entirely, while the 256 KB cap for clean tabs remains unchanged. Both fixes ship with direct regression tests covering the failure scenarios described in the PR.

Confidence Score: 4/5

The sync fix is well-reasoned and the ordering of wipe-before-CRR-registration is correct; both platforms filter the marker from export and block it on import. The dirty-tab change removes the only memory guard on per-session dirty content, which is safe for correctness but leaves renderer memory consumption unbounded when many lanes hold large unsaved files simultaneously.

Both core fixes address real data-loss or data-corruption scenarios and are accompanied by targeted regression tests. The behavioral changes are narrow and the cross-platform symmetry (desktop TypeScript and iOS Swift both implement the same wipe/filter logic) is a good sign. The open questions are around memory growth with unbounded dirty tabs and a minor edge case in CRR metadata cleanup, neither of which causes incorrect behavior on the fixed path.

FilesPage.tsx deserves a second look on the memory side — removing all size bounds on dirty-tab caching combined with the existing never-evict-dirty-sessions guard could let the renderer accumulate significant memory in heavy multi-lane workflows.

Important Files Changed

Filename Overview
apps/desktop/src/main/services/state/kvDb.ts Moves queue wipe from migrate() to a post-migration step before ensureCrrTables, adds CRR-safe local delete helper, and filters the wipe marker from CRDT import/export. Ordering and filtering logic is sound; minor edge case in deleteAllRowsWithoutCrrReplication where crsql_master-only state is not cleaned.
apps/desktop/src/renderer/components/files/FilesPage.tsx Removes 8 MiB cap on cached dirty tabs so unsaved buffers are never silently dropped when switching lane scopes. The fix correctly solves the data-loss bug but removes all memory bounds on dirty content; combined with the never-evict-dirty-sessions guard this could grow renderer memory without bound.
apps/ios/ADE/Services/Database.swift Mirrors the desktop queue-wipe logic: adds deleteAllRowsWithoutCrrReplication, wipeQueueLandingStateForStackedOverhaulIfNeeded, and filters the marker from CRDT export/import. Uses shouldCaptureLocalChanges=false to guard the marker insert; removes the bare DELETE from DatabaseBootstrap.sql.
apps/desktop/src/main/services/state/kvDb.sync.test.ts Adds comprehensive regression test: seeds a queue entry, replicates to peer, triggers the wipe on the upgrading node, and asserts neither queue deletes nor the marker propagate to the peer. Also validates that inbound marker changes from remote are blocked.
apps/ios/ADETests/ADETests.swift Adds testDatabaseTreatsQueueWipeMarkerAsLocalOnlySyncState covering export filtering and inbound-apply blocking; uses SQL string interpolation in a test helper (marker is a constant, but the pattern is slightly inconsistent with the rest of the test suite).
apps/desktop/src/renderer/components/files/FilesPage.test.tsx Adds a test that creates an oversized (>8 MiB) dirty buffer, switches lanes and back, and asserts the content is fully preserved. Covers the regression directly.
apps/ios/ADE/Resources/DatabaseBootstrap.sql Removes the bare DELETE from the bootstrap SQL; wipe is now handled programmatically by wipeQueueLandingStateForStackedOverhaulIfNeeded after bootstrap runs.
docs/features/files-and-editor/file-watcher-and-trust.md Adds a short paragraph documenting the per-scope session cache size limits (clean-only cap, dirty tabs always retained). Accurate.
docs/features/sync-and-multi-device/crdt-model.md Documents the queue_landing_state wipe marker being filtered from CRDT import/export on both platforms. Accurate.

Sequence Diagram

sequenceDiagram
    participant A as Mac A (upgraded)
    participant Host as Sync Host
    participant B as Mac B (not yet upgraded)

    Note over A: openKvDb() on upgrade
    A->>A: migrate()
    A->>A: removeExcludedCrrMetadata()
    A->>A: wipeQueueLandingStateForStackedOverhaulIfNeeded()
    Note over A: strips CRR triggers BEFORE delete
    A->>A: DELETE queue_landing_state (local only)
    A->>A: INSERT kv marker (filtered from export)
    A->>A: ensureCrrTables()

    A->>Host: exportChangesSince(version) marker+queue deletes filtered out
    Host->>B: applyChanges(filtered set)
    Note over B: queue_landing_state rows intact
    B->>B: (later) openKvDb() on own upgrade
    B->>B: wipeQueueLandingStateForStackedOverhaulIfNeeded()
    Note over B: wipes its own rows locally
Loading

Comments Outside Diff (1)

  1. apps/desktop/src/renderer/components/files/FilesPage.tsx, line 248-258 (link)

    P2 Unbounded dirty-tab memory in session cache

    With the size cap removed, cacheableSessionTabs retains dirty buffers of any size. The eviction guard in setFilesPageSession (line 277) already refuses to evict sessions with unsaved tabs, so a user with dirty buffers across all eight cached scopes will push the renderer process above whatever total in-memory content those buffers hold — with no upper bound. This was the exact tradeoff the old 8 MiB limit addressed. Consider logging a warning when a dirty tab exceeds the old threshold so the renderer team can monitor real-world usage, or capping how many over-limit dirty tabs per scope are retained (e.g. keep the most-recently-written N).

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src/renderer/components/files/FilesPage.tsx
    Line: 248-258
    
    Comment:
    **Unbounded dirty-tab memory in session cache**
    
    With the size cap removed, `cacheableSessionTabs` retains dirty buffers of any size. The eviction guard in `setFilesPageSession` (line 277) already refuses to evict sessions with unsaved tabs, so a user with dirty buffers across all eight cached scopes will push the renderer process above whatever total in-memory content those buffers hold — with no upper bound. This was the exact tradeoff the old 8 MiB limit addressed. Consider logging a warning when a dirty tab exceeds the old threshold so the renderer team can monitor real-world usage, or capping how many over-limit dirty tabs per scope are retained (e.g. keep the most-recently-written N).
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Cursor Fix in Codex Fix in Claude Code

Fix All in Cursor Fix All in Codex Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
apps/desktop/src/renderer/components/files/FilesPage.tsx:248-258
**Unbounded dirty-tab memory in session cache**

With the size cap removed, `cacheableSessionTabs` retains dirty buffers of any size. The eviction guard in `setFilesPageSession` (line 277) already refuses to evict sessions with unsaved tabs, so a user with dirty buffers across all eight cached scopes will push the renderer process above whatever total in-memory content those buffers hold — with no upper bound. This was the exact tradeoff the old 8 MiB limit addressed. Consider logging a warning when a dirty tab exceeds the old threshold so the renderer team can monitor real-world usage, or capping how many over-limit dirty tabs per scope are retained (e.g. keep the most-recently-written N).

### Issue 2 of 3
apps/ios/ADETests/ADETests.swift:2972-2974
**SQL string interpolation in test helper**

`"\(marker)"` is interpolated directly into the SQL string. `marker` is a compile-time constant today so there is no injection risk, but the pattern could be copied into helpers that use dynamic values. Binding the key via `executeSqlForTesting` with a parameter slot (or using the existing `kvValue` helper to read back and a separate raw-SQL exec to write) would keep the test consistent with the parameterized style used everywhere else in this test suite.

### Issue 3 of 3
apps/desktop/src/main/services/state/kvDb.ts:524-551
**`crsql_master`/`crsql_changes` cleanup gated behind shadow-table presence**

`deleteAllRowsWithoutCrrReplication` enters the CRR-cleanup branch only when a shadow table (`__crsql_clock` / `__crsql_pks`) or a CRR trigger is found. If a previous partial initialization left entries only in `crsql_master` (no shadow tables, no triggers), those entries survive the wipe and `queue_landing_state` rows deleted afterward could still be associated with stale master metadata. The sibling `removeExcludedCrrMetadata` function checks `hasMasterRows` independently — the same independent pre-check would make this function fully symmetric with it. Very unlikely in practice, but worth aligning for correctness.

Reviews (1): Last reviewed commit: "Harden local-only queue wipe sync" | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
ade Ignored Ignored Preview May 28, 2026 7:56am

@arul28
Copy link
Copy Markdown
Owner

arul28 commented May 28, 2026

@copilot review but do not make fixes

@arul28 arul28 force-pushed the cursor/critical-correctness-bugs-0cf0 branch from 08f4177 to 020bdfd Compare May 28, 2026 07:23
@arul28
Copy link
Copy Markdown
Owner

arul28 commented May 28, 2026

@copilot review but do not make fixes

Copy link
Copy Markdown
Contributor

Copilot AI commented May 28, 2026

@copilot review but do not make fixes

Reviewed the last two commits without making fixes.

Findings: no blocking issues found in the desktop/iOS queue-wipe + Files session-buffer changes.

Validation run:

  • npm --prefix apps/desktop run test -- src/main/services/state/kvDb.sync.test.ts src/renderer/components/files/FilesPage.test.tsx
  • Result: FilesPage.test.tsx passed; kvDb.sync.test.ts was skipped in this Linux sandbox because cr-sqlite is unavailable (describe.skipIf(!isCrsqliteAvailable())).

@arul28
Copy link
Copy Markdown
Owner

arul28 commented May 28, 2026

@copilot review but do not make fixes

cursoragent and others added 2 commits May 28, 2026 03:55
…fers

The stacked-PR queue overhaul migration deleted queue_landing_state while
CRR triggers were active, replicating DELETE tombstones to peers that had
not upgraded yet. Strip CRR metadata before the one-shot local wipe and run
it after migrations but before ensureCrrTables re-registers replication.

Files tab session caching dropped unsaved buffers over 8 MiB when switching
project/lane scope. Always retain dirty tabs in the session snapshot so
large in-flight edits are not lost silently on tab reentry.

Co-authored-by: Arul Sharma <arul28@users.noreply.github.com>
@arul28 arul28 force-pushed the cursor/critical-correctness-bugs-0cf0 branch from b6d218a to 73fd643 Compare May 28, 2026 07:56
@arul28
Copy link
Copy Markdown
Owner

arul28 commented May 28, 2026

@copilot review but do not make fixes

@arul28 arul28 marked this pull request as ready for review May 28, 2026 08:17
@arul28 arul28 merged commit b8ec8aa into main May 28, 2026
28 of 29 checks passed
Copy link
Copy Markdown
Contributor Author

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

PR Review

Scope: 9 file(s), +446 / −53
Verdict: Minor issues

This PR stops the stacked-PR queue overhaul wipe from replicating over CRDT (local-only delete + filtered kv marker on desktop and iOS) and keeps unsaved Files tab buffers when switching lane scopes by dropping the dirty-tab size cap. The sync changes are well tested; the only notable tradeoff is unbounded in-memory retention for very large dirty edits.


⚡ Performance

[Low] Dirty session cache no longer caps oversized unsaved buffers

File: apps/desktop/src/renderer/components/files/FilesPage.tsx:L248-L257
Issue: cacheableSessionTabs now keeps every dirty tab regardless of size; only clean text tabs remain capped at 256 KiB. Scope eviction still respects unsaved tabs (MAX_FILES_PAGE_CACHED_SCOPES = 8), so several multi‑MiB dirty buffers can stay in the renderer at once.
Impact: Lane switching with multiple large unsaved files (the new test uses ~8 MiB + 1 char) can increase memory use and GC pressure compared with the previous 8 MiB dirty cap.
Fix: If needed, keep “never drop dirty edits” but bound cost—e.g. a higher dirty ceiling, spill oversized dirty content to dirtyWorkspaceBuffers, or retain only dirty metadata in the session cache and reload from the spill store on scope return.


Notes

  • Desktop and iOS both run the queue wipe after bootstrap/migrations and before (re)enabling CRR, filter the marker on export/apply, and have targeted tests—good alignment for a local-only migration.
  • Mixed-version rollout: upgraded peers no longer replicate wipe tombstones; peers still on an older build can still push existing queue_landing_state rows until they upgrade—consistent with protecting not-yet-upgraded devices from a one-shot delete.
  • Removing delete from queue_landing_state from iOS DatabaseBootstrap.sql avoids a bootstrap-time wipe that could have been captured for sync on first open.
Open in Web View Automation 

Sent by Cursor Automation: BUGBOT in Versic

Comment on lines +2972 to +2974
dbVersion: versionBeforeApply + 1,
siteId: "ffffffffffffffffffffffffffffffff",
cl: 1,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 SQL string interpolation in test helper

"\(marker)" is interpolated directly into the SQL string. marker is a compile-time constant today so there is no injection risk, but the pattern could be copied into helpers that use dynamic values. Binding the key via executeSqlForTesting with a parameter slot (or using the existing kvValue helper to read back and a separate raw-SQL exec to write) would keep the test consistent with the parameterized style used everywhere else in this test suite.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/ios/ADETests/ADETests.swift
Line: 2972-2974

Comment:
**SQL string interpolation in test helper**

`"\(marker)"` is interpolated directly into the SQL string. `marker` is a compile-time constant today so there is no injection risk, but the pattern could be copied into helpers that use dynamic values. Binding the key via `executeSqlForTesting` with a parameter slot (or using the existing `kvValue` helper to read back and a separate raw-SQL exec to write) would keep the test consistent with the parameterized style used everywhere else in this test suite.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Cursor Fix in Codex Fix in Claude Code

Comment on lines +524 to +551
/** Strip CRR triggers/metadata so row deletes stay local (no replicated tombstones). */
function deleteAllRowsWithoutCrrReplication(
db: DatabaseSyncType,
tableName: string,
logger?: Logger,
): void {
if (!rawHasTable(db, tableName)) return;

const clockTableName = `${tableName}__crsql_clock`;
const pksTableName = `${tableName}__crsql_pks`;
if (rawHasTable(db, clockTableName) || rawHasTable(db, pksTableName) || listCrrTriggers(db, tableName).length > 0) {
if (rawHasTable(db, "crsql_master") && rawHasColumn(db, "crsql_master", "tbl_name")) {
runStatement(db, "delete from crsql_master where tbl_name = ?", [tableName]);
}
if (rawHasTable(db, "crsql_changes") && rawHasColumn(db, "crsql_changes", "table")) {
runStatement(db, "delete from crsql_changes where [table] = ?", [tableName]);
}
try {
getRow(db, "select crsql_as_table(?) as ok", [tableName]);
} catch {
// Table may not be registered enough for crsql_as_table; shadow cleanup below still applies.
}
dropCrrTriggers(db, tableName, logger);
runStatement(db, `drop table if exists ${quoteIdentifier(clockTableName)}`);
runStatement(db, `drop table if exists ${quoteIdentifier(pksTableName)}`);
}

runStatement(db, `delete from ${quoteIdentifier(tableName)}`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 crsql_master/crsql_changes cleanup gated behind shadow-table presence

deleteAllRowsWithoutCrrReplication enters the CRR-cleanup branch only when a shadow table (__crsql_clock / __crsql_pks) or a CRR trigger is found. If a previous partial initialization left entries only in crsql_master (no shadow tables, no triggers), those entries survive the wipe and queue_landing_state rows deleted afterward could still be associated with stale master metadata. The sibling removeExcludedCrrMetadata function checks hasMasterRows independently — the same independent pre-check would make this function fully symmetric with it. Very unlikely in practice, but worth aligning for correctness.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/services/state/kvDb.ts
Line: 524-551

Comment:
**`crsql_master`/`crsql_changes` cleanup gated behind shadow-table presence**

`deleteAllRowsWithoutCrrReplication` enters the CRR-cleanup branch only when a shadow table (`__crsql_clock` / `__crsql_pks`) or a CRR trigger is found. If a previous partial initialization left entries only in `crsql_master` (no shadow tables, no triggers), those entries survive the wipe and `queue_landing_state` rows deleted afterward could still be associated with stale master metadata. The sibling `removeExcludedCrrMetadata` function checks `hasMasterRows` independently — the same independent pre-check would make this function fully symmetric with it. Very unlikely in practice, but worth aligning for correctness.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Codex Fix in Claude Code

@arul28 arul28 deleted the cursor/critical-correctness-bugs-0cf0 branch May 28, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants