Skip to content

fix(quit): stop durable state writes from parking the main thread on quit - #11931

Merged
nwparker merged 3 commits into
mainfrom
nwparker/quit-path-no-sync-fs
Aug 2, 2026
Merged

nwparker merged 3 commits into
mainfrom
nwparker/quit-path-no-sync-fs

Conversation

@nwparker

@nwparker nwparker commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

The freeze

will-quit ran stats.flush() and store.flush() synchronously, before preventDefault(). Both fsync and rename a multi-MB file in the profile directory.

When that directory sits on a stalled network mount (SMB/NFS profile redirection, a VPN drop mid-quit), the syscall enters an uninterruptible wait. A process blocked there ignores SIGTERM and SIGKILL — so the app stops repainting and Force Quit stops working. That is the "app is frozen and won't even force-quit" report.

The existing 20s teardown deadline could not bound this. settleTeardownWithinDeadline schedules a setTimeout on the very thread the syscall parked, so it never fires. No main-thread deadline can bound a main-thread block.

So the fix is not to bound quit — it's to stop blocking. A quit that is slow but responsive stays killable by the OS; one blocked in the kernel does not.

The change

  • preventDefault() moved to the top of the handler, so every teardown step below is free to await.
  • StatsCollector and Store gain flushAsync() twins built on node:fs/promises. The JSON.stringify stays synchronous (both writers need an untorn snapshot), but every syscall is async.
  • Both join the existing teardown barrier — which can now actually bound them, for the first time.
  • The pass-2 will-quit re-entry returns early instead of re-running teardown against already-committed state.
  • quitFlushStarted makes the quit flush the final write. Without it, a teardown step touching the store arms a debounced write with nothing awaiting it, leaving a rename racing process exit.
  • Dropped an existsSync probe ahead of mkdir — recursive mkdir is already a no-op when the directory exists, and the probe was itself a blocking access() on the stalled mount.

Atomic temp+rename is unchanged, so a write cut short by the deadline leaves the previous file whole: bounded loss, never corruption.

One non-obvious consequence, and its guard

Making the swap async costs the atomicity of check-generation-then-rename. A writer parked on await rename has already cleared the generation guard, so nothing downstream can veto it — a later synchronous flush could be silently clobbered by stale state. This matters for active-view-preference, whose sync flushOrThrow() runs from ~15 production sites (session checkpoints, renderer shutdown).

Both async writers now claim their temp path; the sync writers delete it before writing. The stale swap becomes a swallowed ENOENT. Two tests pin this at the rename specifically, since the pre-existing tests only ever parked a writer before its temp write.

Tests

src/main/quit-path-durable-write-blocking.test.ts (10 new) plus 2 clobber tests.

Being explicit about what each one proves — I reverted the fix and re-ran:

Fail without the fix (4): the two "issues no synchronous fs syscalls" tests, the sidecar test (active-view + github-cache also move off the thread), and — the key one — "the quit teardown deadline can now bound a wedged state flush", which drives the real settleTeardownWithinDeadline against a mount that never responds and asserts it returns ['state'] instead of hanging.

Fail without their specific guard (2): the two rename-clobber tests. Removing only the temp-path removal flips active-view.json back to the stale view and loses 8000ms of agent time from the stats flush.

Pass either way, by design (6): behavior-preservation guards — live-agent closeout still happens before killAllPty(), the flush still drains an in-flight debounced write, it resolves rather than throwing when the mount rejects writes, etc. They're regression fencing, not evidence.

