desktop: localStorage has no time-based cleanup; 9 unbounded call sites bypass quota facade
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## Problem
Wes raised: localStorage cleanup appears restart-driven; a desktop client left open for days may grow storage until writes fail. Audit of all 58 raw `localStorage.setItem` call sites in `desktop/src` at `119a84897` confirms the concrete gaps.
**Current protections (already good):**
- `shared/lib/localStorageQuota.ts` — pure snapshot caches (channel messages, sidebar, skeletons, user labels) share a 2 MiB budget with write-time LRU eviction (runs on every write, not just restart), plus quota-exceeded recovery (evict disposables, retry once) and a startup probe-repair pass (`main.tsx:124`).
- `shared/lib/safeStorage.ts` (#5078) — throw-safe reads/writes, so a full store degrades to in-memory + toast rather than crashing.
**Gaps:**
1. All 58 feature call sites write raw — none route through `setLocalStorageItemWithRecovery`; bounds are per-file convention, not enforced.
2. Nine call sites are UNBOUNDED (no size/count/age bound):
- `features/communities/communityIconCache.ts:37` — per-relay icon data URLs, no cap/TTL, failures swallowed
- `features/channels/forcedUnreadStore.ts:123` — per-channel forced-unread entries; per-entry clear only
- `features/messages/lib/persistentAgentAudience.ts:76` — per-scope (channel/thread) agent audiences; reset only on feature disable
- `features/profile/lib/selfProfileStorage.ts:148` — per relay×pubkey profile caches (avatar data URLs ≤256 KB each); GC only on community removal
- `features/sidebar/lib/channelMutesStorage.ts:72` — `muted:false` tombstones never deleted
- `features/sidebar/lib/channelStarsStorage.ts:72` — `starred:false` tombstones never deleted
- `features/sidebar/lib/channelSectionsStorage.ts:162` — per user×relay section assignments, no cap
- `features/sidebar/lib/channelSortPreference.ts:108` — per-section sort prefs; orphans stripped but no cap
- `shared/features/store.ts:30,37` — feature overrides per feature ID
3. Zero time-based cleanup anywhere: eviction is size/count-pressure only. Stale per-pubkey/per-relay/per-channel keys (old identities, removed channels) persist forever, and a session open for weeks only trims the pure-cache prefixes.
**Failure mode today:** not a crash — silent persistence loss (read positions/prefs stop saving) once the ~5 MB WKWebView origin quota fills, plus one toast.
## Industry standard (Slack/Discord shape)
- localStorage for small prefs/flags only (KBs); message/profile/media caches in IndexedDB (async, ~100s of MB, browser-evictable under storage pressure).
- TTL + LRU with a periodic sweep (startup + interval/visibilitychange) so a long-running client converges to the same state as one restarted nightly.
- A single storage facade owning budgets, TTLs, and versioned keys for every write.
## Plan
**Phase 1 — contain (localStorage, small diffs):**
1. Cap `communityIconCache` (count cap + drop oversized data URLs) and bound the other 8 unbounded sites (tombstone deletion for mutes/stars, count caps or LRU where entries carry `updatedAt`).
2. Add a periodic stale-entry sweep (startup + `visibilitychange`/interval) for TTL-able namespaces: self-profile caches, forced-unread, agent audiences, per-identity keys not touched in N days.
3. Route feature writes through `setLocalStorageItemWithRecovery`/`safeStorage` so quota failure handling is uniform.
**Phase 2 — re-tier (larger):**
4. Move pure snapshot caches (channel messages, sidebar, skeletons, profile caches) to IndexedDB; leave localStorage as prefs-only. Removes the 2 MiB-vs-5 MB pressure entirely.
## Evidence
- Audit thread: channel `time-based-localstorage-eviction`, root `0d85a73ca43e54748128f89c3512a4726131bf5473253395d46bf8f3a7b58bd4` (Morty's full 58-site table, spot-checked).
- Nest notes: `RESEARCH/LOCALSTORAGE_EVICTION_CURRENT_STATE.md`.
Contributor guide
Research direction
Read RESEARCH/LOCALSTORAGE_EVICTION_CURRENT_STATE.md and review shared/lib/localStorageQuota.ts, shared/lib/safeStorage.ts, and main.tsx:124 first. Then inspect the nine unbounded call sites listed in the issue and the audit evidence. Done means the planned localStorage bounds, stale-entry sweep, and unified recovery path are implemented without the quota-filling persistence loss described here.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- desktop
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 42/100