Skip to content

fix(desktop): guard localStorage collections against quota-exceeded rollback loops - #6038

Merged
Kitenite merged 1 commit into
mainfrom
fix/quota-guard-rework
Jul 29, 2026
Merged

Kitenite merged 1 commit into
mainfrom
fix/quota-guard-rework

Conversation

@Kitenite

@Kitenite Kitenite commented Jul 29, 2026 •

Copy link
Copy Markdown
Member

Reworked from #5965 by @snehendu098 — thank you for the excellent diagnosis and the withQuotaGuard design, both kept here. Refs #5496 (fixes the freeze mechanism; the growth contributors are tracked separately in SUPER-1686). Opened as a new PR because the original fork doesn't allow maintainer edits and the branch needed reworking on top of #6036.

Problem (from #5965)

When localStorage is full, @tanstack/db's localStorageCollectionOptions re-serializes the whole collection on every mutation and rethrows QuotaExceededError. The library rolls the optimistic write back → the live query re-emits → the effect that queued the write retries — a synchronous ~40x/s loop that pegs the renderer at >110% CPU and survives restarts because the full store is on disk.

Fix

withQuotaGuard (unchanged from #5965) wraps every localStorage-backed collection's storage: on quota error it reclaims space, retries once, then returns without throwing. No throw → no rollback → the loop cannot form at any of the write sites. Non-quota errors still rethrow.

Changes from #5965

  • Reclaim victim selection: driven by the terminal GC's durable terminal-buffer-persisted-at index from fix(desktop): garbage-collect persisted terminal scrollback in localStorage #6036 (24h pressure TTL, unstamped included) instead of registry membership + an in-memory preservedSnapshotTerminalIds set. The in-memory set reset on relaunch, so a quota event shortly after boot could delete restorable snapshots of terminals in not-yet-opened workspaces. This also deletes selectOrphanedTerminalSnapshots, terminalSnapshotStorage, and the two registry additions outright (−4 files, −36 registry lines).
  • Key prefixes stay canonical in terminal-buffer-gc.ts — the shared/constants.ts copies are dropped.
  • QUOTA_NOTICE_MODE ships as offer-reclaim instead of silent: genuine exhaustion surfaces a toast with a working "Free up space" action (clears all persisted scrollback via clearAllTerminalState) rather than a console-only warning the user never sees.

Testing

  • withQuotaGuard tests kept verbatim from fix(renderer): the infinite loop due to which the app ui stays frozen #5965 (quota absorb, retry-once, non-quota rethrow, reclaim-0 skip).
  • notifyQuotaExhausted tests kept (warn dedupe, mode branches, toast id reuse).
  • New terminal-buffer-gc tests for reclaimTerminalStateForQuota (24h TTL, fresh kept, key counts, index hygiene — mutation-checked) and clearAllTerminalState.
  • 350 tests pass across the touched dirs; bun run typecheck --filter=desktop and bun run lint clean.
  • CDP verification against the merged result to follow on this PR.

Open in Stage

Summary by cubic

Prevents renderer-freezing retry loops when localStorage is full by guarding all @tanstack/react-db collections: on quota errors we reclaim space, retry once, then drop the write instead of throwing. Refs SUPER-1686 (fixes the freeze mechanism; growth contributors tracked separately).

  • Bug Fixes

    • Added withQuotaGuard to all localStorage-backed collections; non-quota errors still throw, quota errors reclaim once and return without throwing.
    • Eliminates the rollback/retry loop and keeps changes in memory for the session instead of pegging CPU.
  • New Features

    • Quota reclaim is driven by the terminal GC’s durable terminal-buffer-persisted-at index with a 24h pressure TTL via reclaimTerminalStateForQuota (plus clearAllTerminalState).
    • User notice now uses offer-reclaim: deduped warning and a toast with “Free up space” to clear saved terminal scrollback.

Written for commit b2a9e17. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added automatic recovery when local storage reaches its limit, helping preserve app responsiveness.
    • Added user notifications when storage is full, including an option to free space by clearing saved terminal history.
    • Added cleanup of older saved terminal snapshots to reclaim storage space.
  • Bug Fixes

    • Prevented storage-limit errors from interrupting local state updates.
    • Preserved in-memory changes when persistence temporarily fails.

…ollback loops

Reworked from PR #5965 by @snehendu098 (refs #5496). When localStorage
is full, @tanstack/db's localStorageCollectionOptions rethrows
QuotaExceededError on every write; the library rolls the optimistic
write back, the live query re-emits, the queuing effect retries -
an infinite loop (~40x/s) that pegs the renderer and survives restarts.

withQuotaGuard wraps every localStorage-backed collection's storage so
setItem absorbs quota failures: reclaim space, retry once, then drop
the write instead of throwing. No throw means no rollback, so the loop
cannot form at any write site.

Changes from the original PR:
- Reclaim is driven by the terminal GC's persisted-at index (24h
  pressure TTL, terminal-buffer-gc.ts) instead of registry membership
  plus an in-memory preserved-set that reset on relaunch and could
  delete restorable snapshots of not-yet-opened workspaces.
- Key prefixes stay canonical in terminal-buffer-gc.ts; the
  shared/constants.ts copies and the registry additions
  (getRegisteredTerminalIds, preservedSnapshotTerminalIds) are dropped.
- QUOTA_NOTICE_MODE ships as offer-reclaim: genuine exhaustion surfaces
  a toast with a working "Free up space" action instead of a silent
  console warning.

Co-authored-by: snehendu098 <snehendu098@users.noreply.github.com>
@ghost

ghost commented Jul 29, 2026

Copy link
Copy Markdown

Ready to review this PR? Stage has broken it down into 4 individual chapters for you:

Title
1 Implement terminal state reclamation utilities
2 Create quota guard for storage writes
3 Add quota exhaustion notifications and UI
4 Wire quota guard into collection provider
Open in Stage

Chapters generated by Stage for commit b2a9e17 on Jul 29, 2026 3:24am UTC.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds terminal snapshot quota reclamation, a guarded localStorage write path with soft quota failures, and user notifications with optional reclaim actions. Multiple desktop collections now use the quota guard, with tests covering reclamation, retries, notifications, and persistence behavior.

Changes

LocalStorage quota recovery

Layer / File(s) Summary
Terminal snapshot reclamation
apps/desktop/src/renderer/lib/terminal/terminal-buffer-gc.ts, apps/desktop/src/renderer/lib/terminal/terminal-buffer-gc.test.ts
Adds quota-based and full terminal snapshot clearing helpers, removal counts, shared terminal ID collection, and coverage for fresh, expired, unstamped, and unrelated storage entries.
Quota-guarded storage writes
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/withQuotaGuard.ts, apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/withQuotaGuard.test.ts
Detects browser quota errors, reclaims space, retries writes once when possible, suppresses persistent quota failures, and rethrows non-quota errors.
Quota exhaustion notifications
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/notifyQuotaExhausted.ts, apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/notifyQuotaExhausted.test.ts
Adds silent, notify, and offer-reclaim modes with deduplicated warnings, stable toast IDs, reclaim actions, and cleared-entry descriptions.
Collection integration
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts
Applies the quota guard to sidebar, workspace, terminal preset, preference, and failed-workspace localStorage collections.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Collection as LocalStorage collection
  participant Guard as withQuotaGuard
  participant GC as reclaimTerminalStateForQuota
  participant Notice as notifyQuotaExhausted
  Collection->>Guard: Attempt setItem
  Guard->>GC: Reclaim persisted terminal snapshots
  GC-->>Guard: Return removed-key count
  Guard->>Collection: Retry setItem
  Guard->>Notice: Report failed persistence
  Notice-->>Collection: Show quota notification
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: guarding localStorage collections against quota-exceeded rollback loops.
Description check ✅ Passed The description covers the problem, fix, changes, and testing, though it does not follow the template headings or checklist exactly.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/quota-guard-rework

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/withQuotaGuard.ts (1)

61-78: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider guarding reclaim()/onPersistFailed() against throwing.

The module's entire purpose is "no throw means no rollback." Today reclaim (reclaimTerminalStateForQuota) and onPersistFailed (notifyQuotaExhausted) happen to swallow their own errors, but nothing here enforces that — if either handler throws in the future, the exception propagates straight out of setItem and reinstates the exact rollback/retry loop this file was built to eliminate.

🛡️ Proposed defensive wrapping
 		setItem: (key, value) => {
 			try {
 				base.setItem(key, value);
 				return;
 			} catch (error) {
 				if (!isQuotaExceeded(error)) throw error;
-				if (reclaim() === 0) {
-					onPersistFailed(key, error);
-					return;
-				}
+				let reclaimed = 0;
+				try {
+					reclaimed = reclaim();
+				} catch {}
+				if (reclaimed === 0) {
+					try {
+						onPersistFailed(key, error);
+					} catch {}
+					return;
+				}
 			}
 			try {
 				base.setItem(key, value);
 			} catch (retryError) {
 				if (!isQuotaExceeded(retryError)) throw retryError;
-				onPersistFailed(key, retryError);
+				try {
+					onPersistFailed(key, retryError);
+				} catch {}
 			}
 		},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/withQuotaGuard.ts`
around lines 61 - 78, Update the setItem method’s reclaim and onPersistFailed
handling so exceptions from either callback are caught and do not escape
setItem, preserving the no-throw behavior during both initial quota recovery and
retry failure paths. Keep non-quota errors from base.setItem propagating as they
do now.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/withQuotaGuard.ts`:
- Around line 61-78: Update the setItem method’s reclaim and onPersistFailed
handling so exceptions from either callback are caught and do not escape
setItem, preserving the no-throw behavior during both initial quota recovery and
retry failure paths. Keep non-quota errors from base.setItem propagating as they
do now.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9f4a8995-bcbe-417c-ad9b-a7257ede0120

📥 Commits

Reviewing files that changed from the base of the PR and between 1db8663 and b2a9e17.

📒 Files selected for processing (7)
  • apps/desktop/src/renderer/lib/terminal/terminal-buffer-gc.test.ts
  • apps/desktop/src/renderer/lib/terminal/terminal-buffer-gc.ts
  • apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts
  • apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/notifyQuotaExhausted.test.ts
  • apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/notifyQuotaExhausted.ts
  • apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/withQuotaGuard.test.ts
  • apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/withQuotaGuard.ts

@Kitenite
Kitenite merged commit ee756c9 into main Jul 29, 2026
11 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ⚠️ Neon database branch

Thank you for your contribution! 🎉

@Kitenite
Kitenite deleted the fix/quota-guard-rework branch July 29, 2026 03:40
@Kitenite

Copy link
Copy Markdown
Member Author

Post-merge CDP verification (merged main @ ee756c9, dev app from this worktree)

Ran against the real dev instance (Electron owner pid verified for this worktree, Vite :8025, CDP :9557), driving the real getCollections() guarded storage — not a test double.

Terminal GC sanity (from #6036, re-checked on merged main): 40 seeded orphaned terminal-buffer:*/terminal-dims:* keys → 0 after one reload.

Quota guard (this PR): filled localStorage to the actual ceiling (~100 MB on Electron 40 — higher again than the ~49 MB measured in #5965) and removed/refilled the org collection key's slot (the #5496 freeze repro):

Observation Result
Reload into the exhausted store boot completes, evals alive, macrotask RTT p50 5.0 ms (pre-fix: renderer pegged >110% CPU, CDP evals timed out)
v2SidebarSections.insert() on the full store no throw, row stays in memory, disk write dropped (blob stays 0 bytes) — no rollback loop (RTT p50 4.2 ms during)
Guard telemetry deduped console warning fired; "Storage is full" toast with "Free up space" action rendered (offer-reclaim mode live)
Recovery filler removed → reload → same insert persists to disk (row id found in the blob); test row cleaned up

Caveats, stated plainly: the toast's action button presence was verified but its click wasn't exercised live (toast auto-dismissed between steps; the handler clearAllTerminalState is unit-tested). The insert was driven through the real guarded collection via CDP rather than a UI click — the dev instance's sign-in is currently 500ing server-side (environmental, reproduces on empty storage and pre-dates these changes), so an authenticated UI journey wasn't available. The before-state (freeze loop) was reproduced on unguarded main in the 2026-07-28 session that motivated #5965's verification, not re-reproduced today.

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.

1 participant