Fix re-entrant exclusive-access crash in drag handle hit test (#771)

When sibling.hitTest() triggers a SwiftUI layout pass during the
drag handle's sibling walk, AppKit can call back into
windowDragHandleShouldCaptureHit before the outer invocation
finishes. This re-entry accesses SwiftUI view state that is already
held exclusively, causing a Swift runtime SIGABRT.

Add a module-level re-entrancy guard that bails out (returns false)
on nested calls to the sibling walk. Since hitTest is always called
on the main thread, a simple Bool flag is sufficient.

Crash was reproduced on macOS Sequoia 15.1.1 (24B91) in a UTM VM.
The crash stack: DraggableView.hitTest -> windowDragHandleShouldCaptureHit
-> sibling.hitTest -> SwiftUI body evaluation -> hitTest (re-entry)
-> exclusive-access violation -> SIGABRT.
This commit is contained in:
Lawrence Chen 2026-03-02 19:20:14 -08:00 committed by GitHub
parent 682a57d7db
commit 5bbdd87c29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 61 additions and 0 deletions

View file

@ -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

View file

@ -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(