Skip to content

Storage load guard - #158

Merged
dantheuber merged 13 commits into
mainfrom
freight/feature-storage-load-guard
Sep 6, 2026
Merged

Storage load guard#158
dantheuber merged 13 commits into
mainfrom
freight/feature-storage-load-guard

Conversation

@dantheuber

@dantheuber dantheuber commented Sep 2, 2026

Copy link
Copy Markdown
Owner

This branch assembles the listed issues for review.

Issues in this consist

Closes #156

What & why

Fixes a startup race where the renderer could write its blank seed clip list over the encrypted clip history before the initial load finished, permanently deleting the images that history referenced. Saves are now blocked until storage has confirmed a successful load, and a failed load now surfaces persistently instead of disappearing after a few seconds.

Changes

  • Main: SecureStorage records a load error when the clips file can't be decrypted, encryption is unavailable, or the background load throws. getLoadState() exposes {complete, failed, error}, and saveClips() refuses to run until the load has completed without error. storage-get-clips now returns clips together with the load state they were read under, taken in one step, so a reader can't mistake the placeholder shown during a load for an empty history — this replaces the separate storage-get-load-state channel and the ordering rule callers previously had to follow.
  • Shared: the load error is now a single StorageLoadError value carrying both the message and a recoverable flag, so the two fields can't disagree across the IPC boundary. Only a clips-decrypt failure is treated as non-recoverable; an unclassified failure is assumed to clear on restart.
  • Renderer: useClipsStorage reads load state and clips together, keeps isInitiallyLoading set until a successful load has been applied, and on a failed load shows a persistent banner (instead of a toast) with guidance text and disables saves for the session. The storage-ready event still triggers a reload. The clips list has its own snapshot API (getClipsSnapshot) so getClips continues to mean just the clips.
  • Docs: README notes documenting the save guard behavior.

Review notes

  • Test status: full suite passing (npm run typecheck && npm run test).
  • Worth a close look: the ordering guarantee between the load-state read and the clips read in both SecureStorage (main) and useClipsStorage (renderer) — this is what closes the original race.
  • Coverage: src/renderer/src/providers/clips/storage.test.tsx (empty-before-complete race, failed-load/thrown-read paths, state-before-clips ordering) and src/main/storage/index.test.ts (load state transitions, saveClips guard, recoverable vs. non-recoverable errors).
  • Version bumped to 2.2.3 (patch) — a dependency-fix PR landed on main at the same 2.2.2 version this branch had already claimed, so the bump was reapplied one patch level up.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

📊 Coverage Report

Metric Coverage
Statements 94.16%
Branches 93.67%
Functions 95.02%
Lines 94.26%

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

🧪 E2E Test Results

36 passed, 0 failed, 0 skipped

Test Suite Result
App Launch
Clipboard
Context Menu
Image Clipboard
Quick Clips
Quick look
Quick look — clipboard writes
Quick look — rendered html
Settings window
Theme
Settings — Tools tab
Quick look — pattern scanning
Quick look — clip templates

cb-jeeves and others added 3 commits September 2, 2026 08:07
…ccessfully

Closes the startup race from #156 where the renderer could write its blank
seed list over the encrypted clip history and delete the images it
referenced.

Main: SecureStorage records a load error when the clips file cannot be
decrypted, when encryption is unavailable, or when the background load
throws; getLoadState() exposes {complete, failed, error}; saveClips()
refuses to run until the load has completed without error. Exposed via the
storage-get-load-state IPC channel and preload storageGetLoadState().

Renderer: useClipsStorage asks for the load state before reading clips (so
a load finishing in between cannot be mistaken for an empty history), keeps
isInitiallyLoading set until a successful load has been applied, and on a
failed load shows a toast once and leaves saves disabled. The storage-ready
event still triggers the reload.

Tests: src/renderer/src/providers/clips/storage.test.tsx covers the
empty-before-complete race, the failed-load and thrown-read paths, and the
state-before-clips ordering; src/main/storage/index.test.ts covers the load
state transitions and the saveClips guard.

