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:
parent
682a57d7db
commit
5bbdd87c29
2 changed files with 61 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue