Part of #1578
Symptom
The desktop app is being force-killed while its graceful-shutdown chain is still running. Over 2026-08-09 → 2026-08-19, production telemetry shows 36 app_crashed events with action: unclean_shutdown, across all three platforms and 5 distinct installs. Both codes still fire on the latest release, 2026.817.1.
A shutdown that times out is not cosmetic. The cleanup chain runs the CRDT write-back flush and the SQLite close as its last steps, so a timeout kills the process precisely before the work that makes edits durable. See "What is still in flight" below — this is verified against the code, not assumed.
The difference between the two codes, from the code
Both codes come from the same emitter: apps/desktop/src/main/telemetry/crash-marker.ts:75-89. A marker file (session-marker.json) is written at startup, refreshed every 60s, and removed on a clean quit. A marker still present at the next launch means the previous session died uncleanly, and that launch emits app_crashed on its behalf. The code is chosen by the marker's shutdownFailure field:
| Marker state |
Code |
Meaning |
shutdownFailure: 'timeout' |
SHUTDOWN_TIMEOUT |
before-quit did run. The cleanup chain was still going when the 5s deadline fired. markShutdownFailure('timeout') stamped the marker at index.ts:2085, then app.exit(1). |
shutdownFailure: 'cleanup_error' |
SHUTDOWN_CLEANUP_FAILED |
The cleanup chain rejected. Stamped at index.ts:2186. |
no shutdownFailure field |
UNCLEAN_SHUTDOWN |
The app never got to stamp the marker at all — before-quit never ran, or the process died before reaching either failure path. Hard kill: OOM, SIGKILL, force-quit, main-process abort, OS session end, power loss. |
So the two are not variants of one bug:
SHUTDOWN_TIMEOUT is our bug. The app knows it failed and had time to record why. This is the actionable one, and it is the one whose 5s budget is provably too small (below).
UNCLEAN_SHUTDOWN is a symptom bucket. It is "died without warning," and includes causes outside our control (user force-quit, OS reboot). It is worth watching but should not be assumed to share a root cause.
Worth noting: SHUTDOWN_CLEANUP_FAILED has zero events in the window. The cleanup chain is not throwing. Every recorded self-detected failure is a timeout, which points at the time budget rather than at an exception in any one teardown step.
Evidence
PostHog project 412311, logs table, service_name='desktop', body.name='app_crashed', body.action='unclean_shutdown', timestamp >= 2026-08-09.
| Code |
Platform |
Events |
Unique installs |
First → last |
Versions seen |
SHUTDOWN_TIMEOUT |
darwin |
11 |
1 |
08-10 → 08-18 |
2026.808.2, 2026.811.1, 2026.817.1 |
SHUTDOWN_TIMEOUT |
linux |
10 |
1 |
08-10 → 08-16 |
2026.808.2, 2026.811.1 |
SHUTDOWN_TIMEOUT |
win32 |
3 |
2 |
08-09 → 08-16 |
2026.807.2, 2026.808.1 |
UNCLEAN_SHUTDOWN |
win32 |
9 |
2 |
08-10 → 08-19 |
2026.807.2, 2026.817.1 |
UNCLEAN_SHUTDOWN |
linux |
3 |
2 |
08-09 → 08-19 |
2026.806.2, 2026.808.1, 2026.811.1 |
Totals: SHUTDOWN_TIMEOUT 24, UNCLEAN_SHUTDOWN 12.
Most recent occurrences on the latest release:
SHUTDOWN_TIMEOUT, darwin, 2026.817.1, 2026-08-18
UNCLEAN_SHUTDOWN, win32, 2026.817.1, 2026-08-18 and 2026-08-19
The darwin SHUTDOWN_TIMEOUT install (d3d62e52…) accounts for 11 events on its own and also produced the linux SHUTDOWN_TIMEOUT events — i.e. this reproduces repeatedly for the same user, it is not a one-off.
Caveat on version attribution
app_version on these events is the version of the launch that reported the crash, not necessarily the version of the session that died. crash-marker.ts:88 intends to carry the dead session's version as a prior_app_version dimension and its uptime as a durationMs metric — but neither field arrives. Every shipped body has empty prior_app_version, empty duration_ms, and empty exit_code, and the attributes column carries only {"kind":"error","posthogDistinctId":…}. The metrics and dimensions passed to trackMainEvent are being dropped somewhere in the log-body transform.
Practical consequence: we cannot currently tell a crash-on-startup from a crash after an 8-hour session, and we cannot pin a crash to the version that actually died. Fixing that plumbing is a prerequisite for confidently measuring any fix here.
Where it lives
The deadline — apps/desktop/src/main/index.ts:2079-2093:
const shutdownTimeout = setTimeout(() => {
markShutdownFailure('timeout')
if (isQuitAndInstallRequested()) { … performQuitAndInstall() }
else { shutdownLog.error('timeout - forcing exit'); app.exit(1) }
}, 5000) // 5 second timeout
The chain it is racing — index.ts:2096-2158, strictly sequential .then() steps:
| # |
Step |
Own bounded wait |
| 1 |
flushAllWindows() — renderer save handshake |
2000 ms per window (index.ts:1986) |
| 2 |
createCloseSnapshots() — per open note: disk read + index-DB snapshot write |
unbounded |
| 3 |
stop snooze / reminder / inbox-review / GCal schedulers |
sync |
| 4 |
stopCaptureServer(), stopChatServer() |
unbounded |
| 5 |
stopVoiceModel() |
3000 ms (inbox/voice-model.ts:22) |
| 6 |
stopImageProcessing() |
3000 ms (image-processing/bridge.ts:16) |
| 7 |
stopEmbeddingModel() |
3000 ms (lib/embeddings.ts:68) |
| 8 |
log-ship dispose + disposeTelemetryRuntime() |
network flush |
| 9 |
stopSyncRuntime() → pushAllSnapshots() |
unbounded network (sync/runtime.ts:1052) |
| 10 |
closeVault() |
see below |
The budget does not add up. The bounded waits alone total 2000 + 3000 + 3000 + 3000 = 11,000 ms against a 5,000 ms deadline — before counting the unbounded steps 2, 4, 9 and 10. If the three utility processes are alive and slow to acknowledge, the deadline is blown before the chain ever reaches the sync push or the DB close. That is consistent with the observed telemetry: timeouts, never cleanup exceptions.
What step 10 contains — apps/desktop/src/main/vault/index.ts:643-672:
closeVault()
→ stopVaultAgentServices()
→ stopWatcher()
→ stopProjectionRuntime({ drain: true })
→ stopSyncRuntime() // → CrdtProvider.destroy()
→ closeAllDatabases() // ← the very last statement
What is still in flight when the timeout fires, and whether it can lose data
Verified by reading each path:
1. Pending note write-backs are lost. This is real data loss.
CrdtProvider.destroy() (sync/crdt-provider.ts:543-547) begins with await flushPendingWritebacks(). That function (sync/crdt-writeback.ts:359-370) drains pendingTimers — the debounced timers that write Y.Doc note content out to the vault's markdown files — and runs each runWriteback() immediately instead of waiting for its timer.
destroy() is reachable only through stopSyncRuntime(), which sits at step 9/10 — the end of the chain. When the 5s timer fires first, app.exit(1) kills the process with those debounce timers still armed and never flushed. The pending edits are gone: they were never written to disk.
The exposure window is WRITEBACK_DEBOUNCE_MS = 500 ms (crdt-writeback.ts:61), extended by a back-pressure cooldown up to WRITEBACK_MAX_COOLDOWN_MS = 5000 ms (crdt-writeback.ts:82). So a user who edits a large note and immediately quits can lose up to ~5 seconds of edits. For an offline-first app whose promise is that local edits are safe, this is the finding that matters.
2. The final sync push never happens.
Step 9's pushAllSnapshots() (sync/runtime.ts:1050-1057) is guarded by the comment at index.ts:2147: "A normal quit still pushes so nothing is left unsynced." On a timeout that guarantee is void — edits stay local-only until a later launch's sweep re-pushes them. Not permanent loss on that device, but it silently breaks the invariant the code claims, and on a machine that is then wiped or that never relaunches, it is loss.
3. Both SQLite databases are killed without close().
closeAllDatabases() (database/client.ts:162) is the last statement of closeVault() and calls .close() on the data DB and the index DB. Both run journal_mode = WAL with synchronous = NORMAL (database/client.ts:52/58 and 83/88), so committed transactions are not fsynced per commit — durability is deferred to checkpoint, and .close() is what checkpoints.
Being precise about severity: app.exit(1) is a process exit, so the OS page cache survives and SQLite's WAL recovery on the next open will replay committed transactions. This alone should not corrupt a plain table. But it does mean every timed-out quit leaves an un-checkpointed WAL, and with synchronous = NORMAL any machine-level interruption in that state (power loss, OS crash, forced reboot — note the win32 UNCLEAN_SHUTDOWN cluster) is exactly the documented window in which WAL data can be lost or torn. Given the separate SQLITE_CORRUPT report on the index DB in this same sweep, and that the index DB also hosts sqlite-vec virtual tables, this is a plausible contributing factor worth investigating together — stated as a lead, not a proven cause.
4. Close snapshots may be partial.
createCloseSnapshots() (index.ts:2025-2054) loops open notes sequentially. A timeout mid-loop leaves version-history snapshots missing for the remaining notes. Lowest severity of the four — history convenience, not primary data.
Validation performed against 2026.817.1
What a fix needs to establish
- Order the chain by durability, not by convenience. Everything that makes data durable —
flushPendingWritebacks(), the CRDT doc flush, closeAllDatabases() — must run first, before the best-effort teardown of utility processes, servers and schedulers. Today the order is inverted: the steps that can lose data are behind the steps most likely to hang.
- Stop letting one wedged subprocess consume the whole budget. Steps 5-7 are independent; running them concurrently caps their combined cost at ~3s instead of ~9s. Their bounded waits must also fit inside whatever the global deadline becomes.
- Make the deadline defensible. 5,000 ms is smaller than the sum of the chain's own bounded waits. Either raise it to a value derived from those waits, or give the durability-critical prefix its own guaranteed sub-budget that the rest of teardown cannot eat.
- Never
app.exit() with pending write-backs. At minimum the timeout path should call flushPendingWritebacks() synchronously-as-possible before exiting, so a hung teardown degrades to "slow quit" rather than "lost edits."
- Fix the telemetry plumbing so
durationMs and prior_app_version actually ship. Without them we cannot verify a fix: we would not be able to tell whether timeouts stopped or merely moved.
- Add a marker for how far the chain got. Stamping the last completed step name into the marker before
app.exit(1) would turn this from "shutdown was slow somewhere" into a precise answer about which teardown step hangs on real users' machines. The marker file is already proven to survive a hard exit, so this is cheap.
- Check the
SQLITE_CORRUPT link explicitly. Determine whether the corrupt-index reports correlate with installs that also emit these codes.
Part of #1578
Symptom
The desktop app is being force-killed while its graceful-shutdown chain is still running. Over
2026-08-09 → 2026-08-19, production telemetry shows 36app_crashedevents withaction: unclean_shutdown, across all three platforms and 5 distinct installs. Both codes still fire on the latest release,2026.817.1.A shutdown that times out is not cosmetic. The cleanup chain runs the CRDT write-back flush and the SQLite close as its last steps, so a timeout kills the process precisely before the work that makes edits durable. See "What is still in flight" below — this is verified against the code, not assumed.
The difference between the two codes, from the code
Both codes come from the same emitter:
apps/desktop/src/main/telemetry/crash-marker.ts:75-89. A marker file (session-marker.json) is written at startup, refreshed every 60s, and removed on a clean quit. A marker still present at the next launch means the previous session died uncleanly, and that launch emitsapp_crashedon its behalf. The code is chosen by the marker'sshutdownFailurefield:shutdownFailure: 'timeout'SHUTDOWN_TIMEOUTbefore-quitdid run. The cleanup chain was still going when the 5s deadline fired.markShutdownFailure('timeout')stamped the marker atindex.ts:2085, thenapp.exit(1).shutdownFailure: 'cleanup_error'SHUTDOWN_CLEANUP_FAILEDindex.ts:2186.shutdownFailurefieldUNCLEAN_SHUTDOWNbefore-quitnever ran, or the process died before reaching either failure path. Hard kill: OOM,SIGKILL, force-quit, main-process abort, OS session end, power loss.So the two are not variants of one bug:
SHUTDOWN_TIMEOUTis our bug. The app knows it failed and had time to record why. This is the actionable one, and it is the one whose 5s budget is provably too small (below).UNCLEAN_SHUTDOWNis a symptom bucket. It is "died without warning," and includes causes outside our control (user force-quit, OS reboot). It is worth watching but should not be assumed to share a root cause.Worth noting:
SHUTDOWN_CLEANUP_FAILEDhas zero events in the window. The cleanup chain is not throwing. Every recorded self-detected failure is a timeout, which points at the time budget rather than at an exception in any one teardown step.Evidence
PostHog project 412311,
logstable,service_name='desktop',body.name='app_crashed',body.action='unclean_shutdown',timestamp >= 2026-08-09.SHUTDOWN_TIMEOUTSHUTDOWN_TIMEOUTSHUTDOWN_TIMEOUTUNCLEAN_SHUTDOWNUNCLEAN_SHUTDOWNTotals:
SHUTDOWN_TIMEOUT24,UNCLEAN_SHUTDOWN12.Most recent occurrences on the latest release:
SHUTDOWN_TIMEOUT, darwin,2026.817.1, 2026-08-18UNCLEAN_SHUTDOWN, win32,2026.817.1, 2026-08-18 and 2026-08-19The darwin
SHUTDOWN_TIMEOUTinstall (d3d62e52…) accounts for 11 events on its own and also produced the linuxSHUTDOWN_TIMEOUTevents — i.e. this reproduces repeatedly for the same user, it is not a one-off.Caveat on version attribution
app_versionon these events is the version of the launch that reported the crash, not necessarily the version of the session that died.crash-marker.ts:88intends to carry the dead session's version as aprior_app_versiondimension and its uptime as adurationMsmetric — but neither field arrives. Every shipped body has emptyprior_app_version, emptyduration_ms, and emptyexit_code, and theattributescolumn carries only{"kind":"error","posthogDistinctId":…}. Themetricsanddimensionspassed totrackMainEventare being dropped somewhere in the log-body transform.Practical consequence: we cannot currently tell a crash-on-startup from a crash after an 8-hour session, and we cannot pin a crash to the version that actually died. Fixing that plumbing is a prerequisite for confidently measuring any fix here.
Where it lives
The deadline —
apps/desktop/src/main/index.ts:2079-2093:The chain it is racing —
index.ts:2096-2158, strictly sequential.then()steps:flushAllWindows()— renderer save handshakeindex.ts:1986)createCloseSnapshots()— per open note: disk read + index-DB snapshot writestopCaptureServer(),stopChatServer()stopVoiceModel()inbox/voice-model.ts:22)stopImageProcessing()image-processing/bridge.ts:16)stopEmbeddingModel()lib/embeddings.ts:68)disposeTelemetryRuntime()stopSyncRuntime()→pushAllSnapshots()sync/runtime.ts:1052)closeVault()The budget does not add up. The bounded waits alone total
2000 + 3000 + 3000 + 3000 = 11,000 msagainst a5,000 msdeadline — before counting the unbounded steps 2, 4, 9 and 10. If the three utility processes are alive and slow to acknowledge, the deadline is blown before the chain ever reaches the sync push or the DB close. That is consistent with the observed telemetry: timeouts, never cleanup exceptions.What step 10 contains —
apps/desktop/src/main/vault/index.ts:643-672:What is still in flight when the timeout fires, and whether it can lose data
Verified by reading each path:
1. Pending note write-backs are lost. This is real data loss.
CrdtProvider.destroy()(sync/crdt-provider.ts:543-547) begins withawait flushPendingWritebacks(). That function (sync/crdt-writeback.ts:359-370) drainspendingTimers— the debounced timers that write Y.Doc note content out to the vault's markdown files — and runs eachrunWriteback()immediately instead of waiting for its timer.destroy()is reachable only throughstopSyncRuntime(), which sits at step 9/10 — the end of the chain. When the 5s timer fires first,app.exit(1)kills the process with those debounce timers still armed and never flushed. The pending edits are gone: they were never written to disk.The exposure window is
WRITEBACK_DEBOUNCE_MS = 500ms (crdt-writeback.ts:61), extended by a back-pressure cooldown up toWRITEBACK_MAX_COOLDOWN_MS = 5000ms (crdt-writeback.ts:82). So a user who edits a large note and immediately quits can lose up to ~5 seconds of edits. For an offline-first app whose promise is that local edits are safe, this is the finding that matters.2. The final sync push never happens.
Step 9's
pushAllSnapshots()(sync/runtime.ts:1050-1057) is guarded by the comment atindex.ts:2147: "A normal quit still pushes so nothing is left unsynced." On a timeout that guarantee is void — edits stay local-only until a later launch's sweep re-pushes them. Not permanent loss on that device, but it silently breaks the invariant the code claims, and on a machine that is then wiped or that never relaunches, it is loss.3. Both SQLite databases are killed without
close().closeAllDatabases()(database/client.ts:162) is the last statement ofcloseVault()and calls.close()on the data DB and the index DB. Both runjournal_mode = WALwithsynchronous = NORMAL(database/client.ts:52/58and83/88), so committed transactions are not fsynced per commit — durability is deferred to checkpoint, and.close()is what checkpoints.Being precise about severity:
app.exit(1)is a process exit, so the OS page cache survives and SQLite's WAL recovery on the next open will replay committed transactions. This alone should not corrupt a plain table. But it does mean every timed-out quit leaves an un-checkpointed WAL, and withsynchronous = NORMALany machine-level interruption in that state (power loss, OS crash, forced reboot — note the win32UNCLEAN_SHUTDOWNcluster) is exactly the documented window in which WAL data can be lost or torn. Given the separateSQLITE_CORRUPTreport on the index DB in this same sweep, and that the index DB also hostssqlite-vecvirtual tables, this is a plausible contributing factor worth investigating together — stated as a lead, not a proven cause.4. Close snapshots may be partial.
createCloseSnapshots()(index.ts:2025-2054) loops open notes sequentially. A timeout mid-loop leaves version-history snapshots missing for the remaining notes. Lowest severity of the four — history convenience, not primary data.Validation performed against 2026.817.1
>= 2026-08-09; both codes confirmed present on2026.817.1, most recently 2026-08-19 (UNCLEAN_SHUTDOWN, win32) and 2026-08-18 (SHUTDOWN_TIMEOUT, darwin).attributescolumn inspected; confirmed noexit_code, noprior_app_version, noduration_msis carried.git log origin/main --since=2026-07-15overapps/desktop/src/main/index.tsandtelemetry/crash-marker.tsreviewed: the most recent shutdown-related commits areea12a1966(per-window flush handshake scoping, fix(shutdown): scope the window flush handshake per window and request #1150) anda58f74534(capping the flush handshake at one ipcMain listener, perf(ipc): cap the flush handshake at one ipcMain listener and free window-rpc on teardown #1270). Neither touches the 5s budget, which is unchanged atindex.ts:2093. No fix exists.gh issue list --state all --search "unclean shutdown"/"SHUTDOWN_TIMEOUT") returned only epic EPIC: Production telemetry sweep 2026-08-09 → 2026-08-19 #1578. Not otherwise tracked.What a fix needs to establish
flushPendingWritebacks(), the CRDT doc flush,closeAllDatabases()— must run first, before the best-effort teardown of utility processes, servers and schedulers. Today the order is inverted: the steps that can lose data are behind the steps most likely to hang.app.exit()with pending write-backs. At minimum the timeout path should callflushPendingWritebacks()synchronously-as-possible before exiting, so a hung teardown degrades to "slow quit" rather than "lost edits."durationMsandprior_app_versionactually ship. Without them we cannot verify a fix: we would not be able to tell whether timeouts stopped or merely moved.app.exit(1)would turn this from "shutdown was slow somewhere" into a precise answer about which teardown step hangs on real users' machines. The marker file is already proven to survive a hard exit, so this is cheap.SQLITE_CORRUPTlink explicitly. Determine whether the corrupt-index reports correlate with installs that also emit these codes.