Notes: the OKF brain MCP server named in CLAUDE.md was not reachable from
this environment, so no concepts were read or updated.
@cb-jeeves
cb-jeeves marked this pull request as ready for review September 2, 2026 08:15
@cb-jeeves cb-jeeves added the Jeeves-Review Jeeves: request a skeptical review of this issue label Sep 2, 2026

@cb-jeeves cb-jeeves left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This closes the startup race for real: the main process refuses saveClips until the history has loaded without error, and the renderer asks for load state before it reads clips, so the blank placeholder can no longer be written over the encrypted file. The mechanism is sound and the tests cover the paths that matter. What remains is one user-facing gap (a failed load leaves saving off for the whole session with only a 12-second toast to say so) and a few small tidy-ups.

One non-inline note: importData still calls saveClipsData directly and bypasses the new guard, so a backup import during a failed load overwrites the unread history file. That is arguably what an import means, so I'd leave it, but it is worth knowing the guard is not universal.

The correctness lens failed this round and contributed nothing.

The correctness pass did not complete this round.

Comment thread src/renderer/src/providers/clips/storage.ts Outdated
Comment thread README.md Outdated
Comment thread src/renderer/src/providers/clips/storage.ts Outdated
Comment thread src/shared/types.ts Outdated
Comment thread src/main/storage/index.test.ts Outdated
Comment thread src/renderer/src/providers/clips/storage.ts Outdated
…hand clips out with their load state

The failed-load toast expired after 12 seconds, so a user whose keystore
changed saw only an empty list while saving stayed off for the session.
The list now shows a banner above the rows for as long as the load error
stands, carrying the same two lines plus the error itself.

storage-get-clips now returns the clips together with the load state
they were read under, taken in one step in the main process, so a reader
cannot mistake the placeholder served during the load for an empty
history. This removes the separate storage-get-load-state channel and
the ordering rule that callers had to follow.

StorageLoadState carries a single nullable error instead of a failed
flag that had to agree with it. The storage tests import a fresh
instance per test instead of resetting private fields by name.
@cb-jeeves

Copy link
Copy Markdown
Collaborator

Responded to review round 1, pushed 56fffa7:

  • Addressed persistent failed-load signal (storage.ts): the hook returns loadError, the clips provider exposes it on the meta context, and the list renders a banner above the rows with the paused-saving lines plus the error for as long as it is set. The toast is gone.
  • Addressed README wording: it now says a banner tells you saving is paused so the stored history is not overwritten.
  • Addressed load-state ordering (storage.ts): storage-get-clips returns { loadState, clips } read in one step in the main process. The separate storage-get-load-state channel and the ordering comment are gone.
  • Addressed failed field (types.ts): StorageLoadState is { complete, error: string | null } and getLoadState() is a single literal.
  • Addressed private-field reset (index.test.ts): each test re-imports a fresh instance after vi.resetModules().
  • Addressed unused exports (storage.ts): the two constants moved into Clips.tsx beside the banner and are private.
  • Declined importData bypassing the guard (body note): left as is. An import is an explicit request to replace the stored history, and I agree it should not be blocked by a failed load.

Typecheck, lint and the full test suite pass.

@cb-jeeves cb-jeeves left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since round 1, commit 56fffa7 answers every earlier thread: the toast is now a persistent banner above the list, storage-get-clips returns the clips with the load state they were read under so the ordering rule is gone, StorageLoadState is down to a single nullable error, the storage tests get a fresh instance per test, and the README says what the UI does. The race fix is sound and none of what follows blocks merging. What is left is a few small tidy-ups on the new banner and the renamed API surface.

The correctness lens failed this round and contributed nothing.

The correctness pass did not complete this round.

Comment thread src/renderer/src/components/clips/Clips.tsx
Comment thread src/renderer/src/components/clips/Clips.tsx Outdated
Comment thread src/renderer/src/components/clips/Clips.tsx
Comment thread src/main/clipboard/storage-integration.ts Outdated
Comment thread src/renderer/src/providers/clips/storage.ts Outdated
…d tidy the banner and snapshot API

