cmux-next browser: closing a tab runs beforeunload first; no Chromium app-modal alert (crash fix)

Merged
LA
17d ago
+1128-8

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, then cmux_shim_close called CloseBrowser(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 artifact nightly-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 (CmuxApplication does not implement it). The cmux frames are only CmuxApplication.reportException/sendEvent and main. 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 from chrome_browser_application_mac::CancelTerminate(), called only by JavaScriptAppModalDialogCocoa::OnAlertFinished and ::CancelAppModalDialog (the app-modal JS dialog). Unguarded: this PR answers it on NSApplication and the shim keeps beforeunload off that dialog.
  • addNativeEventProcessorObserver: / removeNativeEventProcessorObserver:: only content::responsiveness::BrowserUINativeEventObserver, guarded by [NSApp conformsToProtocol:] (disassembled).
  • enableScreenReaderCompleteMode(:/AfterDelay:), voiceOverStateChanged:, AppleScript selectors: only sent from inside BrowserCrApplication itself.
  • chrome_browser_application_mac::Terminate() sends terminate: (NSApplication has it), from BrowserProcessPlatformPart::AttemptExit.
  • isHandlingSendEvent / setHandlingSendEvent:: CmuxApplication implements them.

Fix

  • Shim (shim_client.mm, shim_process.mm, shim_browser.mm, header): a CefJSDialogHandler takes every beforeunload question (CMUX_SHIM_BEFORE_UNLOAD_DIALOG, answered with cmux_shim_before_unload_reply; OnResetDialogState sends CMUX_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. New cmux_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 calls try_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 -> .close intent -> 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: NSApplication answers -cancelTerminate: (no-op), so any other Chromium path that sends it cannot raise on the main thread.

Forced close, per caller (decided)

CallerBehavior
Close button, Cmd-W, close others/left/right, tab palette close (all through CloseUndoToasts.close), user originasks (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 Leavethe 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 package swift 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 .mm files 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-snapshot red: check-menu-actions.py flags CmuxNextFloating/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_snapshot does not capture the overlay window; the dialog's presence and visibility are proven by debug.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.