Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Packages/macOS/CmuxNext/CEFShim/src/shim_browser.mm
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

int cmux_shim_create_window(int request, void* parent_view, int width, int height, const char* url,
const char* profile_cache_path) {
if (!parent_view) {
if (!parent_view || stopping()) {
return 0;
}
CefWindowInfo info;
Expand All @@ -35,10 +35,12 @@ int cmux_shim_create_window(int request, void* parent_view, int width, int heigh
}

int cmux_shim_tab_add(int window_browser_id, const char* url, int index, int activate) {
if (stopping()) return 0;
return fork_api().tab_add ? fork_api().tab_add(window_browser_id, url ? url : "", index, activate) : 0;
}

int cmux_shim_tab_duplicate(int browser_id, int window_browser_id, int index) {
if (stopping()) return 0;
return fork_api().tab_duplicate ? fork_api().tab_duplicate(browser_id, window_browser_id, index) : 0;
}

Expand Down
4 changes: 4 additions & 0 deletions Packages/macOS/CmuxNext/CEFShim/src/shim_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ ForkApi& fork_api();
void InstallForkObserver();
Host& host();

// True once quit began to stop Chromium (cmux_shim_close_all): no browser is
// made after, as CEF aborts creating one once its shutdown began.
bool& stopping();

// Emits one event to Swift (main thread only).
void Emit(int kind,
int browser_id,
Expand Down
6 changes: 6 additions & 0 deletions Packages/macOS/CmuxNext/CEFShim/src/shim_process.mm
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
return h;
}

bool& stopping() {
static bool value = false;
return value;
}

void Emit(int kind, int browser_id, int request, int64_t a, int64_t b,
const std::string& s1, const std::string& s2) {
Host& h = host();
Expand Down Expand Up @@ -446,6 +451,7 @@ void cmux_shim_do_work(void) {
}

void cmux_shim_close_all(void) {
stopping() = true;
// Copy: CloseBrowser can re-enter OnBeforeClose synchronously.
auto all = browsers();
for (auto& [id, browser] : all) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ extension CEFPaneHost {
/// tab with nothing committed yet.
@discardableResult
public func duplicate(_ tab: CEFTab) -> CEFTab? {
guard let browserID = tab.browserID, let shim = runtime.shim,
guard let browserID = tab.browserID, runtime.acceptsNewBrowsers, let shim = runtime.shim,
runtime.forkAPIVersion >= Self.duplicateForkAPI, let anchor = anchorBrowser else { return nil }
let window = shim.tabWindowID(anchor)
if window != 0 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ final class CEFPaneHost {
// MARK: Creation

func ensureCreated(_ tab: CEFTab) {
guard tab.browserID == nil, tab.isCreationPending == false, let shim = runtime.shim else { return }
guard tab.browserID == nil, tab.isCreationPending == false, runtime.acceptsNewBrowsers, let shim = runtime.shim else { return }
switch window {
case .none:
let request = runtime.makeRequestToken()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,15 @@ extension CEFRuntime {

// MARK: Quit

/// False once quit began to stop Chromium: CEF aborts making a browser after its shutdown
/// began. AppKit still closes the windows after CefShutdown, and a hidden tab's view unhid
/// there created one (crash on quit, NIGHTLY 1.0.0-nightly.3953614980201).
var acceptsNewBrowsers: Bool { Self.acceptsNewBrowsers(state: state, stopping: shutdownSequence != nil) }

static func acceptsNewBrowsers(state: State, stopping: Bool) -> Bool {
state != .shutDown && !stopping
}

/// Closes every browser, waits (without blocking the main thread) until
/// the Chromium windows are destroyed or `timeout` passes, then calls
/// CefShutdown. The App awaits this from `applicationShouldTerminate`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@ final class CEFRuntime {
OffTheRecordProfiles.shared.observeEnd { [weak self] profile in self?.releaseOffTheRecordContexts(of: profile) }
}

/// A runtime over `shim` that never loads or starts Chromium, its profiles under `storage`
/// (tests drive panes against a spy shim).
init(shim: CEFShimLibrary, storage: CEFProfileStorage) {
self.shim = shim
self.storage = storage
state = .ready
}

/// `cef_log_severity_t` LOGSEVERITY_WARNING for cef.log and every helper.
/// At Chromium's default (INFO), each console call of a page or an
/// extension that no handler takes became an `INFO:CONSOLE` line in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,21 +247,26 @@ nonisolated struct CEFShimLibrary: @unchecked Sendable {
guard let handle = dlopen(url.path, RTLD_NOW | RTLD_LOCAL) else {
throw .open(String(cString: dlerror()))
}
let resolver = Resolver(handle: handle)
let library = try CEFShimLibrary(resolver)
let library = try CEFShimLibrary(Resolver { dlsym(handle, $0) })
try checkABI(expected: expected, found: library.abiIDFn().map { String(cString: $0) })
return library
}

/// Each function by symbol name: `dlsym` on the opened shim, or a test's stand-ins (`resolving`).
private struct Resolver {
let handle: UnsafeMutableRawPointer
let symbol: (String) -> UnsafeMutableRawPointer?

func callAsFunction<T>(_ name: String) throws(LoadError) -> T {
guard let symbol = dlsym(handle, name) else { throw .missingSymbol(name) }
guard let symbol = symbol(name) else { throw .missingSymbol(name) }
return unsafeBitCast(symbol, to: T.self)
}
}

/// The functions `symbol` gives by name, with no shim loaded (a test's spy shim).
static func resolving(_ symbol: @escaping (String) -> UnsafeMutableRawPointer?) throws(LoadError) -> CEFShimLibrary {
try CEFShimLibrary(Resolver(symbol: symbol))
}

private init(_ r: Resolver) throws(LoadError) {
abiIDFn = try r("cmux_shim_abi_id")
load = try r("cmux_shim_load")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import AppKit
@testable import CmuxNextBrowser
import Foundation
import Testing

/// Crash on quit, NIGHTLY 1.0.0-nightly.3953614980201 (2026-10-20): AppKit closed the windows
/// for termination after CefShutdown, a window's observer removed a view, a hidden Chromium tab's
/// content view lost its hidden ancestor (`viewDidUnhide`), and the pane created its browser
/// (`cmux_shim_create_window`): CEF aborts creating a browser after shutdown began.
@MainActor @Suite(.serialized) struct CEFQuitCreationTests {
@Test func noBrowserIsMadeOnceChromiumBeganToStop() {
#expect(CEFRuntime.acceptsNewBrowsers(state: .ready, stopping: false))
// Quit closed every browser and waits for Chromium to destroy them (`shutdown`).
#expect(!CEFRuntime.acceptsNewBrowsers(state: .ready, stopping: true))
// CefShutdown ran, or quit came while the library was still loading.
#expect(!CEFRuntime.acceptsNewBrowsers(state: .shutDown, stopping: false))
}

/// The crash's path, against a spy shim: a parked tab never shown unhides in its window
/// (`viewDidUnhide`), so the tab appears and its pane makes its browser
/// (`CEFTab.contentDidAppear`, `CEFPaneHost.present`, `ensureCreated`).
@Test func aParkedTabUnhiddenAfterChromiumStoppedMakesNoBrowser() throws {
let root = FileManager.default.temporaryDirectory.appending(path: "cef-quit-\(UUID().uuidString)", directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: root) }
let runtime = CEFRuntime(shim: try SpyShim.library(), storage: CEFProfileStorage(root: root))
// Chromium running: the tab gets its browser.
#expect(try unhideParkedTab(runtime) == 1)
runtime.state = .shutDown
#expect(try unhideParkedTab(runtime) == 0)
}

/// Calls of `cmux_shim_create_window` while a parked Chromium tab of a new pane unhides.
private func unhideParkedTab(_ runtime: CEFRuntime) throws -> Int {
let host = CEFPaneHost(key: CEFPaneKey(pane: BrowserPaneID(rawValue: UUID().uuidString), profile: .default), runtime: runtime)
let tab = CEFTab(id: .random(), profile: .default, host: host, runtime: runtime)
host.add(tab)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), styleMask: .borderless, backing: .buffered, defer: true)
let content = try #require(window.contentView)
let parked = NSView(frame: content.bounds)
parked.isHidden = true
content.addSubview(parked)
parked.addSubview(tab.contentView)
let before = SpyShim.createWindowCalls
parked.isHidden = false
return SpyShim.createWindowCalls - before
}
}

/// Stand-in shim functions: `cmux_shim_create_window` counts its calls and reports the browser
/// started; every other function does nothing and returns 0.
private nonisolated enum SpyShim {
nonisolated(unsafe) static var createWindowCalls = 0

static func library() throws -> CEFShimLibrary {
let createWindow: @convention(c) (Int32, UnsafeMutableRawPointer?, Int32, Int32, UnsafePointer<CChar>?, UnsafePointer<CChar>?) -> Int32 = { _, _, _, _, _, _ in
SpyShim.createWindowCalls += 1
return 1
}
let nothing: @convention(c) () -> Int = { 0 }
return try CEFShimLibrary.resolving { name in
if name == "cmux_shim_create_window" { return unsafeBitCast(createWindow, to: UnsafeMutableRawPointer.self) }
return unsafeBitCast(nothing, to: UnsafeMutableRawPointer.self)
}
}
}
Loading