The load state now carries `recoverable`, true only when encryption was unavailable, so the
banner tells a keystore-mismatch user that the history cannot be read under this keystore
instead of sending them into a restart loop. The guidance lines use the banner's normal
text and only the error line is mono and muted. Clips has its own flex-column root, so the
App stylesheet change is reverted. The snapshot API is named getClipsSnapshot at every
layer so getClips keeps meaning just the clips. errorText moved to a shared renderer util
and the storage hook uses it.
@cb-jeeves

Copy link
Copy Markdown
Collaborator

Responded to review round 2, pushed 227b3e0:

  • Addressed banner text styles (Clips.tsx): the guidance lines use the banner's normal text and only the error line is mono and muted, via a new .loadFailedError class.
  • Addressed restart advice for a keystore mismatch (Clips.tsx): StorageLoadState now carries recoverable, true only when encryption was unavailable. The banner suggests a restart in that case and otherwise says the history can't be read with this computer's keystore. A thrown IPC read counts as recoverable.
  • Addressed Clips depending on .mainContent (Clips.tsx): Clips has its own .clips flex-column root and the App.module.css change is reverted.
  • Addressed getClips meaning two shapes (storage-integration.ts): renamed to getClipsSnapshot, storage-get-clips-snapshot and storageGetClipsSnapshot through main, preload and renderer, with the mocks updated.
  • Addressed duplicate errorText (providers/clips/storage.ts): errorText.ts moved to src/renderer/src/utils, the settings imports repointed, and the hook uses it.

Typecheck, lint and the full test suite pass.

@cb-jeeves cb-jeeves left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since round 2, commit 227b3e0 answers every open thread: the banner keeps its guidance in normal text with only the raw error in mono, the main process now says whether a failed load is recoverable and the banner picks its advice from that, Clips carries its own flex root, the snapshot API is named getClipsSnapshot at every layer, and errorText lives in a shared util. The race fix is unchanged and still sound, and nothing below blocks merging. The three notes left are all about the new recoverable flag: what it defaults to, where it lives, and what the unrecoverable banner tells the user to do next.

The correctness lens failed this round and contributed nothing.

The correctness pass did not complete this round.

Comment thread src/main/storage/index.ts Outdated
Comment thread src/renderer/src/components/clips/Clips.tsx
Comment thread src/shared/types.ts Outdated
…adable history at the reset

Fold the message and the recoverable flag into a StorageLoadError so the two fields cannot
disagree across the IPC boundary and the renderer reuses the shared type instead of mapping it.
Treat an unclassified load failure as recoverable, since only a clips decrypt failure is known to
repeat on every launch. When it does, the banner tells the user to clear all data in Settings and
restart, which is the one way out of a permanently paused session.
@cb-jeeves

Copy link
Copy Markdown
Collaborator

Responded to review round 3, pushed f321226:

  • Addressed catch-all default (main/storage/index.ts): the outer catch records an unclassified failure as recoverable, and only the clips decrypt failure in loadAllDomains is marked unrecoverable, so both processes treat an unknown throw the same way.
  • Addressed dead-end banner (Clips.tsx): when the failure is unrecoverable the banner adds a line telling the user to clear all data in Settings and restart. The test covers that the line appears only in that case.
  • Addressed two-field load state (shared/types.ts): StorageLoadError { message, recoverable } is the shared type and StorageLoadState.error is that or null. SecureStorage keeps one field, ClipsLoadError is an alias, the renderer passes the error through unchanged, and the no-error fixtures are back to { complete: true, error: null }.

Typecheck, lint on the changed files and the full test suite pass.

@cb-jeeves cb-jeeves left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since round 3, commit f321226 closes every open thread (one StorageLoadError value shared across the IPC boundary, the catch-all now marked recoverable, and the unreadable-history banner pointing at clear-all-data and restart); nothing is left to raise, though the correctness lens failed this round and contributed nothing.

The correctness pass did not complete this round.

@cb-jeeves cb-jeeves left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since round 4, the only change is the v2.2.3 version bump in package.json and the lockfile refresh from the merge of main; the guard itself is untouched and still sound. One small gap in the guard is worth closing before merge, but it is a one-line change and the PR is otherwise ready. The correctness lens ran this round for the first time; the security, maintainability, and ux lenses had nothing to add.

Comment thread src/main/storage/index.ts
A clips file that decrypts and parses to something other than an array
used to leave loadError unset, so the save guard opened and the next
save replaced the file with an empty history. Throw into the existing
catch instead so it is reported as an unrecoverable load failure.
@cb-jeeves

Copy link
Copy Markdown
Collaborator

Responded to review round 5, pushed 0e76de4:

  • Addressed non-array clips file (main/storage/index.ts): the Array.isArray check has an else branch that throws "Stored clips are not a list", so a file that decrypts and parses to an object or a string goes through the existing catch and sets the unrecoverable loadError. The save guard then refuses to overwrite it. A new test serves an object in place of the list and checks the error and the empty history.

Typecheck, lint on the changed files and the full test suite pass.

@dantheuber dantheuber left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Unit tests should be brought up to 100% coverage before merging

Cover every branch of SecureStorage, the clipboard storage-integration
wrappers and the renderer useClipsStorage hook, and add a provider test
for the loadError hand-off to the meta context.

Two branches were unreachable and are removed rather than ignored: the
saveDomain initialisation guard (every entry point initialises first)
and the lock cleanup for index 0 in useClipsStorage, which can never be
set because locks are only recorded from index 1.
@cb-jeeves

Copy link
Copy Markdown
Collaborator

Responded to review feedback (80ea561):

  • R1 (review by @dantheuber): addressed — every module this branch changes now measures 100% statements, branches, functions and lines under vitest run --coverage: src/main/storage/index.ts, src/main/clipboard/storage-integration.ts, src/renderer/src/providers/clips/storage.ts, Clips.tsx, useToolsData.ts and errorText.ts. Two unreachable branches (the saveDomain initialisation guard and the index-0 lock cleanup in useClipsStorage) were removed rather than excluded. A new provider test covers the loadError hand-off into the meta context. Not covered here: ipc.ts, preload/index.ts and the rest of ClipsProvider, which are pre-existing Electron/provider wiring the branch only touches by a renamed call; happy to take those on in a follow-up if you want them at 100% too.

@cb-jeeves

Copy link
Copy Markdown
Collaborator

Freight feedback log. Last round: 1 addressed, 0 declined (pushed 80ea561).
Maintained by the workflow; edits will be overwritten.

@dantheuber
dantheuber requested a lite review from Copilot September 6, 2026 07:04

Copilot AI 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.

🟡 Changes recommended

The implemented recoverable classification for clip-load failures conflicts with the PR’s stated behavior and should be aligned to avoid incorrect user guidance.

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

Pull request overview

Prevents startup-time data loss by ensuring the renderer cannot write placeholder/empty clip state back to encrypted storage until the main process has completed a successful background load, and by surfacing persistent load failures to the user.

Changes:

  • Main storage now tracks background load completion + error state, exposes an atomic { loadState, clips } snapshot, and refuses saveClips() until a successful load.
  • Renderer consumes the snapshot API, keeps saving disabled until a good load is applied, and shows a persistent banner on failed loads.
  • Adds targeted test coverage for the race and failed-load scenarios; bumps version to 2.2.3 and updates README notes.
