You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[BUG]: macOS desktop: Cmd+Q leaves a half-dead instance, and the next Dock click stalls the renderer #56394
On macOS, quitting the desktop app with Cmd+Q leaves a half-dead instance: the process neither terminates nor enters its background-service state, and the Dock icon keeps showing it as running. The next click on the Dock icon shows the window but the renderer stalls indefinitely. Killing the leftover instance makes the next launch clean.
Reported and reproduced by richardding on shipped 2.0.29; the sample and ps snapshot are attached there.
Reproduction (deterministic on the reporter's machine)
Run the desktop app normally.
Cmd+Q.
The Dock icon remains — the app is neither terminated nor in the "run in background" state.
A single click on the Dock icon → the window shows, the renderer stalls.
If you pkill -f OpenCode at step 3 instead, the Dock switches to "run in background" and the next Dock click launches the GUI cleanly.
Evidence
From sample <renderer-pid> 8 during a stalled launch:
Renderer main thread: 6120/6120 hits (100% of the 8s window) in v8::internal::OptimizingCompileTaskExecutor::WaitUntilCompilationJobsDoneForIsolate → mach_msg2_trap.
No V8 compilation thread is present anywhere in the 8s sample: the main thread waits on compilation jobs while no worker thread is observable running them.
#54296 tracks a main-thread block (the window is shown before the main bundle and layers finish). This is the quit path plus a renderer stall after the Dock click; the oc:// asset starvation seen in the same report is a third mechanism. The reporter's reading — the hanging quit is upstream of the stall — is consistent with pkill clearing it.
Thanks for splitting this out with such a detailed write-up and the sample capture.
I couldn't reproduce it here: it needs the macOS desktop app on a real display (Cmd+Q, the Dock and the activate event), and this sandbox is Linux with no display. A maintainer with a Mac will need to look at it.
One lead from reading the code, not yet confirmed: on quit, the app cancels the first quit request. It then waits for all registered shutdown steps to finish before quitting again (before-quit in packages/desktop/src/main/lifecycle/index.ts, Shutdown.run in lifecycle/shutdown.ts), and there is no timeout. If one of those steps never finishes on macOS, the process would stay half-quit as you describe, and pkill would clear it. If you can reproduce it again, the desktop log from that session (~/Library/Application Support/ai.opencode.desktop/logs/<timestamp>/) covering the moments after Cmd+Q would help pin down which step blocks.
I tested this on macOS by building and running the desktop app from the dev branch, including stress tests with many sessions and large contexts, but was unable to reproduce the Cmd+Q hang. The app exited cleanly.
Logs from the built dev app (OpenCode Dev.app, version 0.0.0-dev-21105):
Could you share more details or the exact conditions that trigger the hang? Additional logs/samples from the failing session would help isolate the blocking shutdown step.
Thanks — a negative result on current dev is genuinely useful here, because it changes what this bug is.
The version split matters: the report is on shipped 2.0.29, and MoeidHeidari built dev (0.0.0-dev-21105, which reports 2.0.32). So either the hang was fixed between 2.0.29 and dev, or it needs the packaged 2.0.29 app rather than a dev build.
The bot's lead points at the same place: before-quit cancels the first quit, then Shutdown.run waits for every registered step with no timeout. A step that never finishes leaves exactly the half-quit state described.
#44239 is a candidate for that step: it reports a DrizzleError: Failed to run the query 'begin' from the draft-flush routine on every quit — a shutdown-ordering race where the final flush runs a transaction while the SQLite connection is being torn down. If that step throws or blocks there, the quit would hang with no timeout to cut it short.
Next step, if richardding can: retest on 2.0.30 or current dev, and if it still reproduces, attach the desktop log from that session (~/Library/Application Support/ai.opencode.desktop/logs/<timestamp>/) covering the moments after Cmd+Q — that names the step that blocks. If it does not reproduce on a newer build, this can close as fixed between 2.0.29 and dev.
Thanks — this is the best evidence on the issue so far, and the UI records plus the process table together pin the sequence down.
From your captures:
16:54:36 Cmd+Q → 16:54:41noWindowsAvailable, but 18606 is still alive at 16:54:49 with ELAPSED 02:03.
16:54:52getApp → 16:55:03 a window exists again (oc://renderer/index.html) — created on that same process, which is the stalled reopen.
16:55:34 a second Cmd+Q → App quit.
So the first quit closes the window and leaves the process running, and the activate path then builds a window on a process that is already quitting. That is the shape to fix; the renderer stall is downstream of it.
Two things from the bundle that narrow it:
The main process is not blocked. The innermost frames of opencode-reproduced-main-hang-sample.txt are -[NSApplication run] → _DPSNextEvent → _BlockUntilNextEventMatchingListInMode → __CFRunLoopRun → mach_msg2_trap — the normal NSApp event loop. (The outer symbols do not resolve — the offsets look like a slide/dSYM mismatch — so treat the middle of that stack as unreliable; only the innermost frames are meaningful.) A cancelled quit that is never re-issued, or a shutdown step awaiting a promise that never settles, fits that. A blocked main thread does not.
The log cannot name the step. Both main.log files contain no quit or shutdown lines at all — the last entry in the first is main window visible at 12:49:28, and the second ends on the same line at 12:54:53. So the Shutdown.run steps are invisible.
What would make the next capture conclusive, if it is cheap on your side: log each shutdown step's start and finish (and the total wait), and skip window creation while a quit is in flight. Then the next reproduction names the step that never finishes instead of leaving it to inference.
One question: the app is connected to a Homebrewopencode serve --service (/opt/homebrew/Cellar/opencode/2.0.32/bin/opencode serve --service, PID 62892, started 07:40 — five hours before the app). Is that the service the quit path waits on?
My custom logging-only and quit-guard builds: evidence/ledger.md, source-identities / build-identities. My first-quit records (shutdown.total, shutdown.wait, phase: 'end'): first-quit-end-lines; my targeted process snapshots: process-comparison.
My logging-only reopen screenshots: screenshots-and-samples. My quit-guard reopen records (event: 'window-restore-skipped', reason: 'quitting', windows: 0): reopen-steps; my reopen errors (getApp, AX read; timeoutReached suffix): cua-results.
Build / cycle
Raw log timestamp for both end records (no explicit offset)
total / waitelapsedMs
Desktop PID after first ⌘Q / reopen
After second ⌘Q, targeted ps
logging-only / cycle01
2026-10-19 08:42:55.623
4 / 4
91110 / 91110
91110 absent
logging-only / cycle02
2026-10-19 08:47:41.890
4 / 4
92940 / 92940
92940 absent
logging-only / cycle03
2026-10-19 08:49:40.881
4 / 4
93639 / 93639
93639 absent
quit-guard / cycle01
2026-10-19 09:14:38.699
3 / 3
1234 / 1234
1234 absent
quit-guard / cycle02
2026-10-19 09:16:22.648
4 / 4
2015 / 2015
2015 absent
quit-guard / cycle03
2026-10-19 09:18:20.242
4 / 4
2680 / 2680
2680 absent
My raw records and UTC snapshot intervals: evidence/ledger.md, first-quit-end-lines and process-comparison. My action/error records: cua-results. My lifecycle records: reopen-steps.
My AX error excerpts: cua-results — first quit: noWindowsAvailable; logging-only second quit: App quit; quit-guard second quit, cycle01/cycle03: timeoutReached; cycle02: NSCocoaErrorDomain Code=256, NSOSStatusErrorDomain Code=-600, procNotFound (noncontiguous excerpts; complete messages in cua-results).
My service metadata (62892, 2.0.32, http://127.0.0.1:49374) and service-ready fields (version: '2.0.32', probed: true): service-ready. My process records (/opt/homebrew/Cellar/opencode/2.0.32/bin/opencode; lstart: Sun Oct 18 07:40:07 2026; PID 62892): service-rows, process-comparison.
My build/version records (2.0.32): source-identities / build-identities. My build/toolchain, CLI/signing, protocol/updater records and frozen source/emitter pointers: evidence/README.md and source-emitter-pointers.
My logging-only cycle01 sample records and command intervals: sample-origins. My guard cycle03 action and elision records: cua-results; my step records: evidence/README.md.
My reopen images: evidence/runtime/logging-only/cycle01/reopen-persistent.jpg, evidence/runtime/logging-only/cycle02/reopen-persistent.jpg, evidence/runtime/logging-only/cycle03/reopen-persistent.jpg. My screenshot/origin records: screenshots-and-samples.
My retained main/crash/netlog, CUA, known-PID process and origin/hash records: evidence/README.md, evidence/manifest.json. My command/output ledger and replay: evidence/ledger.md; node verify.mjs from evidence/.
The two builds are exactly the experiment, and the logging-only result is the important one: the shutdown completes in 4 ms. That rules the shutdown steps out as the blocker — the "no timeout" lead does not apply here.
What is left is the quit re-issue. In packages/desktop/src/main/lifecycle/index.ts:
letshutdownReady=falseconstprepareToRestart=shutdown.run.pipe(Effect.ensuring(()=>(shutdownReady=true)))constbeforeQuit=(event: Electron.Event)=>{setAppQuitting()if(shutdownReady)return// a later quit passes throughevent.preventDefault()// the first quit is cancelledrunFork(prepareToRestart.pipe(Effect.ensuring(()=>app.quit())))}
Your data is consistent with the re-issued app.quit() never landing: the second Cmd+Q takes the shutdownReady branch and quits, which is the same path the re-issue would take. So the difference is not in the quit handler — it is that the first quit's completion path did not reach app.quit().
That moves the next probe from the individual steps to the aggregate: log Shutdown.run's own completion, and log each effect registered through Shutdown.add. There is exactly one today — the extension host's stop in packages/desktop/src/main/extension/index.ts. Shutdown.run is Effect.cached over the registered set with concurrency: "unbounded", and it only resolves when every effect resolves; a step that logs end and then hangs still holds the aggregate open.
Note that storage.flush is separate: it is registered on app.on("before-quit", …) in packages/desktop/src/main/storage/index.ts, so it fires on both quits and is the routine behind #44239. It is worth logging separately for the same reason.
Your quit-guard is a real fix and orthogonal to this: window-restore-skipped with windows: 0 is what activate should do while quitting (lifecycle/index.ts restores windows from activate with no quit check). Keep it — it removes the dead window; it just does not explain the surviving process.
Thanks for reviewing the captures. Before I build another version, could you review these records in my existing evidence attachment? existing evidence attachment?
My logging-only cycle02 trace: evidence/runtime/logging-only/cycle02/main.log:303–324 (noncontiguous selected-field excerpts; complete records are in that file).
Record
Selected fields
name: 'shutdown.step:extensions.stop'
phase: 'end', elapsedMs: 4
name: 'shutdown.total'
phase: 'end', elapsedMs: 4
name: 'shutdown.wait'
phase: 'end', elapsedMs: 4
event: 'quit-reissue:before-quit'
shutdownReady: true
event: 'before-quit'
shutdownReady: true
My corresponding instrumentation sites in that attachment:
I reproduced this on the latest version on my MacBook Pro: the Cmd+Q quit leaves a process behind. That process sticks around, then a reopen of OpenCode hangs, and another Cmd+Q ends the processes cleanly and another launch works normally. I then built tag v2.1.2 (a517687fde98a7329ada3ae644a01a9fc6c4e99e, Electron 44.9.1) with a probe (opencode-probe/probe.patch; build: opencode-probe/build.sh, opencode-probe/electron-builder.probe.ts; ledger: "Probe build provenance") and ran the probe build through Cmd+Q and reopen. I also ran it under lldb and ran a standalone Electron 44.9.1 app. Everything below is in the attached evidence.zip; every value is in its ledger.md.
For the keyboard ⌘Q, opencode-probe/runs/keyboard-manual/quit-probe.log (last record window:show 42264) has app:window-all-closed and no app:will-quit; under lldb, the two hits up to the capture point on strb w9, [x19, #0x18] in Browser::Quit() read w9 = 0x00000001, then w9 = 0x00000000.
Probe build: quit Apple event vs keyboard ⌘Q (first quit of each process)
Record (ms)
Quit Apple event, pid 56985
Keyboard ⌘Q, pid 58731
app.quit()
62180
21008
window:closed
62202
21123
app:will-quit
62202
—
process:exit
62204
—
app.quit() returned
62213
21101
app:window-all-closed
—
21124
later in the same pid
—
app:activate 42122, window:show 42264
Keyboard column: opencode-probe/runs/keyboard-manual/quit-probe.log (last record window:show 42264; copy span: README, "Disclosures"). In both, app:before-quit, app.quit(), app:before-quit, window:close come in that order; contents:before-unload-fired has proceed=true; window:close:settled has defaultPrevented=false; the two app:before-quit:settled records have defaultPrevented=true then false. Record writers: probe.patch:79-151 (:settled records: :80). Ledger: "Keyboard Cmd+Q I pressed myself", "Quit Apple event (osascript)", "Counts of will-quit / window-all-closed / process:exit", "Probe record writers".
Comment 6269060618, a reply to my comment 6268748333 (my logging-only and quit-guard builds, build/version records 2.0.32), says "the first quit's completion path did not reach app.quit()" (ledger: "Comment 6268748333", "Comment 6269060618"). In the 2.1.2 probe build, the keyboard run's app.quit() record at ms=21008 has second stack frame at n.~effect/Effect/args (file://…/app.asar/out/main/desktop-sRS9a7pu.js:48:7240) (…: build path elided); in the packaged app.asar, line 48 columns 7181-7260 are l)},m=e=>{ff(),!s&&(e.preventDefault(),t(c.pipe(mn(I(()=>R.quit())))))},h=()=>{f and column 7240 is q (ledger: "app.quit() wrapper record", "Minified frame").
Ledger: "lldb keyboard run: breakpoint definitions", "lldb keyboard run: hits up to the capture point".
At the capture point, ps -p 63361 -o pid=,stat=,lstart= printed 63361 SX Wed Oct 21 09:01:19 2026 (trailing spaces omitted; opencode-probe/runs/lldb-keyboard/ps-after-cmdq.txt; README, "Timestamps"; ledger: "lldb keyboard run: process state"). Offsets: FUNC records of electron-v44.9.1-darwin-arm64-symbols.zip and the disassembly of Browser::Quit() (ledger: "Electron symbols", "Disassembly"); script: lldb/lldb-capture.sh. A run under 13 function-entry breakpoints (pid 60208, opencode-probe/runs/lldb-functions-keyboard/) hit, in order: Browser::Quit, Browser::Quit, WindowList::CloseAllWindows, NativeWindowMac::Close, NativeWindow::NotifyWindowCloseButtonClicked, BrowserWindow::OnCloseButtonClicked, NativeWindowMac::CloseImmediately, WindowList::RemoveWindow, Browser::OnWindowAllClosed (ledger: "lldb run pid 60208: functions", "Electron symbols"). Its first Browser::Quit hit has frames #1-#5-[NSApplication(NSResponder) sendAction:to:from:], -[NSMenuItem _corePerformAction:], _NSMenuPerformActionWithHighlighting, -[NSMenu _performKeyEquivalentForItemAtIndex:], -[NSMenu performKeyEquivalent:] (symbol names only; addresses, AppKit prefix and + N offsets omitted; ledger: "first Browser::Quit hit").
Omitted from the last row: microtask-keyboard.log lines at 12970–12971 ms. repro/run.sh:22-25: process check and kill. Ledger: "Repro results (all four runs)", "run.sh: process check and kill".
Your logging-only trace corrects me: the re-issue does land — quit-reissue:before-quit with shutdownReady: true, and the second before-quit:settled has defaultPrevented: false. So my "the re-issue never lands" reading was wrong. Those boundaries were the right ones.
Your probe's two columns then give the answer:
Record
Quit Apple event (56985)
Keyboard ⌘Q (58731)
window:closed
62202
21123
app:will-quit
62202
—
process:exit
62204
—
app:window-all-closed
—
21124
Both run app.quit() → before-quit (prevented) → app.quit() (passes) → window:close. The Apple event then reaches will-quit and exits. The keyboard path never reaches will-quit — it falls through to window-all-closed, which on macOS does nothing (lifecycle/index.ts only quits on non-darwin). That is exactly the half-quit state, and it explains why the second ⌘Q works: by then no quit is in flight.
So the defect is in the re-entrant quit, not in the shutdown: calling app.quit() a second time from the async callback while the first quit's window close is in flight leaves Electron with no quit in progress after the window closes.
Two candidate fixes, and your probe can tell them apart cheaply:
A: after the shutdown resolves, call app.exit(0) instead of app.quit(). Forced exit, no will-quit — acceptable here because Shutdown.run is the work will-quit would otherwise do.
B: don't cancel the first quit at all; drop the before-quit preventDefault and let will-quit run the shutdown. Risk: the window closes before the shutdown finishes.
A is a one-line probe and matches your logging-only result (4 ms). If A shows no half-quit, the fix is that line.
Would you run A? It is cheaper than another boundary.
I ran A. The build is tag v2.1.2 + the same probe.patch as my earlier attachment + option-a.patch (packages/desktop/src/main/lifecycle/index.ts:101 at a517687fde98a7329ada3ae644a01a9fc6c4e99e) (ledger: "option-a.patch", "probe.patch in this bundle vs probe.patch in the earlier bundle", "Option A build provenance"). The control is my earlier probe build (tag v2.1.2 + probe.patch only). Both builds print 2.1.2, ai.opencode.desktop and Electron Framework UUID 4C4C44B3-5555-3144-A1D1-D61C2A2E3B66; their app.asar SHA-256 hashes are 8a5aff63f36c0a0cc157d443a8a7dd804f22d9a4d42e87e65a8c7abbfa7261ec (Option A) and fc0d7824b720ed471106e1a31224af67ee85e96204762727fda3908e5e317e94 (control) (ledger: "Build identities", "Control app.asar hash in the earlier bundle ledger").
My Cmd+Q key events here were sent by the computer-use feature of the Claude desktop app, not pressed by hand. Each call, with the times it was issued and returned, is in runs/agent-calls.jsonl (ledger: "Tool calls that sent each Cmd+Q, and my Bash commands containing osascript or run.sh"; README, "Runs").
First quit of each process
Build / trigger
pid
First app:before-quitt
Quit record (ms)
app:will-quit
app:window-all-closed
process:exit
Next snapshot (UTC): process lines matching <app>/Contents/MacOS
control / keyboard ⌘Q, cycle01
87535
2026-10-22T01:48:17.433Z
app.quit() 35235
—
35350
—
2026-10-22T01:48:31Z: 87535
control / keyboard ⌘Q, cycle02
99388
2026-10-22T02:05:06.124Z
app.quit() 34476
—
34589
—
2026-10-22T02:05:19Z: 99388
control / keyboard ⌘Q, cycle03
1027
2026-10-22T02:06:02.043Z
app.quit() 34487
—
34505
—
2026-10-22T02:06:15Z: 1027
A / keyboard ⌘Q, cycle01
91049
2026-10-22T01:49:21.054Z
app.exit() 33277
—
—
33278
2026-10-22T01:49:33Z: none
A / keyboard ⌘Q, cycle02
92335
2026-10-22T01:50:15.044Z
app.exit() 34710
—
—
34711
2026-10-22T01:50:28Z: none
A / keyboard ⌘Q, cycle03
92842
2026-10-22T01:51:03.474Z
app.exit() 34549
—
—
34550
2026-10-22T01:51:18Z: none
A / quit Apple event
93566
2026-10-22T01:51:51.238Z
app.exit() 32151
—
—
32153
2026-10-22T01:51:57Z: none
A / quit Apple event after reopen
94059
2026-10-22T01:52:12.494Z
app.exit() 15032
—
—
15034
2026-10-22T01:52:20Z: none
Records: quit-probe.log files under runs/; record writers: opencode-option-a/probe.patch:79-151; snapshots: ps-*.txt files under runs/; snap: runs/snap.sh, runs/option-a-applescript/cycle.sh:5 (ledger: "Control (earlier probe build), keyboard Cmd+Q", "Option A, keyboard Cmd+Q", "Option A, quit Apple event", the matching "process tables" entries, "snap.sh", "Probe record writers in probe.patch", and "Counts per log and pid"). Each Option A app.exit() record has "code":0 and a second stack frame ending desktop-lH9jttwV.js:48:7240); in that build's app.asar, line 48 columns 7181-7262 are l)},m=e=>{ff(),!s&&(e.preventDefault(),t(c.pipe(mn(I(()=>R.exit(0))))))},h=()=>{ff (ledger: "app.exit() records", "Minified frame").
After the quit Apple event in each control cycle, the records for its process include app:will-quit at ms=55916, ms=47628, ms=47790 and process:exit code=0 at ms=55919, ms=47631, ms=47793 (pids 87535, 99388, 1027; ledger: "Control: records after the quit Apple event that ended it", "Tool calls that sent each Cmd+Q, and my Bash commands containing osascript or run.sh").
Event names in each control quit-probe.log and in no Option A quit-probe.log: app.quit(), app.quit() returned, app:window-all-closed, contents:before-unload-fired, contents:close, window:close, window:close:settled (ledger: "Event names in each control quit-probe.log (after-Cmd+Q copy) that appear in no Option A quit-probe.log"). In each Option A log, contents:destroyed comes after process:exit (ledger: "Option A, keyboard Cmd+Q: event sequence", "Option A, quit Apple event (cycle.sh): event sequence").
Third column: log lines without their [Nms] prefix and ready=/windows= fields. Omitted from the last row: microtask-keyboard.log lines at [9946ms] (README, "Disclosures"; ledger: "Repro results (three runs)").
Not examined
The SIGINT/SIGTERM path (lifecycle/index.ts:125)
The relaunch path (lifecycle/index.ts:65-77)
Completion of prepareToRestart (lifecycle/index.ts:37) before app.exit(0)
Processes other than those matching <app>/Contents/MacOS after quit
Reopen from the Dock after a keyboard ⌘Q of the Option A build
Cmd+Q pressed by hand on the Option A build
Windows and Linux; Electron versions other than 44.9.1
Evidence: evidence.zip (README.md manifest with hashes, redaction and disclosures; ledger.md).
A settles it: every Option A cycle exits — process:exit at 33278 / 34711 / 34550 / 32153 / 15034, and the ps snapshot has no matching process — while all three control cycles survive with window-all-closed and no will-quit.
The standalone repro is the cleanest possible statement of the cause: on a plain Electron 44.9.1 app, requit=microtask (re-issue app.quit() from a microtask) reproduces the hang, and requit=exit does not. That is an Electron re-entrancy bug, not something in our lifecycle code. Which also means my earlier "the re-issue never lands" framing was wrong twice over: it lands, and Electron then abandons the quit.
So the fix is the one line: after prepareToRestart resolves, app.exit(0) instead of app.quit().
Two things to settle before that becomes a patch:
will-quit handlers.app.exit(0) skips them. lifecycle/index.ts already runs shutdown.run from will-quit, and prepareToRestart is the same effect, so the lifecycle's own work is covered — but anything else registered on will-quit, inside or outside packages/desktop, would stop running. Worth a grep first.
The paths you listed as not examined.SIGINT/SIGTERM (lifecycle/index.ts:125) and the relaunch path (:65-77) go through the same prepareToRestart.pipe(ensuring(() => app.quit())). Same re-entrancy shape, so they likely want the same change.
I can carry the patch if you would rather not — you have done the hard part. Either way the issue now has a proven cause and a proven fix.
In my test build, the main process was gone after the quit in 8 of 8 app.exit(0) runs and 7 of 7 setTimeout(() => app.quit(), 0) runs, against 4 of 9 with the re-issue as released. None of the 8 app.exit(0) processes logged runIpc:will-quit, finalizer:storage:end or desktop:scope-closed. SIGTERM/SIGINT (4 runs) and Restart (2 runs) ended the process in quit mode.
Effect.addFinalizer sites in packages/desktop/src/main: storage/index.ts:40-46 (storage.close() at :45), ipc.ts:85, ipc-transport.ts:93, lifecycle/index.ts:147, extension/index.ts:105.
Searched by: git grep for on|once|addListener|prependListener|off followed by a quoted will-quit|before-quit|quit, over the whole repository at a517687; \.(on|once|addListener|prependListener|prependOnceListener)\( followed by a quoted quit|will-quit|before-quit, over all JS files of the 2.1.2 release app.asar; every before-quit occurrence in the repository; every will-quit and every before-quit occurrence in all JS files of the 2.1.2 release app.asar (scanned 6740 of 6743 js files; the other 3 are directories); addFinalizer|acquireRelease in packages/desktop/src/main, tests excluded. Outside reach: native code; Electron's own listeners; quit listeners registered with a non-literal event name; scopes outside packages/desktop/src/main; main-process code of installed extensions (extension/host.ts:295-306, extension/module.ts:24-33).
2. Test build: the re-issue at lifecycle/index.ts:101 switched per launch
quit = as released, exit = app.exit(0), defer = setTimeout(() => app.quit(), 0). Main process not alive in the snapshot finish.sh took after the trigger:
Trigger
quit
exit
defer
Cmd+Q (computer-use keystroke), home screen
0 of 3
4 of 4
3 of 3
"Quit OpenCode" menu item (accessibility press)
0 of 2
1 of 1
1 of 1
Quit Apple event
2 of 2
2 of 2
2 of 2
Cmd+Q (computer-use keystroke), session tab open, after typing a draft
2 of 2
1 of 1
1 of 1
Records in the probe log copied in that step, per mode:
Mode
Runs
Exited
app:will-quit
runIpc:will-quit
finalizer:storage:end
desktop:scope-closed
app.exit()
process:exit
quit
9
4
4
4
4
4
0
4
exit
8
8
0
0
0
0
8
8
defer
7
7
7
7
7
7
0
7
Per process over all probe logs, including the cleanup quits and the relaunched processes: processes with app:will-quit: 20, of them with all three records: 20.
Drafts: drafts.sqlite-wal was listed in that snapshot in 5 quit runs, 0 exit, 0 defer. integrity_check returned ok in every run, checked once no app process was running. The typed marker was in drafts.sqlite after the quit in the one draft run per mode that typed through the focused element.
Evidence: ledger "Counts per trigger group and mode", "Per process, all logs …", "Per-run summary", "quit-mode runs … ms"; runs/, runs/agent-calls.jsonl, build/test-build.patch; README "Disclosures".
3. SIGTERM, SIGINT and Restart with the re-issue as released
Trigger
Runs
Exited
Records in order
kill -TERM
2
2
finalizers, desktop:scope-closed, app.exit() code 130, process:exit, then prepareToRestart:exit, app.quit()
The signal runs' app.exit() record has stack frames o.exit, process.exit, desktop-DvdUQ2F-.js:1:22846; that column of the test build's app.asar is inside process.removeListener(`SIGINT`,r),process.removeListener(`SIGTERM`,r),t(e,e=>{(n||e!==0)&&process.exit(e)}). Source pointers: @effect/[email protected]src/NodeRuntime.ts:58-74; OpenCode's handlers: lifecycle/index.ts:123-126, :145-146; Restart: :65-77.
Evidence: ledger "Signal and Restart runs: event order for every run", "Signals: …"; runs/sig*, runs/relaunch-*. "Records in order" leaves out probe:installed, window:show and the created/settled/contents: records; for the signals also window:closed, app:quit, app.quit() returned; for Restart also window:close, app.quit() returned, window:closed, app:quit. "finalizers" = finalizer:ipc-transport, finalizer:lifecycle, finalizer:storage:start, finalizer:storage:end. Stack frames: the first 3 of each record's 9, shortened to the function name or file:line:column.
Not examined
Cmd+Q pressed by hand on the test build
Update install on quit and quitAndInstall
A failing prepareToRestart
The other app.quit()/app.exit()/app.relaunch() callers: extension/index.ts:58-59, lifecycle/configure.ts:47, lifecycle/index.ts:129, windows/early.ts:70, windows/index.ts:50-51, windows/recovery.ts:43
Windows and Linux; Electron versions other than 44.9.1
I'm good with you writing the patch. I'm happy to provide testing or other info.
Evidence: evidence.zip (README.md manifest with hashes, redaction and disclosures; ledger.md).
Activity
opencode-agent commentedon Oct 17, 2026
Thanks for splitting this out with such a detailed write-up and the
samplecapture.I couldn't reproduce it here: it needs the macOS desktop app on a real display (Cmd+Q, the Dock and the activate event), and this sandbox is Linux with no display. A maintainer with a Mac will need to look at it.
One lead from reading the code, not yet confirmed: on quit, the app cancels the first quit request. It then waits for all registered shutdown steps to finish before quitting again (
before-quitinpackages/desktop/src/main/lifecycle/index.ts,Shutdown.runinlifecycle/shutdown.ts), and there is no timeout. If one of those steps never finishes on macOS, the process would stay half-quit as you describe, andpkillwould clear it. If you can reproduce it again, the desktop log from that session (~/Library/Application Support/ai.opencode.desktop/logs/<timestamp>/) covering the moments after Cmd+Q would help pin down which step blocks.Related:
MoeidHeidari commentedon Oct 17, 2026
I tested this on macOS by building and running the desktop app from the dev branch, including stress tests with many sessions and large contexts, but was unable to reproduce the Cmd+Q hang. The app exited cleanly.
Logs from the built dev app (
OpenCode Dev.app, version0.0.0-dev-21105):main.log:[2026-10-17 15:48:33.791] [info] (main) app starting { version: '2.0.32', packaged: true, onboardingTest: false, marks: { entry: 1792244913171, ready: 1792244913443, window: 1792244913612, served: 1792244913754, bundle: 1792244913773, onboarding: 1792244913775, logging: 1792244913791 } }
[2026-10-17 15:48:35.680] [info] (main) [server] Loaded shell environment with -il (49 vars)
[2026-10-17 15:48:35.686] [info] (main) layers ready { marks: { entry: 1792244913171, ready: 1792244913443, window: 1792244913612, served: 1792244913754, bundle: 1792244913773, onboarding: 1792244913775, logging: 1792244913791, storage: 1792244913800, init: 1792244915683, layers: 1792244915686 } }
[2026-10-17 15:48:35.687] [info] (main) main window visible { window: '76d76261-8751-4877-aab8-d1978576c360', shownAt: 1792244913606 }
[2026-10-17 15:48:35.728] [info] (main) starting v2 background service
[2026-10-17 15:48:35.728] [info] (main) v2 CLI executable resolved { bundled: '/Users/moeidheidari/projects/opencode/packages/desktop/dist/mac-arm64/OpenCode Dev.app/Contents/Resources/opencode-cli', packaged: true }
[2026-10-17 15:48:35.729] [info] (main) v2 CLI version bundled { version: '0.0.0-dev-21105' }
[2026-10-17 15:48:35.729] [info] (main) v2 CLI staged executable reused { path: '/Users/moeidheidari/Library/Application Support/ai.opencode.desktop.dev/cli/0.0.0-dev-21105-arm64/opencode-cli', version: '0.0.0-dev-21105' }
[2026-10-17 15:48:35.734] [info] (main) v2 CLI background service ready { version: '0.0.0-dev-21105', probed: true, url: 'http://127.0.0.1:49374', hostname: '127.0.0.1', port: '49374' }
[2026-10-17 15:48:35.828] [warn] (main) failed to start net log { error: { message: 'An error occurred in Effect.tryPromise', cause: 'Error: net::ERR_INSUFFICIENT_RESOURCES', _tag: 'UnknownError', '~effect/Cause/UnknownError': '~effect/Cause/UnknownError' } }
renderer.log:[2026-10-17 15:48:56.029] [debug] [tabs] update persisted session info [object Object]
[2026-10-17 15:48:56.064] [debug] [tabs] update persisted session info [object Object]
[2026-10-17 15:49:02.141] [error] ResizeObserver loop completed with undelivered notifications.
crash.log:[2026-10-17 15:48:36.262] [info] (crash) crash reporter started { path: '/Users/moeidheidari/Library/Application Support/ai.opencode.desktop.dev/Crashpad' }
Could you share more details or the exact conditions that trigger the hang? Additional logs/samples from the failing session would help isolate the blocking shutdown step.
Te-River commentedon Oct 17, 2026
Thanks — a negative result on current dev is genuinely useful here, because it changes what this bug is.
The version split matters: the report is on shipped 2.0.29, and MoeidHeidari built dev (
0.0.0-dev-21105, which reports 2.0.32). So either the hang was fixed between 2.0.29 and dev, or it needs the packaged 2.0.29 app rather than a dev build.The bot's lead points at the same place:
before-quitcancels the first quit, thenShutdown.runwaits for every registered step with no timeout. A step that never finishes leaves exactly the half-quit state described.#44239 is a candidate for that step: it reports a
DrizzleError: Failed to run the query 'begin'from the draft-flush routine on every quit — a shutdown-ordering race where the final flush runs a transaction while the SQLite connection is being torn down. If that step throws or blocks there, the quit would hang with no timeout to cut it short.Next step, if richardding can: retest on 2.0.30 or current dev, and if it still reproduces, attach the desktop log from that session (
~/Library/Application Support/ai.opencode.desktop/logs/<timestamp>/) covering the moments after Cmd+Q — that names the step that blocks. If it does not reproduce on a newer build, this can close as fixed between 2.0.29 and dev.kcharlan commentedon Oct 18, 2026
I reproduced the hang. It usually happens shortly after I close it.
My installed build:
evidence/ledger.md, “Environment and installed build” (2.0.32,ai.opencode.desktop,macOS 27.0.1 (26A434),ARM64).My captured process observations:
evidence/ledger.md, “Process tables”, “Processes during the reproduced hang”, and “UI records”.18606still present6289218606still present; renderer218026289218606absent from the targetedpsoutput628922210962892My UI records:
evidence/runtime/reproduction-trace.json;evidence/ledger.md, “UI records”. My earlier loading-window screenshot:evidence/runtime/original-loading-window.jpg.My evidence manifest:
evidence/README.mdandevidence/manifest.json.opencode-56394-evidence-2026-10-18.zip
Te-River commentedon Oct 19, 2026
Thanks — this is the best evidence on the issue so far, and the UI records plus the process table together pin the sequence down.
From your captures:
16:54:36Cmd+Q →16:54:41noWindowsAvailable, but18606is still alive at16:54:49withELAPSED 02:03.16:54:52getApp→16:55:03a window exists again (oc://renderer/index.html) — created on that same process, which is the stalled reopen.16:55:34a second Cmd+Q →App quit.So the first quit closes the window and leaves the process running, and the activate path then builds a window on a process that is already quitting. That is the shape to fix; the renderer stall is downstream of it.
Two things from the bundle that narrow it:
The main process is not blocked. The innermost frames of
opencode-reproduced-main-hang-sample.txtare-[NSApplication run]→_DPSNextEvent→_BlockUntilNextEventMatchingListInMode→__CFRunLoopRun→mach_msg2_trap— the normal NSApp event loop. (The outer symbols do not resolve — the offsets look like a slide/dSYM mismatch — so treat the middle of that stack as unreliable; only the innermost frames are meaningful.) A cancelled quit that is never re-issued, or a shutdown step awaiting a promise that never settles, fits that. A blocked main thread does not.The log cannot name the step. Both
main.logfiles contain no quit or shutdown lines at all — the last entry in the first ismain window visibleat12:49:28, and the second ends on the same line at12:54:53. So theShutdown.runsteps are invisible.What would make the next capture conclusive, if it is cheap on your side: log each shutdown step's start and finish (and the total wait), and skip window creation while a quit is in flight. Then the next reproduction names the step that never finishes instead of leaving it to inference.
One question: the app is connected to a Homebrew
opencode serve --service(/opt/homebrew/Cellar/opencode/2.0.32/bin/opencode serve --service, PID 62892, started 07:40 — five hours before the app). Is that the service the quit path waits on?kcharlan commentedon Oct 19, 2026
My custom logging-only and quit-guard builds:
evidence/ledger.md,source-identities/build-identities. My first-quit records (shutdown.total,shutdown.wait,phase: 'end'):first-quit-end-lines; my targeted process snapshots:process-comparison.My logging-only reopen screenshots:
screenshots-and-samples. My quit-guard reopen records (event: 'window-restore-skipped',reason: 'quitting',windows: 0):reopen-steps; my reopen errors (getApp, AX read;timeoutReachedsuffix):cua-results.total/waitelapsedMsps2026-10-19 08:42:55.6234/491110/9111091110absent2026-10-19 08:47:41.8904/492940/9294092940absent2026-10-19 08:49:40.8814/493639/9363993639absent2026-10-19 09:14:38.6993/31234/12341234absent2026-10-19 09:16:22.6484/42015/20152015absent2026-10-19 09:18:20.2424/42680/26802680absentMy raw records and UTC snapshot intervals:
evidence/ledger.md,first-quit-end-linesandprocess-comparison. My action/error records:cua-results. My lifecycle records:reopen-steps.My AX error excerpts:
cua-results— first quit:noWindowsAvailable; logging-only second quit:App quit; quit-guard second quit, cycle01/cycle03:timeoutReached; cycle02:NSCocoaErrorDomain Code=256,NSOSStatusErrorDomain Code=-600,procNotFound(noncontiguous excerpts; complete messages incua-results).My service metadata (
62892,2.0.32,http://127.0.0.1:49374) and service-ready fields (version: '2.0.32',probed: true):service-ready. My process records (/opt/homebrew/Cellar/opencode/2.0.32/bin/opencode; lstart:Sun Oct 18 07:40:07 2026; PID62892):service-rows,process-comparison.My build/version records (
2.0.32):source-identities/build-identities. My build/toolchain, CLI/signing, protocol/updater records and frozen source/emitter pointers:evidence/README.mdandsource-emitter-pointers.My logging-only cycle01 sample records and command intervals:
sample-origins. My guard cycle03 action and elision records:cua-results; my step records:evidence/README.md.My reopen images:
evidence/runtime/logging-only/cycle01/reopen-persistent.jpg,evidence/runtime/logging-only/cycle02/reopen-persistent.jpg,evidence/runtime/logging-only/cycle03/reopen-persistent.jpg. My screenshot/origin records:screenshots-and-samples.My retained main/crash/netlog, CUA, known-PID process and origin/hash records:
evidence/README.md,evidence/manifest.json. My command/output ledger and replay:evidence/ledger.md;node verify.mjsfromevidence/.opencode-56394-instrumented-evidence-2026-10-19.zip
Te-River commentedon Oct 19, 2026
The two builds are exactly the experiment, and the logging-only result is the important one: the shutdown completes in 4 ms. That rules the shutdown steps out as the blocker — the "no timeout" lead does not apply here.
What is left is the quit re-issue. In
packages/desktop/src/main/lifecycle/index.ts:Your data is consistent with the re-issued
app.quit()never landing: the second Cmd+Q takes theshutdownReadybranch and quits, which is the same path the re-issue would take. So the difference is not in the quit handler — it is that the first quit's completion path did not reachapp.quit().That moves the next probe from the individual steps to the aggregate: log
Shutdown.run's own completion, and log each effect registered throughShutdown.add. There is exactly one today — the extension host'sstopinpackages/desktop/src/main/extension/index.ts.Shutdown.runisEffect.cachedover the registered set withconcurrency: "unbounded", and it only resolves when every effect resolves; a step that logsendand then hangs still holds the aggregate open.Note that
storage.flushis separate: it is registered onapp.on("before-quit", …)inpackages/desktop/src/main/storage/index.ts, so it fires on both quits and is the routine behind #44239. It is worth logging separately for the same reason.Your quit-guard is a real fix and orthogonal to this:
window-restore-skippedwithwindows: 0is whatactivateshould do while quitting (lifecycle/index.tsrestores windows fromactivatewith no quit check). Keep it — it removes the dead window; it just does not explain the surviving process.kcharlan commentedon Oct 19, 2026
Thanks for reviewing the captures. Before I build another version, could you review these records in my existing evidence attachment? existing evidence attachment?
My logging-only cycle02 trace:
evidence/runtime/logging-only/cycle02/main.log:303–324(noncontiguous selected-field excerpts; complete records are in that file).name: 'shutdown.step:extensions.stop'phase: 'end',elapsedMs: 4name: 'shutdown.total'phase: 'end',elapsedMs: 4name: 'shutdown.wait'phase: 'end',elapsedMs: 4event: 'quit-reissue:before-quit'shutdownReady: trueevent: 'before-quit'shutdownReady: trueMy corresponding instrumentation sites in that attachment:
evidence/provenance/logging-only/source/custom/packages/desktop/src/main/lifecycle/shutdown.ts:19–42evidence/provenance/logging-only/source/custom/packages/desktop/src/main/extension/index.ts:97–110evidence/provenance/logging-only/source/custom/packages/desktop/src/main/lifecycle/index.ts:106–128Do these sites cover the aggregate and registered-effect logging you intended, or is there a different boundary you would like me to capture?
Would you still like another instrumented run with separate
storage.flushlogging? Are there any additional boundaries you would like included?kcharlan commentedon Oct 21, 2026
I reproduced this on the latest version on my MacBook Pro: the Cmd+Q quit leaves a process behind. That process sticks around, then a reopen of OpenCode hangs, and another Cmd+Q ends the processes cleanly and another launch works normally. I then built tag
v2.1.2(a517687fde98a7329ada3ae644a01a9fc6c4e99e, Electron44.9.1) with a probe (opencode-probe/probe.patch; build:opencode-probe/build.sh,opencode-probe/electron-builder.probe.ts; ledger: "Probe build provenance") and ran the probe build through Cmd+Q and reopen. I also ran it underlldband ran a standalone Electron44.9.1app. Everything below is in the attachedevidence.zip; every value is in itsledger.md.For the keyboard ⌘Q,
opencode-probe/runs/keyboard-manual/quit-probe.log(last recordwindow:show42264) hasapp:window-all-closedand noapp:will-quit; underlldb, the two hits up to the capture point onstrb w9, [x19, #0x18]inBrowser::Quit()readw9 = 0x00000001, thenw9 = 0x00000000.Probe build: quit Apple event vs keyboard ⌘Q (first quit of each process)
ms)app.quit()window:closedapp:will-quitprocess:exitapp.quit() returnedapp:window-all-closedapp:activate42122,window:show42264Keyboard column:
opencode-probe/runs/keyboard-manual/quit-probe.log(last recordwindow:show42264; copy span: README, "Disclosures"). In both,app:before-quit,app.quit(),app:before-quit,window:closecome in that order;contents:before-unload-firedhasproceed=true;window:close:settledhasdefaultPrevented=false; the twoapp:before-quit:settledrecords havedefaultPrevented=truethenfalse. Record writers:probe.patch:79-151(:settledrecords::80). Ledger: "Keyboard Cmd+Q I pressed myself", "Quit Apple event (osascript)", "Counts of will-quit / window-all-closed / process:exit", "Probe record writers".Comment 6269060618, a reply to my comment 6268748333 (my logging-only and quit-guard builds, build/version records
2.0.32), says "the first quit's completion path did not reachapp.quit()" (ledger: "Comment 6268748333", "Comment 6269060618"). In the 2.1.2 probe build, the keyboard run'sapp.quit()record atms=21008has second stack frameat n.~effect/Effect/args (file://…/app.asar/out/main/desktop-sRS9a7pu.js:48:7240)(…: build path elided); in the packagedapp.asar, line 48 columns 7181-7260 arel)},m=e=>{ff(),!s&&(e.preventDefault(),t(c.pipe(mn(I(()=>R.quit())))))},h=()=>{fand column 7240 isq(ledger: "app.quit() wrapper record", "Minified frame").lldb(pid 63361,opencode-probe/runs/lldb-keyboard/)0x2bfdfc0electron::Browser::Quit()entry; reads[x0+0x18]0x00, 2nd0x000x2bfe004strb w9, [x19, #0x18]inBrowser::Quit(); readsw9,x19w9 = 0x00000001, 4thw9 = 0x00000000;x19 = 0x0000011c002c0000both0x2bfeae8electron::Browser::OnWindowAllClosed()entry; reads[x0+0x18]0x000x2bfe034electron::Browser::NotifyAndShutdown()entry0x2bfead8electron::Browser::OnWindowCloseCancelled()entryLedger: "lldb keyboard run: breakpoint definitions", "lldb keyboard run: hits up to the capture point".
At the capture point,
ps -p 63361 -o pid=,stat=,lstart=printed63361 SX Wed Oct 21 09:01:19 2026(trailing spaces omitted;opencode-probe/runs/lldb-keyboard/ps-after-cmdq.txt; README, "Timestamps"; ledger: "lldb keyboard run: process state"). Offsets:FUNCrecords ofelectron-v44.9.1-darwin-arm64-symbols.zipand the disassembly ofBrowser::Quit()(ledger: "Electron symbols", "Disassembly"); script:lldb/lldb-capture.sh. A run under 13 function-entry breakpoints (pid 60208,opencode-probe/runs/lldb-functions-keyboard/) hit, in order:Browser::Quit,Browser::Quit,WindowList::CloseAllWindows,NativeWindowMac::Close,NativeWindow::NotifyWindowCloseButtonClicked,BrowserWindow::OnCloseButtonClicked,NativeWindowMac::CloseImmediately,WindowList::RemoveWindow,Browser::OnWindowAllClosed(ledger: "lldb run pid 60208: functions", "Electron symbols"). Its firstBrowser::Quithit has frames #1-#5-[NSApplication(NSResponder) sendAction:to:from:],-[NSMenuItem _corePerformAction:],_NSMenuPerformActionWithHighlighting,-[NSMenu _performKeyEquivalentForItemAtIndex:],-[NSMenu performKeyEquivalent:](symbol names only; addresses,AppKitprefix and+ Noffsets omitted; ledger: "first Browser::Quit hit").Standalone Electron 44.9.1 app (
repro/)repro/main.js:18-24(before-quithandler;:22microtaskand:23timeoutre-issue)../run.sh <microtask|timeout> <applescript|keyboard> <outfile>(ledger: "Repro source lines"):requittriggerwindow closed.resultline (run.sh:21-27)timeoutapplescriptwill-quit,quit5s after before-quit: pid 62515 exitedmicrotaskapplescriptwill-quit,quit,app.quit() returned5s after before-quit: pid 62560 exitedtimeoutkeyboardwill-quit,quit5s after before-quit: pid 62606 exitedmicrotaskkeyboardwindow-all-closed5s after before-quit: pid 63177 still runningOmitted from the last row:
microtask-keyboard.loglines at 12970–12971 ms.repro/run.sh:22-25: process check andkill. Ledger: "Repro results (all four runs)", "run.sh: process check and kill".Source pointers
a517687packages/desktop/src/main/lifecycle/index.ts:96-102beforeQuit:37,:141-142prepareToRestart;before-quit/will-quitregistratione02265bshell/browser/browser.cc:120-132Browser::Quit():289-301,:303-307,:309-314,:316-324NotifyAndShutdown(),HandleBeforeQuit(),OnWindowCloseCancelled(),OnWindowAllClosed()e02265bshell/browser/mac/electron_application.mm:92-101terminate:Not examined
lldbdesktop-sRS9a7pu.js:48:7240was built fromapp.quit()call runs inEvidence:
evidence.zip(README.mdmanifest with hashes, spans and redaction;ledger.md).evidence.zip
Te-River commentedon Oct 22, 2026
Your logging-only trace corrects me: the re-issue does land —
quit-reissue:before-quitwithshutdownReady: true, and the secondbefore-quit:settledhasdefaultPrevented: false. So my "the re-issue never lands" reading was wrong. Those boundaries were the right ones.Your probe's two columns then give the answer:
window:closedapp:will-quitprocess:exitapp:window-all-closedBoth run
app.quit()→before-quit(prevented) →app.quit()(passes) →window:close. The Apple event then reacheswill-quitand exits. The keyboard path never reacheswill-quit— it falls through towindow-all-closed, which on macOS does nothing (lifecycle/index.tsonly quits on non-darwin). That is exactly the half-quit state, and it explains why the second ⌘Q works: by then no quit is in flight.So the defect is in the re-entrant quit, not in the shutdown: calling
app.quit()a second time from the async callback while the first quit's window close is in flight leaves Electron with no quit in progress after the window closes.Two candidate fixes, and your probe can tell them apart cheaply:
app.exit(0)instead ofapp.quit(). Forced exit, nowill-quit— acceptable here becauseShutdown.runis the workwill-quitwould otherwise do.before-quitpreventDefault and letwill-quitrun the shutdown. Risk: the window closes before the shutdown finishes.A is a one-line probe and matches your logging-only result (4 ms). If A shows no half-quit, the fix is that line.
Would you run A? It is cheaper than another boundary.
kcharlan commentedon Oct 22, 2026
I ran A. The build is tag
v2.1.2+ the sameprobe.patchas my earlier attachment +option-a.patch(packages/desktop/src/main/lifecycle/index.ts:101ata517687fde98a7329ada3ae644a01a9fc6c4e99e) (ledger: "option-a.patch", "probe.patch in this bundle vs probe.patch in the earlier bundle", "Option A build provenance"). The control is my earlier probe build (tagv2.1.2+probe.patchonly). Both builds print2.1.2,ai.opencode.desktopand Electron Framework UUID4C4C44B3-5555-3144-A1D1-D61C2A2E3B66; theirapp.asarSHA-256 hashes are8a5aff63f36c0a0cc157d443a8a7dd804f22d9a4d42e87e65a8c7abbfa7261ec(Option A) andfc0d7824b720ed471106e1a31224af67ee85e96204762727fda3908e5e317e94(control) (ledger: "Build identities", "Control app.asar hash in the earlier bundle ledger").My Cmd+Q key events here were sent by the computer-use feature of the Claude desktop app, not pressed by hand. Each call, with the times it was issued and returned, is in
runs/agent-calls.jsonl(ledger: "Tool calls that sent each Cmd+Q, and my Bash commands containing osascript or run.sh"; README, "Runs").First quit of each process
app:before-quittms)app:will-quitapp:window-all-closedprocess:exit<app>/Contents/MacOS2026-10-22T01:48:17.433Zapp.quit()352352026-10-22T01:48:31Z: 875352026-10-22T02:05:06.124Zapp.quit()344762026-10-22T02:05:19Z: 993882026-10-22T02:06:02.043Zapp.quit()344872026-10-22T02:06:15Z: 10272026-10-22T01:49:21.054Zapp.exit()332772026-10-22T01:49:33Z: none2026-10-22T01:50:15.044Zapp.exit()347102026-10-22T01:50:28Z: none2026-10-22T01:51:03.474Zapp.exit()345492026-10-22T01:51:18Z: none2026-10-22T01:51:51.238Zapp.exit()321512026-10-22T01:51:57Z: none2026-10-22T01:52:12.494Zapp.exit()150322026-10-22T01:52:20Z: noneRecords:
quit-probe.logfiles underruns/; record writers:opencode-option-a/probe.patch:79-151; snapshots:ps-*.txtfiles underruns/;snap:runs/snap.sh,runs/option-a-applescript/cycle.sh:5(ledger: "Control (earlier probe build), keyboard Cmd+Q", "Option A, keyboard Cmd+Q", "Option A, quit Apple event", the matching "process tables" entries, "snap.sh", "Probe record writers in probe.patch", and "Counts per log and pid"). Each Option Aapp.exit()record has"code":0and a second stack frame endingdesktop-lH9jttwV.js:48:7240); in that build'sapp.asar, line 48 columns 7181-7262 arel)},m=e=>{ff(),!s&&(e.preventDefault(),t(c.pipe(mn(I(()=>R.exit(0))))))},h=()=>{ff(ledger: "app.exit() records", "Minified frame").After the quit Apple event in each control cycle, the records for its process include
app:will-quitatms=55916,ms=47628,ms=47790andprocess:exit code=0atms=55919,ms=47631,ms=47793(pids 87535, 99388, 1027; ledger: "Control: records after the quit Apple event that ended it", "Tool calls that sent each Cmd+Q, and my Bash commands containing osascript or run.sh").Event names in each control
quit-probe.logand in no Option Aquit-probe.log:app.quit(),app.quit() returned,app:window-all-closed,contents:before-unload-fired,contents:close,window:close,window:close:settled(ledger: "Event names in each control quit-probe.log (after-Cmd+Q copy) that appear in no Option A quit-probe.log"). In each Option A log,contents:destroyedcomes afterprocess:exit(ledger: "Option A, keyboard Cmd+Q: event sequence", "Option A, quit Apple event (cycle.sh): event sequence").Standalone Electron 44.9.1 app
REQUIT=exit:repro/main.js:25-29(ledger: "Repro source lines", "Repro files").requittriggerbefore-quit.resultline (repro/run.sh:23-28)exitapplescriptapp.exit(0),window closed,quit5s after before-quit: pid 84646 exitedexitkeyboardapp.exit(0),window closed,quit5s after before-quit: pid 84958 exitedmicrotaskkeyboardreissue app.quit(),before-quit,window close,app.quit() returned,window closed,window-all-closed5s after before-quit: pid 85610 still runningThird column: log lines without their
[Nms]prefix andready=/windows=fields. Omitted from the last row:microtask-keyboard.loglines at[9946ms](README, "Disclosures"; ledger: "Repro results (three runs)").Not examined
SIGINT/SIGTERMpath (lifecycle/index.ts:125)lifecycle/index.ts:65-77)prepareToRestart(lifecycle/index.ts:37) beforeapp.exit(0)<app>/Contents/MacOSafter quitEvidence:
evidence.zip(README.mdmanifest with hashes, redaction and disclosures;ledger.md).evidence.zip
Te-River commentedon Oct 22, 2026
A settles it: every Option A cycle exits —
process:exitat 33278 / 34711 / 34550 / 32153 / 15034, and thepssnapshot has no matching process — while all three control cycles survive withwindow-all-closedand nowill-quit.The standalone repro is the cleanest possible statement of the cause: on a plain Electron 44.9.1 app,
requit=microtask(re-issueapp.quit()from a microtask) reproduces the hang, andrequit=exitdoes not. That is an Electron re-entrancy bug, not something in our lifecycle code. Which also means my earlier "the re-issue never lands" framing was wrong twice over: it lands, and Electron then abandons the quit.So the fix is the one line: after
prepareToRestartresolves,app.exit(0)instead ofapp.quit().Two things to settle before that becomes a patch:
will-quithandlers.app.exit(0)skips them.lifecycle/index.tsalready runsshutdown.runfromwill-quit, andprepareToRestartis the same effect, so the lifecycle's own work is covered — but anything else registered onwill-quit, inside or outsidepackages/desktop, would stop running. Worth a grep first.SIGINT/SIGTERM(lifecycle/index.ts:125) and the relaunch path (:65-77) go through the sameprepareToRestart.pipe(ensuring(() => app.quit())). Same re-entrancy shape, so they likely want the same change.I can carry the patch if you would rather not — you have done the hard part. Either way the issue now has a proven cause and a proven fix.
kcharlan commentedon Oct 22, 2026
In my test build, the main process was gone after the quit in 8 of 8
app.exit(0)runs and 7 of 7setTimeout(() => app.quit(), 0)runs, against 4 of 9 with the re-issue as released. None of the 8app.exit(0)processes loggedrunIpc:will-quit,finalizer:storage:endordesktop:scope-closed.SIGTERM/SIGINT(4 runs) and Restart (2 runs) ended the process inquitmode.1. Quit-event listeners at
a517687packages/desktop/src/main/desktop.ts:37will-quit(once)runIpccallback;Effect.scopedat:50packages/desktop/src/main/lifecycle/index.ts:142will-quitwillQuit(:104-107)packages/desktop/src/main/lifecycle/index.ts:141before-quitbeforeQuit(:96-102)packages/desktop/src/main/storage/index.ts:37before-quitstorage.flush[email protected]out/ElectronAppAdapter.js:38quit(once)onQuitEffect.addFinalizersites inpackages/desktop/src/main:storage/index.ts:40-46(storage.close()at:45),ipc.ts:85,ipc-transport.ts:93,lifecycle/index.ts:147,extension/index.ts:105.Searched by:
git grepforon|once|addListener|prependListener|offfollowed by a quotedwill-quit|before-quit|quit, over the whole repository ata517687;\.(on|once|addListener|prependListener|prependOnceListener)\(followed by a quotedquit|will-quit|before-quit, over all JS files of the 2.1.2 releaseapp.asar; everybefore-quitoccurrence in the repository; everywill-quitand everybefore-quitoccurrence in all JS files of the 2.1.2 releaseapp.asar(scanned 6740 of 6743 js files; the other 3 are directories);addFinalizer|acquireReleaseinpackages/desktop/src/main, tests excluded. Outside reach: native code; Electron's own listeners;quitlisteners registered with a non-literal event name; scopes outsidepackages/desktop/src/main; main-process code of installed extensions (extension/host.ts:295-306,extension/module.ts:24-33).Evidence: ledger "Survey: …", "Pinned lines: …", "Versions: …";
pinned/,tools/asar-grep.mjs.2. Test build: the re-issue at
lifecycle/index.ts:101switched per launchquit= as released,exit=app.exit(0),defer=setTimeout(() => app.quit(), 0). Main process not alive in the snapshotfinish.shtook after the trigger:quitexitdeferRecords in the probe log copied in that step, per mode:
app:will-quitrunIpc:will-quitfinalizer:storage:enddesktop:scope-closedapp.exit()process:exitquitexitdeferPer process over all probe logs, including the cleanup quits and the relaunched processes:
processes with app:will-quit: 20, of them with all three records: 20.Drafts:
drafts.sqlite-walwas listed in that snapshot in 5quitruns, 0exit, 0defer.integrity_checkreturnedokin every run, checked once no app process was running. The typed marker was indrafts.sqliteafter the quit in the one draft run per mode that typed through the focused element.Evidence: ledger "Counts per trigger group and mode", "Per process, all logs …", "Per-run summary", "quit-mode runs … ms";
runs/,runs/agent-calls.jsonl,build/test-build.patch; README "Disclosures".3.
SIGTERM,SIGINTand Restart with the re-issue as releasedkill -TERMdesktop:scope-closed,app.exit()code130,process:exit, thenprepareToRestart:exit,app.quit()kill -INTprepareToRestart:exit,app.quit(),app:before-quit,app:will-quit,runIpc:will-quit, finalizers,desktop:scope-closed,process:exitThe signal runs'
app.exit()record has stack frameso.exit,process.exit,desktop-DvdUQ2F-.js:1:22846; that column of the test build'sapp.asaris insideprocess.removeListener(`SIGINT`,r),process.removeListener(`SIGTERM`,r),t(e,e=>{(n||e!==0)&&process.exit(e)}). Source pointers:@effect/[email protected]src/NodeRuntime.ts:58-74; OpenCode's handlers:lifecycle/index.ts:123-126,:145-146; Restart::65-77.Evidence: ledger "Signal and Restart runs: event order for every run", "Signals: …";
runs/sig*,runs/relaunch-*. "Records in order" leaves outprobe:installed,window:showand thecreated/settled/contents:records; for the signals alsowindow:closed,app:quit,app.quit() returned; for Restart alsowindow:close,app.quit() returned,window:closed,app:quit. "finalizers" =finalizer:ipc-transport,finalizer:lifecycle,finalizer:storage:start,finalizer:storage:end. Stack frames: the first 3 of each record's 9, shortened to the function name orfile:line:column.Not examined
quitAndInstallprepareToRestartapp.quit()/app.exit()/app.relaunch()callers:extension/index.ts:58-59,lifecycle/configure.ts:47,lifecycle/index.ts:129,windows/early.ts:70,windows/index.ts:50-51,windows/recovery.ts:43I'm good with you writing the patch. I'm happy to provide testing or other info.
Evidence:
evidence.zip(README.mdmanifest with hashes, redaction and disclosures;ledger.md).evidence.zip