Skip to content

fix(desktop): move settings-store writes off the main thread - #237

Merged
quickbeard merged 1 commit into
mainfrom
async-store-writes
Aug 14, 2026
Merged

quickbeard merged 1 commit into
mainfrom
async-store-writes

Conversation

@quickbeard

Copy link
Copy Markdown
Owner

Problem

Twice on a dev machine, clicking Save in the sandbox Folders dialog froze the entire app beyond quitting. Kernel forensics (sysrq-w) showed the Electron main thread in uninterruptible disk sleep inside fsync (ext4_fc_commit → jbd2_log_wait_commit): the NVMe dropped a write during a power-state transition, wedging the ext4 journal, and a fast-commit bug in the 6.8 kernel then lost the wakeup — so the main thread never returned even after the journal recovered. With the event loop dead, timers, IPC, and quit were all gone.

The syscall that walks into the trap is ours to control: conf v15 (electron-store's engine) performs a synchronous fsync on the main thread for every store.set, and renderers persist through store-set IPC at exactly the busy moments — e.g. when a sandbox connection drops during a mount-apply restart.

Change

getStore() now returns a small fork-owned store instead of electron-store:

  • store-file.ts — state lives in memory (read-your-writes); every mutation flushes write-behind. Replacing rather than patching is load-bearing: conf rereads the file on every access, so with queued writes a set on one key would load a stale file and silently drop a still-queued update to another (covered by a test).
  • store-writer.ts — serialized, latest-wins async atomic writer (temp file → fsync → rename via fs.promises). The fsync runs on the libuv threadpool: a wedged disk stalls one pool thread while the app stays interactive, and the flush lands when the disk recovers.
  • Shutdown: stopSidecars awaits flushStoreWrites(1500) — relaunch/SIGINT/SIGTERM keep pending settings, bounded so a wedged disk can't hang quit. Deleting a store file discards its writer first so a late flush can't resurrect it.
  • Compatibility: on-disk format unchanged (flat keys, tab-indented JSON, no extension); existing files load as-is. A corrupt file now starts empty with a logged error instead of throwing from every accessor. electron-store dependency removed.

Trade-off accepted: a hard crash can lose the last few milliseconds of unflushed settings — versus today's behavior of freezing the entire app permanently on a bad disk.

Tests

bun typecheck + bun test in packages/desktop: 96 pass (13 new — writer coalescing/timeout/discard/error paths; store read-your-writes, the cross-key lost-update scenario, format compatibility both directions, corrupt/missing file, delete/clear). bun run build (electron-vite) passes without electron-store.

🤖 Generated with Claude Code

conf v15's store.set is a synchronous fsync on the Electron main thread,
and one of those parked the whole app in uninterruptible disk sleep when
a kernel journal stall lost its wakeup (an NVMe write the drive never
completed wedged ext4's journal; fast-commit then dropped the waiter):
event loop dead, IPC dead, quit impossible. Renderers persist through
store-set at exactly the busy moments, e.g. a sandbox mount-apply
restart, which is where it was hit live — twice.

getStore now returns a small fork-owned file store: state lives in
memory (read-your-writes; conf's reread-per-access design would let a
set on one key load a stale file and drop a still-queued update, which
is why the stock class could not simply be rerouted), and every change
flushes write-behind through a serialized latest-wins async atomic
writer (temp file -> fsync -> rename via fs.promises, so a wedged disk
stalls a threadpool thread while the app stays interactive). The
on-disk format is unchanged and existing files load as-is; a corrupt
file starts empty with a logged error instead of throwing from every
accessor. stopSidecars awaits a bounded flushStoreWrites(1500) so
relaunch/exit keep pending settings without letting a wedged disk hang
shutdown; deleting a store file discards its writer first so a late
flush cannot resurrect it. The electron-store dependency is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@quickbeard
quickbeard merged commit e0013ae into main Aug 14, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the async-store-writes branch August 14, 2026 09:35
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