File summaries
File Description
src/shared/types.ts Adds shared IPC-safe types for storage load errors/state and the clips snapshot payload.
src/renderer/src/utils/errorText.ts Introduces a small helper for consistent thrown-error stringification in the renderer.
src/renderer/src/utils/errorText.test.ts Unit tests for errorText.
src/renderer/src/test-setup.ts Updates renderer test API mocks to use storageGetClipsSnapshot.
src/renderer/src/providers/clips/types.ts Extends clips meta context typing to carry a storage load error.
src/renderer/src/providers/clips/storage.ts Implements the renderer-side save guard using the snapshot load state + adds loadError output.
src/renderer/src/providers/clips/storage.test.tsx Adds tests covering the empty-before-complete race and error/throw paths.
src/renderer/src/providers/clips/README.md Documents the new “saving stays disabled until successful load” behavior.
src/renderer/src/providers/clips/index.tsx Plumbs loadError from useClipsStorage into clips meta context.
src/renderer/src/providers/clips/index.test.tsx Tests that the provider exposes and clears load errors via context.
src/renderer/src/components/settings/tools/useToolsData.ts Switches newest-clip read to the snapshot API.
src/renderer/src/components/settings/tools/useToolsData.test.tsx Updates tests for the snapshot API usage.
src/renderer/src/components/settings/tools/Tools.tsx Switches errorText import to the new shared renderer util.
src/renderer/src/components/settings/tools/harness.tsx Updates tools harness mocks to return a snapshot.
src/renderer/src/components/settings/tools/ExportImport.tsx Switches errorText import to the new shared renderer util.
src/renderer/src/components/settings/general/SettingsProvider.tsx Switches errorText import to the new shared renderer util.
src/renderer/src/components/settings/general/ImportPreview.tsx Switches errorText import to the new shared renderer util.
src/renderer/src/components/settings/general/General.tsx Switches errorText import to the new shared renderer util.
src/renderer/src/components/settings/general/ClearAll.tsx Switches errorText import to the new shared renderer util.
src/renderer/src/components/clips/Clips.tsx Adds a persistent “load failed” banner and renders it when loadError is present.
src/renderer/src/components/clips/Clips.test.tsx Tests banner rendering and recoverable vs non-recoverable guidance text.
src/renderer/src/components/clips/Clips.module.css Adds layout + styling for the new load-failed banner and adjusts list container flex behavior.
src/preload/index.ts Replaces storageGetClips with storageGetClipsSnapshot IPC invoke.
src/preload/index.d.ts Updates the global window.api typing for storageGetClipsSnapshot.
src/main/storage/index.ts Adds load error/state tracking, exposes getClipsSnapshot(), and guards saveClips() until successful load.
src/main/storage/index.test.ts Adds comprehensive tests for load state transitions, snapshot semantics, and the save guard.
src/main/clipboard/storage-integration.ts Updates integration API to return clips snapshot and load state together.
src/main/clipboard/storage-integration.test.ts Tests updated storage integration behavior for snapshot reads and error handling.
src/main/clipboard/ipc.ts Wires a new IPC handler storage-get-clips-snapshot (replacing the old clips read channel).
README.md Documents that nothing is written back until history load succeeds, and failures pause saving with a banner.
package.json Bumps app version to 2.2.3.
package-lock.json Updates lockfile version fields for 2.2.3 and reflects re-serialized package metadata.
Review details
  • Files reviewed: 28/32 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 thread src/main/storage/index.ts
Comment on lines +195 to +199
// A clips file exists but cannot be read (for example the keystore changed).
// Reporting the history as empty here would let the renderer save over it, and a
// decrypt failure repeats on every launch, so a restart will not clear it.
console.error('Failed to load clips:', error);
this.loadError = { message: errorMessage(error), recoverable: false };
Comment thread src/shared/types.ts
Comment on lines +121 to +124
* Why the stored history could not be read. `recoverable` is true when the next launch may
* read it (the keystore was locked or unavailable, or something else failed on the way) and
* false when the file cannot be read under this keystore at all, so a restart would only
* repeat the failure.
@dantheuber
dantheuber merged commit a979fa6 into main Sep 6, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Jeeves-Review Jeeves: request a skeptical review of this issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Block clip saves until storage has finished loading successfully

3 participants