Deliberate scope decisions

  • Sync flush() / flushOrThrow() are kept. They have non-quit callers that genuinely cannot await (session checkpoints, crash paths). Only the quit path moved.
  • stats and state flush concurrently with the other teardown joiners, not after them. Serializing after would let a wedged transport ([Bug]: macOS app freezes on the "phone session ended" resize modal after waking from sleep, and can't be quit except via Force Quit #9447) eat the entire window and starve the state write. Verified neither disconnectDaemon() nor shutdownWatchersOnce() mutates the store.
  • The 20s deadline is untouched — this PR is what makes it reachable, not a replacement for it.
  • Follow-up, not in scope: on an fsync failure (as opposed to a hang) the writer's finally removes the temp file, losing that write. Degrading to an unsynced commit would preserve it. Different failure mode; worth a separate change.

Verification

tsc --noEmit clean · oxlint src/main clean · oxfmt --check clean · full src/main suite 17,969 passed / 57 skipped, 0 failures.

One unrelated flake appeared in a single run and did not reproduce across two subsequent full runs; it's in a file this PR doesn't touch.

Screenshots

No visual change.

AI Review Report

  • Independent concurrency, compatibility, and security/data-integrity reviews completed with no remaining findings.
  • Review-driven fixes cover final-flush idempotency, stale rename fencing, quiescent profile barriers, coalesced writers, reentrant quit events, unload staging, and async scrollback migration.
  • Existing inline review findings were reproduced and covered with deterministic regression tests.

Security Audit

  • No dependency, lockfile, binary, install-hook, permission, shell, or network-surface changes.
  • Secret serialization and existing file-permission behavior are preserved.
  • Cross-instance temp cleanup preserves fresh foreign-process writes with a 24-hour age threshold.

Notes

  • No production synchronous filesystem calls remain in the committed quit path.
  • Changed tests: 220/220 passing.
  • Full repository suite: 42,859 passing; two unrelated suite-load flakes passed immediately in isolation (144/144 daemon PTY and 15/15 speech download resume).
  • Typecheck, changed-code quality, max-lines ratchet, targeted oxlint, and diff checks pass.

will-quit ran stats.flush() and store.flush() synchronously, before
preventDefault(). Both fsync and rename a multi-MB file on the profile
directory. When that directory sits on a stalled network mount the
syscall enters an uninterruptible wait: the app stops repainting and
stops responding to Force Quit, because a process blocked in the kernel
ignores SIGTERM and SIGKILL alike.

The existing 20s teardown deadline could not bound this. Its timer runs
on the very thread the syscall parked, so it never fires. The fix is to
make the quit path awaitable rather than to try to bound it — a quit
that is slow but responsive stays killable by the OS.

- preventDefault() now runs first, so every teardown step is free to await
- stats and state gain flushAsync() twins that use node:fs/promises
- both join the existing teardown barrier, which can now actually bound them
- the pass-2 will-quit re-entry returns early instead of re-running teardown
- quitFlushStarted makes the quit flush the last write, so a teardown step
  touching the store cannot arm a debounce that races process exit

Making the swap async cost the atomicity of check-generation-then-rename:
a writer parked on await rename has already cleared the guard, so a later
synchronous flush could be clobbered by stale state. Both async writers now
claim their temp path, and the sync writers delete it, turning that swap
into a swallowed ENOENT.

Atomic temp+rename is unchanged, so a write cut short by the deadline
leaves the previous file whole — bounded loss, never corruption.
@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds serialized asynchronous persistence for active-view preferences, statistics, store state, terminal snapshots, and GitHub cache data. Synchronous flushes remove stale temporary files before writing newer state. New pending and final flush APIs drain current generations and support abort signals. Renderer unload handling now stages state before asynchronous persistence. Application shutdown prevents duplicate teardown and coordinates bounded asynchronous cleanup before re-entering quitting. Tests cover rename races, stalled writes, timeout handling, staging, and post-quit write suppression.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.45% 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
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.
Title check ✅ Passed The title clearly identifies the primary fix: preventing durable state writes from blocking the main thread during quit.
Description check ✅ Passed The description covers the change, testing, screenshots, AI review, security audit, and notes with detailed verification results.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

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.

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/main/quit-path-durable-write-blocking.test.ts (1)

283-300: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Also assert the active-view sidecar after the quit flush.

This test passes activeView: 'tasks', so it exercises the quitFlushStarted guard in ActiveViewPreference.scheduleSave as well. The assertions only cover syncCalls and the state file. An asynchronous active-view write that landed after the quit flush would issue no synchronous call and would not change ui.sidebarWidth, so it would pass undetected. Add an assertion on the sidecar contents.

♻️ Proposed addition
     expect(fsCalls.syncCalls).toEqual([])
     expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(10)
+    expect(JSON.parse(readFileSync(activeViewFile(dir), 'utf-8')).activeView).not.toBe('tasks')

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4deace0b-e5bf-4fda-8297-c2e9865c1456

📥 Commits

Reviewing files that changed from the base of the PR and between 16c5526 and 72436e4.

📒 Files selected for processing (7)
  • src/main/active-view-preference-sync-flush-veto.test.ts
  • src/main/active-view-preference.ts
  • src/main/index.ts
  • src/main/persistence.ts
  • src/main/quit-path-durable-write-blocking.test.ts
  • src/main/stats/collector-async-save.test.ts
  • src/main/stats/collector.ts

Comment thread src/main/persistence.ts
Comment thread src/main/stats/collector.ts Outdated
Comment thread src/main/stats/collector.ts Outdated
@greptile-apps

greptile-apps Bot commented Aug 1, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR fixes a hard freeze on quit caused by synchronous fsync/rename syscalls on the Electron main thread: when the user's profile directory sits on a stalled network mount, those syscalls enter an uninterruptible kernel wait that ignores both SIGTERM and SIGKILL. Moving preventDefault() to the very top of the will-quit handler and replacing the blocking writes with async twins (flushAsync() on both StatsCollector and Store) allows the existing 20 s teardown deadline to actually bound a wedged write for the first time.

  • Async write path: StatsSnapshotWriter, Store.flushAsync()/flushCurrentStateAsync(), and ActiveViewPreference.flushAsync()/flushPendingAsync() are new async twins that move every filesystem syscall off the main thread while keeping JSON.stringify synchronous for snapshot integrity.
  • Stale-rename veto: Both Store.flushOrThrow() and ActiveViewPreference.flushOrThrow() gain an inFlightTmpFile / inFlightAsyncTmpFile pointer; a sync flush deletes the parked async temp file before writing, so a stale rename that completes after the sync write sees ENOENT and is swallowed.
  • quitFlushStarted gate: Set at the beginning of each flushAsync() call, this prevents scheduleSave() from arming a new debounced write after the final quit flush has started.

Confidence Score: 5/5

Safe to merge. The async quit path is correct and thoroughly tested; atomic temp+rename guarantees bounded loss and no corruption if the deadline fires mid-write.

The core invariants — generation-guarded async renames, inFlightTmpFile veto for sync flush, quitFlushStarted gate to block late debounced writes — are each independently tested and correct. The daemonDisconnectDone / QuitTeardownStartGate interplay correctly handles re-entrant and concurrent will-quit events. The only finding is a minor logging inconsistency with no correctness impact.

Files Needing Attention: No files require special attention; the complexity in persistence.ts and active-view-preference.ts is well-commented and covered by the new test suite.

Important Files Changed

Filename Overview
src/main/index.ts will-quit handler refactored: preventDefault moved to top via QuitTeardownStartGate, daemonDisconnectDone guards the final-exit re-entry, stats/store flush captured as async promises before joining the teardown barrier.
src/main/persistence.ts Added flushAsync()/flushPendingAsync()/flushPendingOrThrowAsync(); inFlightAsyncTmpFile guard vetoes stale renames during sync flush; quitFlushStarted blocks new scheduleSave/flushOrThrow calls after quit flush begins.
src/main/active-view-preference.ts Added flushAsync()/flushPendingAsync()/drainPendingWrites() for async quit path; inFlightTmpFile allows flushOrThrow() to veto a parked async rename via unlinkSync; quitFlushStarted prevents new debounced writes after final flush begins.
src/main/stats/stats-snapshot-writer.ts New class for async stats writes using writeFile+rename (consistent with pre-existing writeSync path); inFlightAsyncTmpFile guard mirrors the persistence.ts pattern for sync-flush veto.
src/main/stats/collector.ts Added flushAsync() delegating to StatsSnapshotWriter; closeOutLiveAgents() remains synchronous; quitFlushStarted guard prevents late scheduleSave calls; errors are logged before swallowing.
src/main/quit-teardown-start-gate.ts New guard class that calls preventDefault on every overlapping will-quit while teardown is in progress; returns false on re-entry so the handler exits early without repeating teardown.
src/main/durable-file-write.ts Added renameDurable() for async rename + directory fsync; durableWriteTempPath() for consistent temp-file naming; removeStaleDurableWriteTempFiles() for orphan cleanup. writeFileDurableSync kept for crash/sync paths.
src/main/orca-profiles/profile-persistence-deadline.ts New helper wrapping flushPendingOrThrowAsync in a 20-second AbortController-backed deadline for profile mutations; timeout always cleared in finally.
src/main/agent-auth-restart-preservation.ts Switched store.flush() to store.flushPendingOrThrowAsync() for the restart path; now awaited within the existing 2-second lifecycle timeout.
src/main/quit-path-durable-write-blocking.test.ts 10 new integration tests covering the async quit path: no-sync-syscall assertions, teardown deadline bounding against a stalled mount, rename-clobber guards, and behavior-preservation regression fencing.
src/main/active-view-preference-sync-flush-veto.test.ts New test file verifying a parked async rename cannot overwrite a later synchronous flushOrThrow(), covering the unlink-veto, EBUSY recovery, and abort-signal coalescing paths.
src/main/terminal-scrollback-snapshot-async-migration.ts New async migration helpers for terminal scrollback snapshots, offloading I/O from the main thread during session state writes.

Sequence Diagram

sequenceDiagram
    participant E as Electron
    participant WQ as will-quit handler
    participant Gate as QuitTeardownStartGate
    participant Stats as StatsCollector
    participant Store as Store
    participant Deadline as settleTeardownWithinDeadline
    participant FS as fs/promises

    E->>WQ: will-quit (1st)
    WQ->>Gate: tryStart(e) calls preventDefault
    Gate-->>WQ: returns true (started)
    WQ->>Stats: flushAsync closeOutLiveAgents sync set quitFlushStarted
    Stats-->>WQ: statsFlush Promise
    WQ->>Store: flushAsync set quitFlushStarted
    Store-->>WQ: storeFlush Promise
    WQ->>Deadline: settleTeardownWithinDeadline with daemon rpc stats state promises
    par async background writes
        Stats->>FS: writeFile tmpFile then rename to statsFile
        Store->>FS: open tmpFile writeFile fsync renameDurable
    end
    Deadline-->>WQ: all settled or deadline reached
    WQ->>E: "daemonDisconnectDone=true then app.quit()"
    E->>WQ: will-quit (2nd)
    WQ-->>E: "daemonDisconnectDone=true so return without preventDefault"
Loading

Reviews (3): Last reviewed commit: "fix(persistence): bound best-effort flus..." | Re-trigger Greptile

@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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/main/stats/collector.ts (1)

228-238: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Shorten the comment and remove the stale identifier references.

The comment names prepareWritePayload and writeToDiskSync. Neither symbol exists after the extraction; the writer now exposes preparePayload and writeSync. The block also walks through implementation details across seven lines.

As per coding guidelines: "Comments must be concise, limited to non-obvious information, and preferably one line; do not explain obvious code or walk through implementation details."

♻️ Proposed comment
     this.writeTimer = setTimeout(() => {
       this.writeTimer = null
-      // Why: the debounced save is fun-stats telemetry, not crash-critical
-      // state, so it uses the async writer to move the ~900KB tmp-file write
-      // off the main thread (the stringify stays sync — see prepareWritePayload).
-      // A chatty multi-agent session re-arms this every 5s; a fully-sync write
-      // is a recurring main-thread stall. The quit path uses flushAsync(); the sync
-      // flush() remains for callers that cannot await, and writeToDiskSync keeps the
-      // two paths race-safe.
+      // Why async: a chatty session re-arms this every 5s, and a sync ~900KB write
+      // would stall the main thread each time.
       void this.enqueueWrite().catch((err) => {

Source: Coding guidelines

src/main/window/attach-main-window-services.ts (1)

158-164: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bound this flush with a deadline.

flushPendingAsync() is unbounded and cannot be aborted, because Store.flushPendingAsync() captures no signal at call time and Store.flushCurrentStateAsync() never checks an AbortSignal in its current code. The updater onBeforeQuit can still hang indefinitely on a stalled disk write; avoid the duplicate store flush or call flushPendingOrThrowAsync({ signal }) and race the flush against a timeout.

🧹 Nitpick comments (4)
src/main/stats/stats-snapshot-writer.ts (2)

23-37: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider restarting the drain if writeRequested is still set when the tracked promise settles.

drainWrites() exits when writeRequested is false. The reset of pendingWrite happens in a .finally microtask after run settles. If write() runs in that gap, it sets writeRequested = true, observes a non-null pendingWrite, and returns the settling promise. No drain then consumes the request, so the last snapshot is lost.

The window is narrow because write() is normally called from timer or IPC macrotasks. A guard in the reset closes it.

♻️ Proposed guard
     const run = this.drainWrites()
     const tracked = run.finally(() => {
       if (this.pendingWrite === tracked) {
         this.pendingWrite = null
+        if (this.writeRequested && this.pendingSerialize) {
+          void this.write(this.pendingSerialize).catch(() => {})
+        }
       }
     })

68-79: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Resolve the target file once per write instead of twice.

writeToDiskAsync() captures statsFile at line 102, and preparePayload() calls this.resolveFile() again at line 75. getStatsFile reads a module-level path that initStatsPath() can change. If the path changes between the two calls, the temporary file and the rename target live in different directories, and the rename fails or writes to the wrong profile. Pass the resolved path into preparePayload().

♻️ Proposed change
-  private preparePayload(serialize: () => string): {
+  private preparePayload(
+    finalPath: string,
+    serialize: () => string
+  ): {
     tmpFile: string
     json: string
     generation: number
   } {
     const generation = ++this.writeGeneration
     return {
-      tmpFile: durableWriteTempPath(this.resolveFile()),
+      tmpFile: durableWriteTempPath(finalPath),
       json: serialize(),
       generation
     }
   }

Update both call sites to pass the already resolved statsFile.

src/main/stats/collector.ts (1)

248-257: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

STATS_SCHEMA_VERSION is now declared in two files.

src/main/stats/stats-file-loader.ts declares its own STATS_SCHEMA_VERSION = 1 at line 4, and serialize() writes the constant declared in this file. A future bump in one file leaves the other at the old value. Export the constant from a single module and import it in both places.

src/main/ipc/orca-profiles.test.ts (1)

165-167: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert that onBeforeRelaunch ran before comparing the order.

The ?? Number.POSITIVE_INFINITY fallback makes this assertion pass when onBeforeRelaunch was never invoked. The test then proves only that flush ran, not that it ran first. Add an explicit call assertion so a regression that drops onBeforeRelaunch fails here.

♻️ Proposed change
+    expect(onBeforeRelaunch).toHaveBeenCalledTimes(1)
     expect(flush.mock.invocationCallOrder[0]).toBeLessThan(
-      onBeforeRelaunch.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
+      onBeforeRelaunch.mock.invocationCallOrder[0]
     )

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2c6e4dde-4175-42ea-b458-ef36ee698719

📥 Commits

Reviewing files that changed from the base of the PR and between 72436e4 and 4d7ebd9.

📒 Files selected for processing (30)
  • src/main/active-view-preference-sync-flush-veto.test.ts
  • src/main/active-view-preference.ts
  • src/main/agent-auth-restart-preservation.test.ts
  • src/main/agent-auth-restart-preservation.ts
  • src/main/durable-file-write.ts
  • src/main/index.ts
  • src/main/ipc/orca-profiles.test.ts
  • src/main/ipc/orca-profiles.ts
  • src/main/ipc/renderer-shutdown-checkpoint.test.ts
  • src/main/ipc/renderer-shutdown-checkpoint.ts
  • src/main/orca-profiles/profile-persistence-deadline.ts
  • src/main/persistence-async-write-syscalls.test.ts
  • src/main/persistence.ts
  • src/main/quit-path-durable-write-blocking.test.ts
  • src/main/quit-teardown-start-gate.test.ts
  • src/main/quit-teardown-start-gate.ts
  • src/main/stats/collector-async-save.test.ts
  • src/main/stats/collector.ts
  • src/main/stats/stats-file-loader.ts
  • src/main/stats/stats-snapshot-writer.ts
  • src/main/terminal-scrollback-snapshot-async-migration.ts
  • src/main/terminal-scrollback-snapshots.ts
  • src/main/window/attach-main-window-services.test.ts
  • src/main/window/attach-main-window-services.ts
  • src/preload/api-types.ts
  • src/preload/index.ts
  • src/renderer/src/App.tsx
  • src/renderer/src/app-startup-routing.test.ts
  • src/renderer/src/web/web-preload-api.test.ts
  • src/renderer/src/web/web-preload-api.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/index.ts

Comment thread src/main/persistence.ts Outdated
Comment thread src/main/quit-teardown-start-gate.ts
@nwparker
nwparker merged commit 8ab85c9 into main Aug 2, 2026
43 checks passed
gal064 pushed a commit to gal064/orca that referenced this pull request Aug 3, 2026
…quit (stablyai#11931)

* fix(quit): stop durable state writes from parking the main thread

will-quit ran stats.flush() and store.flush() synchronously, before
preventDefault(). Both fsync and rename a multi-MB file on the profile
directory. When that directory sits on a stalled network mount the
syscall enters an uninterruptible wait: the app stops repainting and
stops responding to Force Quit, because a process blocked in the kernel
ignores SIGTERM and SIGKILL alike.

The existing 20s teardown deadline could not bound this. Its timer runs
on the very thread the syscall parked, so it never fires. The fix is to
make the quit path awaitable rather than to try to bound it — a quit
that is slow but responsive stays killable by the OS.

- preventDefault() now runs first, so every teardown step is free to await
- stats and state gain flushAsync() twins that use node:fs/promises
- both join the existing teardown barrier, which can now actually bound them
- the pass-2 will-quit re-entry returns early instead of re-running teardown
- quitFlushStarted makes the quit flush the last write, so a teardown step
  touching the store cannot arm a debounce that races process exit

Making the swap async cost the atomicity of check-generation-then-rename:
a writer parked on await rename has already cleared the guard, so a later
synchronous flush could be clobbered by stale state. Both async writers now
claim their temp path, and the sync writers delete it, turning that swap
into a swallowed ENOENT.

Atomic temp+rename is unchanged, so a write cut short by the deadline
leaves the previous file whole — bounded loss, never corruption.

* fix(quit): harden async persistence finalization

* fix(persistence): bound best-effort flushes

(cherry picked from commit 8ab85c9)
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