cmux-next browser: closing a tab runs beforeunload first; no Chromium app-modal alert (crash fix)
Summary
Closing a Chromium tab whose page has a beforeunload handler closed the tab at once (nightly 1.0.0-nightly.3950982952501). Chrome and Safari ask "Leave site?" first and keep the tab on Cancel. In the same incident the app hung and was force-quit.
Root cause (crash symbolicated)
CEFTab.close()removed the view and the daemon had already removed the tab, thencmux_shim_closecalledCloseBrowser(true). In Chrome runtime style that still runs beforeunload (DoCloseBrowser->TabStripModel::CloseWebContentsAt->RunUnloadListenerBeforeClosing), so Chromium showed its own app-modal beforeunload alert with no tab behind it.- Crash report
cmux-2026-10-20-181229.ips, symbolicated with the nightly dSYM (run 39509829525 artifactnightly-next-dsyms-d5829ffea005...) and the CEF debug binary (cef-154.0.28-cmux.28-macos-arm64-debug, UUIDs match). The NSException backtrace: NSButton click ->-[AlertBridgeHelper alertDidEnd:returnCode:contextInfo:]->remote_cocoa::AlertBridge::SendResultAndDestroy->JavaScriptAppModalDialogCocoa::OnAlertFinished(Cancel/Stay branch of a beforeunload dialog) ->chrome_browser_application_mac::CancelTerminate()=[NSApp cancelTerminate:nil]-> unrecognized selector (CmuxApplicationdoes not implement it). The cmux frames are onlyCmuxApplication.reportException/sendEventandmain. Then the Sentry crash handler hung and the Dock force-quit the app.
Other NSApp selectors Chromium can send (audit of the CEF 154 debug binary)
Every objc_msgSend stub for a BrowserCrApplication-only selector, with its call sites (BL/B scan of __text):
cancelTerminate:: only fromchrome_browser_application_mac::CancelTerminate(), called only byJavaScriptAppModalDialogCocoa::OnAlertFinishedand::CancelAppModalDialog(the app-modal JS dialog). Unguarded: this PR answers it onNSApplicationand the shim keeps beforeunload off that dialog.addNativeEventProcessorObserver:/removeNativeEventProcessorObserver:: onlycontent::responsiveness::BrowserUINativeEventObserver, guarded by[NSApp conformsToProtocol:](disassembled).enableScreenReaderCompleteMode(:/AfterDelay:),voiceOverStateChanged:, AppleScript selectors: only sent from insideBrowserCrApplicationitself.chrome_browser_application_mac::Terminate()sendsterminate:(NSApplication has it), fromBrowserProcessPlatformPart::AttemptExit.isHandlingSendEvent/setHandlingSendEvent::CmuxApplicationimplements them.
Fix
- Shim (
shim_client.mm,shim_process.mm,shim_browser.mm, header): aCefJSDialogHandlertakes every beforeunload question (CMUX_SHIM_BEFORE_UNLOAD_DIALOG, answered withcmux_shim_before_unload_reply;OnResetDialogStatesendsCMUX_SHIM_BEFORE_UNLOAD_DISMISSED). Chromium's app-modal alert never shows. A host close (cmux_shim_close,cmux_shim_close_all) never asks: a waiting question is answered Leave and one asked during the close is answered Leave at once. Newcmux_shim_try_close(TryCloseBrowser) for a person's close. alert/confirm/prompt keep Chromium's own tab-modal dialogs (unchanged). - CEFTab (
CEFTab+UserClose.swift,BrowserUserClosing): a person's close callstry_close. If the page runs beforeunload, the tab, its view and its daemon record stay; the question is a cmux tab dialog (BrowserPromptKind.beforeUnload, Chrome's wording "Leave site?" / "Reload site?" / "Changes that you made may not be saved.", Cancel / Leave). Leave lets Chromium close the page;BEFORE_CLOSE->browserDidClose->.closeintent -> the normal close path removes the tab. Cancel keeps the tab fully usable. A second close while the question shows changes nothing. A hidden tab comes forward first (Chrome does the same). The same dialog now also serves beforeunload on navigation and reload. - Close path (
CloseUndoToasts.close, before the undo toast is announced): user-origin closes ask the page first. Page replacements (profile move, protected-media reopen) close with a script origin. Engine confirmation drives the removal, so the daemon record never goes away before the page agrees. - Defense in depth:
NSApplicationanswers-cancelTerminate:(no-op), so any other Chromium path that sends it cannot raise on the main thread.
Forced close, per caller (decided)
| Caller | Behavior |
|---|---|
Close button, Cmd-W, close others/left/right, tab palette close (all through CloseUndoToasts.close), user origin | asks (beforeunload) |
Tab group close (one daemon close-tab-group command) | forced; follow-up if wanted |
| Automation close (CLI, agents, MCP; non-user action origin) | forced, never asks (Playwright runBeforeUnload: false default) |
A hidden tab closed with no pane controller (TabLifecycle.close hidden branch) | forced (the question could not be seen) |
App quit (cmux_shim_close_all) | forced |
| Window / workspace close (incl. the sidebar row's close) | forced (not through PaneController.close) |
Page replacement (hibernation swap, store reroute, profile move, protected-media reopen, reopen in the other engine), CEFOrphanTabs, popup windows (CEFPaneHost+PopupWindows, BrowserPopupWindows) | forced (CEFTab.close() / cmux_shim_close) |
The page's own window.close() and the close after Leave | the engine already decided |
Hang: a page that hangs in its handler is closed by Chromium's own beforeunload path (renderer hang -> "Page unresponsive" -> Exit page, or Chromium's beforeunload timeout). Not proven live.
Design note: the veto lives in the page engine (it is the only owner that can run beforeunload); the daemon command is sent only after the engine closed the page. A client that sends close-surface straight to the daemon keeps forced semantics. No new Swift adapter or transport.
Testing
Behavior test at the product boundary: scripts/cmux-next/browser-beforeunload-close-live.py (tagged app on cmux-lawrence-2, nx-remote --needs gui --app-from-fleet; app debug socket: debug.key Cmd-W, debug.mouse page click for user activation, debug.dialog press, browser.page.eval). Committed before the fix.
- Red on base (test commit d48390429575): FAIL, 7 checks (job 1020-184522-b41dee, cmux-lawrence-2, fleet app 8769ba84): Cmd-W closed the tab at once, no question, "Cancel keeps the tab" failed
- Green on head 4748a5622a36 after the review fixes: PASS, 19/19 (job 1020-190253-e40c77). Earlier green at d45873d40aa4: PASS, 19/19 checks (job 1020-184539-798e69, fleet app 07c87a4e): Cmd-W asks and keeps the tab; a second Cmd-W keeps one question; Cancel keeps a working page; Leave closes; an automation close of an armed page closes with no question; the socket answers after Cancel and at the end
Observed / Not run
Ran and saw:
- Live GUI check red on base, green on head (above). Window snapshots in the job artifacts.
- Gate receipts all green at d45873d40aa4 and again at 4748a5622a36 (
/tmp/gates/bu-close-4748a562.json):package-test-lane suite(whole CmuxNext packageswift build --build-tests, then PasswordPromptBarTests, VerificationPromptBarTests, ToastCopyTests; Xcode 26.6, AWS Mac),check-release-compile.sh(Release compile, aws-m4pro-3), check-no-process-scan, check-app-size (no source growth), py_compile. The shim.mmfiles compiled in both fleet app builds. prepush-gate.py: no new violations.localization_catalog.py check: no errors for the new keys (21 locales; base debt unchanged).- Crash symbolication and the selector audit above.
Not run / unverified:
- CI
cmux-next guards / defaults-snapshotred:check-menu-actions.pyflagsCmuxNextFloating/ChiefPet/ChiefPetPanel.swift, a file this PR does not touch (base finding). - The gate plan's every-suite run (the header change routes to every CmuxNext test target, 600+ suites); only the focused suites ran.
- Pixels of the cmux dialog:
debug.window_snapshotdoes not capture the overlay window; the dialog's presence and visibility are proven bydebug.dialog(visible: true). - A page that hangs inside its beforeunload handler; beforeunload on navigation and reload through the new dialog; a beforeunload question in a hidden tab (tab comes forward); real pointer clicks (the check used the app's synthetic page click and dialog press).
- Popup windows' own close still forced (no beforeunload), tab-group close still forced.
Changelog
Fixed: Closing a browser tab whose page asks "Leave site?" now asks first and keeps the tab on Cancel, instead of closing it and hanging the app.