diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index 15f406e9..3aa5f16d 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -218,6 +218,12 @@ func windowDragHandleShouldTreatTopHitAsPassiveHost(_ view: NSView) -> Bool { return false } +/// Re-entrancy guard for the sibling hit-test walk. When `sibling.hitTest()` +/// triggers SwiftUI view-body evaluation, AppKit can call back into this +/// function before the outer invocation finishes, causing a Swift +/// exclusive-access violation (SIGABRT). Main-thread only, no lock needed. +private var _windowDragHandleIsResolvingSiblingHits = false + /// Returns whether the titlebar drag handle should capture a hit at `point`. /// We only claim the hit when no sibling view already handles it, so interactive /// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures. @@ -295,6 +301,20 @@ func windowDragHandleShouldCaptureHit( return true } + // Bail out if we're already inside a sibling hit-test walk. This happens + // when sibling.hitTest() re-enters SwiftUI layout, which calls hitTest on + // this drag handle again. Proceeding would trigger an exclusive-access + // violation in the Swift runtime. + guard !_windowDragHandleIsResolvingSiblingHits else { + #if DEBUG + dlog("titlebar.dragHandle.hitTest capture=false reason=reentrant point=\(windowDragHandleFormatPoint(point))") + #endif + return false + } + + _windowDragHandleIsResolvingSiblingHits = true + defer { _windowDragHandleIsResolvingSiblingHits = false } + let siblingSnapshot = Array(superview.subviews.reversed()) #if DEBUG diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 7f5ddb4c..d116a4e9 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6521,6 +6521,24 @@ final class WindowDragHandleHitTests: XCTestCase { } } + /// A sibling view whose hitTest re-enters windowDragHandleShouldCaptureHit, + /// simulating the crash path where sibling.hitTest triggers a SwiftUI layout + /// pass that calls back into the drag handle's hit resolution. + private final class ReentrantSiblingView: NSView { + weak var dragHandle: NSView? + var reenteredResult: Bool? + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point), let dragHandle else { return nil } + // Simulate the re-entry: during sibling hit test, SwiftUI layout + // calls windowDragHandleShouldCaptureHit on the drag handle again. + reenteredResult = windowDragHandleShouldCaptureHit( + point, in: dragHandle, eventType: .leftMouseDown, eventWindow: dragHandle.window + ) + return nil + } + } + func testDragHandleCapturesHitWhenNoSiblingClaimsPoint() { let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) let dragHandle = NSView(frame: container.bounds) @@ -6751,6 +6769,29 @@ final class WindowDragHandleHitTests: XCTestCase { ) } + func testDragHandleSiblingHitTestReentrancyDoesNotCrash() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let reentrantSibling = ReentrantSiblingView(frame: container.bounds) + reentrantSibling.dragHandle = dragHandle + container.addSubview(reentrantSibling) + + // The outer call enters the sibling walk, which calls + // reentrantSibling.hitTest(), which re-enters + // windowDragHandleShouldCaptureHit. Without the re-entrancy guard + // this would trigger a Swift exclusive-access violation (SIGABRT). + let outerResult = windowDragHandleShouldCaptureHit( + NSPoint(x: 110, y: 18), in: dragHandle, eventType: .leftMouseDown + ) + XCTAssertTrue(outerResult, "Outer call should still capture when sibling returns nil") + XCTAssertEqual( + reentrantSibling.reenteredResult, false, + "Re-entrant call should bail out (return false) instead of crashing" + ) + } + func testDragHandleTopHitResolutionSurvivesSameWindowReentrancy() { let point = NSPoint(x: 180, y: 18) let window = NSWindow(