diff --git a/ModernTests/NSAlert_iTermTests.swift b/ModernTests/NSAlert_iTermTests.swift new file mode 100644 index 0000000000..9e133ec509 --- /dev/null +++ b/ModernTests/NSAlert_iTermTests.swift @@ -0,0 +1,42 @@ +// +// NSAlert_iTermTests.swift +// iTerm2 ModernTests +// +// Verifies that -[NSAlert(iTerm) runSheetModalForWindow:] does not +// hang when the parent window is destroyed while the sheet is shown. +// + +import XCTest +@testable import iTerm2SharedARC + +final class NSAlert_iTermTests: XCTestCase { + + func test_modalReturnsWhenParentWindowCloses() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false) + + let alert = NSAlert() + alert.messageText = "test" + alert.addButton(withTitle: "OK") + + // Close the window from a background queue shortly after the + // modal begins. The nested run loop inside runModalForWindow: + // will deliver NSWindowWillCloseNotification, our observer + // will fire abortModal, and the modal must unwind — proving + // we are not deadlocked. + DispatchQueue.global().async { + // Give the modal a moment to enter its run loop. + Thread.sleep(forTimeInterval: 0.1) + window.close() + } + + let response = alert.runSheetModalForWindow(window) + // Any return value means the fix is working — the call did not + // block forever. + XCTAssertNotEqual(response, NSModalResponseContinue, + "Modal should not return NSModalResponseContinue when aborted") + } +} diff --git a/sources/Categories/NSAlert+iTerm.m b/sources/Categories/NSAlert+iTerm.m index aced7e7a9d..8dacea2b61 100644 --- a/sources/Categories/NSAlert+iTerm.m +++ b/sources/Categories/NSAlert+iTerm.m @@ -14,7 +14,32 @@ - (NSModalResponse)runSheetModalForWindow:(NSWindow *)window { DLog(@"Run sheet modal for window %@", window); [NSApp activateIgnoringOtherApps:YES]; + + // If the parent window is closed before the user dismisses the sheet, + // the completion handler will never fire and the modal loop in + // runModalForWindow: will run forever, blocking the entire app. + // Observe the parent window closing so we can abort the modal. + __block BOOL stopped = NO; + __block id observer = nil; + observer = [[NSNotificationCenter defaultCenter] addObserverForName:NSWindowWillCloseNotification + object:window + queue:nil + usingBlock:^(NSNotification *note) { + if (stopped) { + return; + } + stopped = YES; + DLog(@"Parent window closed while sheet modal was active — aborting modal"); + [[NSNotificationCenter defaultCenter] removeObserver:observer]; + [NSApp abortModal]; + }]; + [self beginSheetModalForWindow:window completionHandler:^(NSModalResponse returnCode) { + if (stopped) { + return; + } + stopped = YES; + [[NSNotificationCenter defaultCenter] removeObserver:observer]; [NSApp stopModalWithCode:returnCode]; }]; return [NSApp runModalForWindow:[self window]];