Skip to content

[BUG]: macOS desktop: Cmd+Q leaves a half-dead instance, and the next Dock click stalls the renderer #56394

Description

@Te-River

Summary

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.

Environment

  • macOS 27 (Darwin 27.0.0), Apple Silicon
  • OpenCode Desktop 2.0.29 (prod channel, ai.opencode.desktop)

Reproduction (deterministic on the reporter's machine)

  1. Run the desktop app normally.
  2. Cmd+Q.
  3. The Dock icon remains — the app is neither terminated nor in the "run in background" state.
  4. A single click on the Dock icon → the window shows, the renderer stalls.
  5. 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:

Why this is separate from #54296

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

Activity

  1. opencode-agent commented on Oct 17, 2026

    @opencode-agent
    Contributor

    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.

    Related:

  2. MoeidHeidari commented on Oct 17, 2026

    @MoeidHeidari

    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):

    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.

  3. Te-River commented on Oct 17, 2026

    @Te-River
    Author

    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.

  4. kcharlan commented on Oct 18, 2026

    @kcharlan

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

    Observation Desktop PID Background-service PID
    After ⌘Q closed the window 18606 still present 62892
    During the stalled reopen 18606 still present; renderer 21802 62892
    After another ⌘Q 18606 absent from the targeted ps output 62892
    After launching again 22109 62892

    My 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.md and evidence/manifest.json.

    opencode-56394-evidence-2026-10-18.zip

  5. Te-River commented on Oct 19, 2026

    @Te-River
    Author

    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:41 noWindowsAvailable, but 18606 is still alive at 16:54:49 with ELAPSED 02:03.
    • 16:54:52 getApp → 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 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?

  6. kcharlan commented on Oct 19, 2026

    @kcharlan

    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 / wait elapsedMs 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/.

    opencode-56394-instrumented-evidence-2026-10-19.zip

  7. Te-River commented on Oct 19, 2026

    @Te-River
    Author

    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:

    let shutdownReady = false
    const prepareToRestart = shutdown.run.pipe(Effect.ensuring(() => (shutdownReady = true)))
    
    const beforeQuit = (event: Electron.Event) => {
      setAppQuitting()
      if (shutdownReady) return              // a later quit passes through
      event.preventDefault()                 // the first quit is cancelled
      runFork(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.

  8. kcharlan commented on Oct 19, 2026

    @kcharlan

    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:

    • evidence/provenance/logging-only/source/custom/packages/desktop/src/main/lifecycle/shutdown.ts:19–42
    • evidence/provenance/logging-only/source/custom/packages/desktop/src/main/extension/index.ts:97–110
    • evidence/provenance/logging-only/source/custom/packages/desktop/src/main/lifecycle/index.ts:106–128

    Do 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.flush logging? Are there any additional boundaries you would like included?

  9. kcharlan commented on Oct 21, 2026

    @kcharlan

    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").

    lldb (pid 63361, opencode-probe/runs/lldb-keyboard/)

    Bp Offset Site Hits up to the capture point, in order
    1 0x2bfdfc0 electron::Browser::Quit() entry; reads [x0+0x18] 1st 0x00, 2nd 0x00
    2 0x2bfe004 strb w9, [x19, #0x18] in Browser::Quit(); reads w9, x19 3rd w9 = 0x00000001, 4th w9 = 0x00000000; x19 = 0x0000011c002c0000 both
    3 0x2bfeae8 electron::Browser::OnWindowAllClosed() entry; reads [x0+0x18] 5th 0x00
    4 0x2bfe034 electron::Browser::NotifyAndShutdown() entry none
    5 0x2bfead8 electron::Browser::OnWindowCloseCancelled() entry none

    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").

    Standalone Electron 44.9.1 app (repro/)

    repro/main.js:18-24 (before-quit handler; :22 microtask and :23 timeout re-issue). ./run.sh <microtask|timeout> <applescript|keyboard> <outfile> (ledger: "Repro source lines"):

    requit trigger After window closed .result line (run.sh:21-27)
    timeout applescript will-quit, quit 5s after before-quit: pid 62515 exited
    microtask applescript will-quit, quit, app.quit() returned 5s after before-quit: pid 62560 exited
    timeout keyboard will-quit, quit 5s after before-quit: pid 62606 exited
    microtask keyboard window-all-closed 5s after before-quit: pid 63177 still running

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

    Source pointers

    Pointer Label
    OpenCode a517687 packages/desktop/src/main/lifecycle/index.ts:96-102 beforeQuit
    same file :37, :141-142 prepareToRestart; before-quit / will-quit registration
    Electron e02265b shell/browser/browser.cc:120-132 Browser::Quit()
    same file :289-301, :303-307, :309-314, :316-324 NotifyAndShutdown(), HandleBeforeQuit(), OnWindowCloseCancelled(), OnWindowAllClosed()
    Electron e02265b shell/browser/mac/electron_application.mm:92-101 terminate:

    Not examined

    • Electron versions other than 44.9.1; Windows and Linux
    • Other OpenCode versions under lldb
    • The source line that desktop-sRS9a7pu.js:48:7240 was built from
    • Which task or microtask the recorded app.quit() call runs in
    • The renderer after the reopen

    Evidence: evidence.zip (README.md manifest with hashes, spans and redaction; ledger.md).

    evidence.zip

  10. Te-River commented on Oct 22, 2026

    @Te-River
    Author

    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.

  11. kcharlan commented on Oct 22, 2026

    @kcharlan

    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-quit t 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").

    Standalone Electron 44.9.1 app

    REQUIT=exit: repro/main.js:25-29 (ledger: "Repro source lines", "Repro files").

    requit trigger After before-quit .result line (repro/run.sh:23-28)
    exit applescript app.exit(0), window closed, quit 5s after before-quit: pid 84646 exited
    exit keyboard app.exit(0), window closed, quit 5s after before-quit: pid 84958 exited
    microtask keyboard reissue app.quit(), before-quit, window close, app.quit() returned, window closed, window-all-closed 5s after before-quit: pid 85610 still running

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

    evidence.zip

  12. Te-River commented on Oct 22, 2026

    @Te-River
    Author

    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:

    1. 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.
    2. 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.

  13. kcharlan commented on Oct 22, 2026

    @kcharlan

    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.

    1. Quit-event listeners at a517687

    Site Event Label
    packages/desktop/src/main/desktop.ts:37 will-quit (once) runIpc callback; Effect.scoped at :50
    packages/desktop/src/main/lifecycle/index.ts:142 will-quit willQuit (:104-107)
    packages/desktop/src/main/lifecycle/index.ts:141 before-quit beforeQuit (:96-102)
    packages/desktop/src/main/storage/index.ts:37 before-quit storage.flush
    [email protected] out/ElectronAppAdapter.js:38 quit (once) onQuit

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

    Evidence: ledger "Survey: …", "Pinned lines: …", "Versions: …"; pinned/, tools/asar-grep.mjs.

    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()
    kill -INT 2 2 same
    "Restart" menu item 2 2, new pid each prepareToRestart:exit, app.quit(), app:before-quit, app:will-quit, runIpc:will-quit, finalizers, desktop:scope-closed, process:exit

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

    evidence.zip

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions