From 0439b30c305f8b51a5b79eb73773e8d9732fa9d5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 2 Aug 2026 14:16:29 +0900 Subject: [PATCH] Guard close-confirmation alert against re-entrant presentation Close requests reach presentCloseConfirmation from independent paths (TCA effects, Ghostty callbacks, the tab bar, the CLI socket) while NSAlert.runModal() keeps draining main-actor jobs, so a late request could present a second alert nested inside the first one's run loop. On macOS 27 beta AppKit answers that with an NSException rethrown out of runModal, which Swift cannot catch (SIGABRT, Sentry PROWL-MACOS-FP). Add TerminalCloseConfirmationGate: while a confirmation is on screen, later ones are dropped and treated as cancelled. Claude-Session: https://claude.ai/code/session_01TWSNesKV4dk8HLKmcNEJjS --- .../TerminalCloseConfirmationGate.swift | 24 +++++++++++++++ .../WorktreeTerminalState+Surfaces.swift | 17 ++++++----- .../TerminalCloseConfirmationGateTests.swift | 29 +++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 supacode/Features/Terminal/Models/TerminalCloseConfirmationGate.swift create mode 100644 supacodeTests/TerminalCloseConfirmationGateTests.swift diff --git a/supacode/Features/Terminal/Models/TerminalCloseConfirmationGate.swift b/supacode/Features/Terminal/Models/TerminalCloseConfirmationGate.swift new file mode 100644 index 000000000..8e26ba8be --- /dev/null +++ b/supacode/Features/Terminal/Models/TerminalCloseConfirmationGate.swift @@ -0,0 +1,24 @@ +import Foundation + +/// App-global guard against re-entrant close-confirmation alerts. +/// +/// Close requests arrive from independent paths (TCA effects, Ghostty +/// callbacks, the tab bar, the CLI socket), and `NSAlert.runModal()` keeps +/// draining main-actor jobs while it spins, so a late request can try to +/// present a second alert from inside the first one's run loop. AppKit may +/// answer that nested modal with an uncaught NSException (SIGABRT, Sentry +/// PROWL-MACOS-FP). While a confirmation is on screen, later ones are +/// dropped and treated as cancelled. +@MainActor +enum TerminalCloseConfirmationGate { + private(set) static var isPresenting = false + + /// Runs `present` unless a confirmation is already showing. + /// Returns `nil` when the gate is held; callers treat that as "cancelled". + static func run(_ present: () -> T) -> T? { + guard !isPresenting else { return nil } + isPresenting = true + defer { isPresenting = false } + return present() + } +} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 6de15cefa..bf8c12c46 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -47,13 +47,16 @@ extension WorktreeTerminalState { target: TerminalCloseConfirmationTarget, decision: TerminalCloseConfirmationDecision ) -> Bool { - let alert = NSAlert() - alert.messageText = target.messageText - alert.informativeText = closeConfirmationMessage(for: decision) - alert.alertStyle = .warning - alert.addButton(withTitle: target.confirmButtonTitle) - alert.addButton(withTitle: "Cancel") - return alert.runModal() == .alertFirstButtonReturn + let confirmed = TerminalCloseConfirmationGate.run { + let alert = NSAlert() + alert.messageText = target.messageText + alert.informativeText = closeConfirmationMessage(for: decision) + alert.alertStyle = .warning + alert.addButton(withTitle: target.confirmButtonTitle) + alert.addButton(withTitle: "Cancel") + return alert.runModal() == .alertFirstButtonReturn + } + return confirmed ?? false } func closeConfirmationMessage(for decision: TerminalCloseConfirmationDecision) -> String { diff --git a/supacodeTests/TerminalCloseConfirmationGateTests.swift b/supacodeTests/TerminalCloseConfirmationGateTests.swift new file mode 100644 index 000000000..b8c3c1479 --- /dev/null +++ b/supacodeTests/TerminalCloseConfirmationGateTests.swift @@ -0,0 +1,29 @@ +import Testing + +@testable import supacode + +@MainActor +struct TerminalCloseConfirmationGateTests { + @Test func runExecutesWhenGateIsFree() { + let result = TerminalCloseConfirmationGate.run { true } + #expect(result == true) + #expect(TerminalCloseConfirmationGate.isPresenting == false) + } + + @Test func nestedRunIsDropped() { + var nestedResult: Bool? = false + let outer = TerminalCloseConfirmationGate.run { () -> Bool in + #expect(TerminalCloseConfirmationGate.isPresenting) + nestedResult = TerminalCloseConfirmationGate.run { true } + return true + } + #expect(outer == true) + #expect(nestedResult == nil) + } + + @Test func gateIsReleasedAfterRun() { + _ = TerminalCloseConfirmationGate.run { true } + let second = TerminalCloseConfirmationGate.run { false } + #expect(second == false) + } +}