From f7eaaac2c520275c161e026abb8e12b9a7ffc79e Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 26 Aug 2026 17:14:32 -0300 Subject: [PATCH 1/7] test: reproduce duplicate instance arbitration race --- .../AppDelegateShortcutRoutingTests.swift | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 72528787..d0aeda4a 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -1,4 +1,5 @@ import XCTest +import Darwin import Combine #if canImport(Programa_DEV) @@ -102,6 +103,69 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { super.tearDown() } + func testDuplicateInstanceArbitrationLetsLaterStartSecondWinAndEarlierStartLose() { + let earlier = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 999_999, + processIdentifier: 200 + ) + let later = ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: 0, + processIdentifier: 100 + ) + + XCTAssertTrue(AppDelegate.shouldTerminateDuplicateInstance(current: later, other: earlier)) + XCTAssertFalse(AppDelegate.shouldTerminateDuplicateInstance(current: earlier, other: later)) + } + + func testDuplicateInstanceArbitrationLetsLaterStartMicrosecondWinAndEarlierStartLose() { + let earlier = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 200 + ) + let later = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 101, + processIdentifier: 100 + ) + + XCTAssertTrue(AppDelegate.shouldTerminateDuplicateInstance(current: later, other: earlier)) + XCTAssertFalse(AppDelegate.shouldTerminateDuplicateInstance(current: earlier, other: later)) + } + + func testDuplicateInstanceArbitrationUsesPIDToElectOneWinnerForIdenticalTimestamps() { + let lowerPID = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 + ) + let higherPID = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 200 + ) + let higherPIDWins = AppDelegate.shouldTerminateDuplicateInstance(current: higherPID, other: lowerPID) + let lowerPIDWins = AppDelegate.shouldTerminateDuplicateInstance(current: lowerPID, other: higherPID) + + XCTAssertTrue(higherPIDWins) + XCTAssertFalse(lowerPIDWins) + XCTAssertNotEqual(higherPIDWins, lowerPIDWins, "Identical kernel timestamps must elect exactly one winner") + } + + func testSingleInstanceProcessKeyReadsCurrentKernelProcessIdentity() throws { + let currentPID = getpid() + let key = try XCTUnwrap(AppDelegate.singleInstanceProcessKey(for: currentPID)) + + XCTAssertEqual(key.processIdentifier, currentPID) + XCTAssertGreaterThan(key.startSeconds, 0, "The current process must have a positive kernel start timestamp") + } + + func testSingleInstanceProcessKeyRejectsMissingKernelProcessRecord() { + XCTAssertNil(AppDelegate.singleInstanceProcessKey(for: pid_t.max)) + } + func testOrphanReconciliationRetainsOneRecoveryWorkspacePerSuccessfulSessionOnly() throws { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") From 95e67a25bacc47ee1681ac8ebed8fd3e9c933992 Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 26 Aug 2026 17:19:24 -0300 Subject: [PATCH 2/7] fix: arbitrate simultaneous app launches --- CHANGELOG.md | 1 + Sources/AppDelegate.swift | 69 ++++++++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 837c0428..bf27ae64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - Revoking a paired mobile device now also blocks connections still being admitted, and disabling the bridge closes active phone sessions. - An unreadable browser history file no longer causes repeated disk reads on every omnibar keystroke. - Clearing browser history now stays cleared after a temporary disk deletion failure or app termination. +- Launching two copies of Programa at nearly the same time now deterministically keeps the newer instance instead of allowing both processes to terminate each other. - Browser imports now treat Unicode domains and their Punycode forms as the same filter, so internationalized domains no longer silently import zero matching cookies or history entries. - Socket automation no longer hangs on split Unicode requests or unsubscribe races, and malformed telemetry can no longer crash the app or grow retained workspace state without bounds. - Large command output no longer deadlocks the CLI or background Git checks, and stalled Git probes now time out instead of accumulating work. diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 74fc75d5..37b1f2cf 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -775,6 +775,12 @@ func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> return shouldSuppressWindowMoveForFolderDrag(hitView: hitView) } +struct ProgramaSingleInstanceProcessKey: Equatable, Sendable { + let startSeconds: Int64 + let startMicroseconds: Int64 + let processIdentifier: pid_t +} + @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate, NSMenuItemValidation { nonisolated(unsafe) static var shared: AppDelegate? @@ -8845,16 +8851,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } #endif + nonisolated static func singleInstanceProcessKey( + for processIdentifier: pid_t + ) -> ProgramaSingleInstanceProcessKey? { + var processInfo = kinfo_proc() + var processInfoSize = MemoryLayout.size + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, processIdentifier] + guard sysctl(&mib, UInt32(mib.count), &processInfo, &processInfoSize, nil, 0) == 0, + processInfoSize == MemoryLayout.size, + processInfo.kp_proc.p_pid == processIdentifier else { + return nil + } + + let startTime = processInfo.kp_proc.p_starttime + guard startTime.tv_sec > 0 || startTime.tv_usec > 0 else { return nil } + return ProgramaSingleInstanceProcessKey( + startSeconds: Int64(startTime.tv_sec), + startMicroseconds: Int64(startTime.tv_usec), + processIdentifier: processIdentifier + ) + } + + nonisolated static func shouldTerminateDuplicateInstance( + current: ProgramaSingleInstanceProcessKey, + other: ProgramaSingleInstanceProcessKey + ) -> Bool { + if current.startSeconds != other.startSeconds { + return current.startSeconds > other.startSeconds + } + if current.startMicroseconds != other.startMicroseconds { + return current.startMicroseconds > other.startMicroseconds + } + return current.processIdentifier > other.processIdentifier + } + + nonisolated private static func terminateDuplicateApplication(_ app: NSRunningApplication) { + app.terminate() + if !app.isTerminated { + _ = app.forceTerminate() + } + } + private func enforceSingleInstance() { guard let bundleId = Bundle.main.bundleIdentifier else { return } - let currentPid = ProcessInfo.processInfo.processIdentifier + let currentPid = NSRunningApplication.current.processIdentifier + guard let currentKey = Self.singleInstanceProcessKey(for: currentPid) else { return } for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) { - guard app.processIdentifier != currentPid else { continue } - app.terminate() - if !app.isTerminated { - _ = app.forceTerminate() + guard app.processIdentifier != currentPid, + let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier), + Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { + continue } + Self.terminateDuplicateApplication(app) } } @@ -8864,7 +8913,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) .standardizedFileURL .resolvingSymlinksInPath() - let currentPid = ProcessInfo.processInfo.processIdentifier + let currentPid = NSRunningApplication.current.processIdentifier + guard let currentKey = Self.singleInstanceProcessKey(for: currentPid) else { return } workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didLaunchApplicationNotification, @@ -8881,10 +8931,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return } - app.terminate() - if !app.isTerminated { - _ = app.forceTerminate() + guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier), + Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { + return } + Self.terminateDuplicateApplication(app) NSRunningApplication.current.activate(options: [.activateAllWindows]) } } From 01c6d25d92b641bbdb2643d1b4d71f5003cff9eb Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 26 Aug 2026 17:32:25 -0300 Subject: [PATCH 3/7] fix: exclude embedded cli from instance arbitration --- Sources/AppDelegate.swift | 47 +++++++++++++--- .../AppDelegateShortcutRoutingTests.swift | 54 +++++++++++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 37b1f2cf..79654af7 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8885,6 +8885,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return current.processIdentifier > other.processIdentifier } + nonisolated static func shouldConsiderDuplicateApplication( + candidateBundleIdentifier: String?, + candidateProcessIdentifier: pid_t, + candidateExecutableURL: URL?, + expectedBundleIdentifier: String, + currentProcessIdentifier: pid_t, + embeddedCLIURL: URL + ) -> Bool { + guard candidateBundleIdentifier == expectedBundleIdentifier, + candidateProcessIdentifier != currentProcessIdentifier else { + return false + } + guard let candidateExecutableURL else { return true } + return candidateExecutableURL.standardizedFileURL.resolvingSymlinksInPath() + != embeddedCLIURL.standardizedFileURL.resolvingSymlinksInPath() + } + nonisolated private static func terminateDuplicateApplication(_ app: NSRunningApplication) { app.terminate() if !app.isTerminated { @@ -8894,12 +8911,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser private func enforceSingleInstance() { guard let bundleId = Bundle.main.bundleIdentifier else { return } + let embeddedCLIURL = Bundle.main.bundleURL + .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) + .standardizedFileURL + .resolvingSymlinksInPath() let currentPid = NSRunningApplication.current.processIdentifier guard let currentKey = Self.singleInstanceProcessKey(for: currentPid) else { return } for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) { - guard app.processIdentifier != currentPid, - let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier), + guard Self.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: app.bundleIdentifier, + candidateProcessIdentifier: app.processIdentifier, + candidateExecutableURL: app.executableURL, + expectedBundleIdentifier: bundleId, + currentProcessIdentifier: currentPid, + embeddedCLIURL: embeddedCLIURL + ) else { + continue + } + guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier), Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { continue } @@ -8923,11 +8953,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) { [weak self] notification in guard self != nil else { return } guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } - guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return } - if let executableURL = app.executableURL? - .standardizedFileURL - .resolvingSymlinksInPath(), - executableURL == embeddedCLIURL { + guard Self.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: app.bundleIdentifier, + candidateProcessIdentifier: app.processIdentifier, + candidateExecutableURL: app.executableURL, + expectedBundleIdentifier: bundleId, + currentProcessIdentifier: currentPid, + embeddedCLIURL: embeddedCLIURL + ) else { return } diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index d0aeda4a..c5a17ea2 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -154,6 +154,60 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertNotEqual(higherPIDWins, lowerPIDWins, "Identical kernel timestamps must elect exactly one winner") } + func testDuplicateInstanceCandidateExcludesEmbeddedCLIExecutable() { + let embeddedCLIURL = URL( + fileURLWithPath: "/Applications/Programa.app/Contents/Resources/bin/programa" + ) + + XCTAssertFalse(AppDelegate.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: "com.darkroom.programa", + candidateProcessIdentifier: 200, + candidateExecutableURL: embeddedCLIURL, + expectedBundleIdentifier: "com.darkroom.programa", + currentProcessIdentifier: 100, + embeddedCLIURL: embeddedCLIURL + )) + } + + func testDuplicateInstanceCandidateIncludesSameBundleGUIExecutable() { + XCTAssertTrue(AppDelegate.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: "com.darkroom.programa", + candidateProcessIdentifier: 200, + candidateExecutableURL: URL( + fileURLWithPath: "/Applications/Programa.app/Contents/MacOS/Programa" + ), + expectedBundleIdentifier: "com.darkroom.programa", + currentProcessIdentifier: 100, + embeddedCLIURL: URL( + fileURLWithPath: "/Applications/Programa.app/Contents/Resources/bin/programa" + ) + )) + } + + func testDuplicateInstanceCandidateRejectsCurrentProcessAndDifferentBundle() { + let embeddedCLIURL = URL( + fileURLWithPath: "/Applications/Programa.app/Contents/Resources/bin/programa" + ) + let guiURL = URL(fileURLWithPath: "/Applications/Programa.app/Contents/MacOS/Programa") + + XCTAssertFalse(AppDelegate.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: "com.darkroom.programa", + candidateProcessIdentifier: 100, + candidateExecutableURL: guiURL, + expectedBundleIdentifier: "com.darkroom.programa", + currentProcessIdentifier: 100, + embeddedCLIURL: embeddedCLIURL + )) + XCTAssertFalse(AppDelegate.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: "com.example.other", + candidateProcessIdentifier: 200, + candidateExecutableURL: guiURL, + expectedBundleIdentifier: "com.darkroom.programa", + currentProcessIdentifier: 100, + embeddedCLIURL: embeddedCLIURL + )) + } + func testSingleInstanceProcessKeyReadsCurrentKernelProcessIdentity() throws { let currentPID = getpid() let key = try XCTUnwrap(AppDelegate.singleInstanceProcessKey(for: currentPID)) From 22f58dd545e45a0a9652f313c8d80bccebaeca1b Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 27 Aug 2026 10:31:23 -0300 Subject: [PATCH 4/7] test: cover safe duplicate termination --- Sources/AppDelegate.swift | 11 +++++ .../AppDelegateShortcutRoutingTests.swift | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 79654af7..99b54967 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8909,6 +8909,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } } +#if DEBUG + static func scheduleDuplicateTerminationForTesting( + requestTermination: () -> Void, + scheduleGrace: (@escaping () -> Void) -> Void, + forceTerminationIfStillMatching: @escaping () -> Bool + ) { + requestTermination() + _ = forceTerminationIfStillMatching() + } +#endif + private func enforceSingleInstance() { guard let bundleId = Bundle.main.bundleIdentifier else { return } let embeddedCLIURL = Bundle.main.bundleURL diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index c5a17ea2..c7e8b974 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -184,6 +184,46 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { )) } + func testDuplicateInstanceCandidateRejectsMissingExecutableMetadata() { + XCTAssertFalse(AppDelegate.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: "com.darkroom.programa", + candidateProcessIdentifier: 200, + candidateExecutableURL: nil, + expectedBundleIdentifier: "com.darkroom.programa", + currentProcessIdentifier: 100, + embeddedCLIURL: URL( + fileURLWithPath: "/Applications/Programa.app/Contents/Resources/bin/programa" + ) + )) + } + + func testDuplicateInstanceTerminationWaitsForGraceBeforeForcing() throws { + var gracefulTerminationCount = 0 + var forcedTerminationCount = 0 + var scheduledGraceAction: (() -> Void)? + + AppDelegate.scheduleDuplicateTerminationForTesting( + requestTermination: { + gracefulTerminationCount += 1 + }, + scheduleGrace: { action in + scheduledGraceAction = action + }, + forceTerminationIfStillMatching: { + forcedTerminationCount += 1 + return true + } + ) + + XCTAssertEqual(gracefulTerminationCount, 1) + XCTAssertEqual(forcedTerminationCount, 0, "Force termination must not run synchronously") + + let graceAction = try XCTUnwrap(scheduledGraceAction) + graceAction() + + XCTAssertEqual(forcedTerminationCount, 1) + } + func testDuplicateInstanceCandidateRejectsCurrentProcessAndDifferentBundle() { let embeddedCLIURL = URL( fileURLWithPath: "/Applications/Programa.app/Contents/Resources/bin/programa" From b6015dc1ad35580ac12e6e7428f80df41c795502 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 27 Aug 2026 10:44:25 -0300 Subject: [PATCH 5/7] fix: harden duplicate app termination --- .github/workflows/test-e2e.yml | 39 +++++++ CHANGELOG.md | 2 +- Sources/AppDelegate.swift | 106 +++++++++++++++--- .../AppDelegateShortcutRoutingTests.swift | 2 +- .../MultiWindowNotificationsUITests.swift | 19 +++- 5 files changed, 143 insertions(+), 25 deletions(-) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 02fa8dec..82373a9e 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -207,6 +207,8 @@ jobs: SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" ONLY_TESTING="-only-testing:programaUITests/$TEST_FILTER" DISPLAY_ENV_PREFIX=() + RESULT_BUNDLE_PATH="/tmp/programa-e2e.xcresult" + rm -rf "$RESULT_BUNDLE_PATH" if [ "$TEST_FILTER" = "DisplayResolutionRegressionUITests" ]; then HELPER_PATH="/tmp/create-virtual-display" @@ -256,6 +258,7 @@ jobs: -disableAutomaticPackageResolution -destination "platform=macOS" -maximum-test-execution-time-allowance "$TEST_TIMEOUT" + -resultBundlePath "$RESULT_BUNDLE_PATH" $ONLY_TESTING test ) @@ -292,6 +295,42 @@ jobs: exit 1 fi + - name: Capture E2E diagnostics + if: always() + run: | + set -euo pipefail + DIAGNOSTICS_DIR="/tmp/programa-e2e-diagnostics" + REPORTS_DIR="$DIAGNOSTICS_DIR/DiagnosticReports" + rm -rf "$DIAGNOSTICS_DIR" + mkdir -p "$REPORTS_DIR" + + ps -axo pid=,ppid=,lstart=,state=,comm= \ + | awk 'BEGIN { print "pid ppid start state executable" } /Programa|programa|xctest|XCTRunner|testmanagerd/' \ + > "$DIAGNOSTICS_DIR/processes.txt" + + SYSTEM_REPORTS="$HOME/Library/Logs/DiagnosticReports" + if [ -d "$SYSTEM_REPORTS" ]; then + find "$SYSTEM_REPORTS" -maxdepth 1 -type f -mmin -30 \ + \( -name 'Programa*.crash' -o -name 'Programa*.ips' -o -name 'programa*.crash' -o -name 'programa*.ips' \) \ + -exec cp {} "$REPORTS_DIR/" \; + fi + + if [ -d /tmp/programa-e2e.xcresult ]; then + xcrun xcresulttool get test-results summary \ + --path /tmp/programa-e2e.xcresult \ + > "$DIAGNOSTICS_DIR/xcresult-summary.json" 2>&1 || true + fi + + - name: Upload E2E diagnostics + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: programa-e2e-diagnostics-${{ github.run_id }} + path: | + /tmp/programa-e2e.xcresult + /tmp/programa-e2e-diagnostics + if-no-files-found: warn + - name: Stop recording and trim if: ${{ always() && inputs.record_video && env.RECORD_PID != '' }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index bf27ae64..36a1fa8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - Revoking a paired mobile device now also blocks connections still being admitted, and disabling the bridge closes active phone sessions. - An unreadable browser history file no longer causes repeated disk reads on every omnibar keystroke. - Clearing browser history now stays cleared after a temporary disk deletion failure or app termination. -- Launching two copies of Programa at nearly the same time now deterministically keeps the newer instance instead of allowing both processes to terminate each other. +- Launching two copies of Programa at nearly the same time now deterministically keeps the newer instance instead of allowing both processes to terminate each other. Candidates with incomplete process metadata are ignored, and an unresponsive older copy is force-closed only after a grace period and a fresh process-identity check. - Browser imports now treat Unicode domains and their Punycode forms as the same filter, so internationalized domains no longer silently import zero matching cookies or history entries. - Socket automation no longer hangs on split Unicode requests or unsubscribe races, and malformed telemetry can no longer crash the app or grow retained workspace state without bounds. - Large command output no longer deadlocks the CLI or background Git checks, and stalled Git probes now time out instead of accumulating work. diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 99b54967..099c4475 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1205,8 +1205,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } } else if forceDuplicateLaunchObserver { // Some UI regressions specifically exercise launch-observer behavior while still - // running under XCTest. Allow an explicit opt-in for those cases only. - DispatchQueue.main.async { [weak self] in + // running under XCTest. Give the initial window and accessibility hierarchy a + // bounded head start before opting into process inspection for those cases only. + dilog("single_instance", "pid=\(getpid()) outcome=scheduled reason=ui_test_observer") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + dilog("single_instance", "pid=\(getpid()) outcome=installed reason=ui_test_observer") self?.observeDuplicateLaunches() } } @@ -8893,33 +8896,95 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser currentProcessIdentifier: pid_t, embeddedCLIURL: URL ) -> Bool { - guard candidateBundleIdentifier == expectedBundleIdentifier, - candidateProcessIdentifier != currentProcessIdentifier else { + guard candidateBundleIdentifier == expectedBundleIdentifier else { + dilog("single_instance", "pid=\(candidateProcessIdentifier) outcome=ignored reason=bundle_mismatch") + return false + } + guard candidateProcessIdentifier != currentProcessIdentifier else { + dilog("single_instance", "pid=\(candidateProcessIdentifier) outcome=ignored reason=current_process") + return false + } + guard let candidateExecutableURL else { + dilog("single_instance", "pid=\(candidateProcessIdentifier) outcome=ignored reason=missing_executable") return false } - guard let candidateExecutableURL else { return true } - return candidateExecutableURL.standardizedFileURL.resolvingSymlinksInPath() - != embeddedCLIURL.standardizedFileURL.resolvingSymlinksInPath() + guard candidateExecutableURL.standardizedFileURL.resolvingSymlinksInPath() + != embeddedCLIURL.standardizedFileURL.resolvingSymlinksInPath() else { + dilog("single_instance", "pid=\(candidateProcessIdentifier) outcome=ignored reason=embedded_cli") + return false + } + dilog("single_instance", "pid=\(candidateProcessIdentifier) outcome=accepted reason=gui_candidate") + return true } - nonisolated private static func terminateDuplicateApplication(_ app: NSRunningApplication) { - app.terminate() - if !app.isTerminated { - _ = app.forceTerminate() + private static func scheduleDuplicateTermination( + requestTermination: () -> Void, + scheduleGrace: (@escaping @MainActor () -> Void) -> Void, + forceTerminationIfStillMatching: @escaping @MainActor () -> Bool + ) { + requestTermination() + scheduleGrace { + _ = forceTerminationIfStillMatching() } } #if DEBUG static func scheduleDuplicateTerminationForTesting( requestTermination: () -> Void, - scheduleGrace: (@escaping () -> Void) -> Void, - forceTerminationIfStillMatching: @escaping () -> Bool + scheduleGrace: (@escaping @MainActor () -> Void) -> Void, + forceTerminationIfStillMatching: @escaping @MainActor () -> Bool ) { - requestTermination() - _ = forceTerminationIfStillMatching() + scheduleDuplicateTermination( + requestTermination: requestTermination, + scheduleGrace: scheduleGrace, + forceTerminationIfStillMatching: forceTerminationIfStillMatching + ) } #endif + private static func terminateDuplicateApplication( + _ app: NSRunningApplication, + expectedProcessKey: ProgramaSingleInstanceProcessKey + ) { + let processIdentifier = app.processIdentifier + scheduleDuplicateTermination( + requestTermination: { + let accepted = app.terminate() + dilog( + "single_instance", + "pid=\(processIdentifier) outcome=\(accepted ? "requested" : "request_rejected") reason=graceful_terminate" + ) + }, + scheduleGrace: { action in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { @MainActor in + action() + } + }, + forceTerminationIfStillMatching: { + guard let resolvedApplication = NSRunningApplication(processIdentifier: processIdentifier) else { + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=no_longer_running") + return false + } + guard let resolvedKey = singleInstanceProcessKey(for: processIdentifier), + resolvedKey == expectedProcessKey else { + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=identity_changed") + return false + } + guard !resolvedApplication.isTerminated else { + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=already_terminated") + return false + } + + let forced = resolvedApplication.forceTerminate() + dilog( + "single_instance", + "pid=\(processIdentifier) outcome=\(forced ? "forced" : "force_rejected") reason=grace_expired" + ) + return forced + } + ) + } + private func enforceSingleInstance() { guard let bundleId = Bundle.main.bundleIdentifier else { return } let embeddedCLIURL = Bundle.main.bundleURL @@ -8942,13 +9007,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier), Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { + dilog("single_instance", "pid=\(app.processIdentifier) outcome=ignored reason=election") continue } - Self.terminateDuplicateApplication(app) + Self.terminateDuplicateApplication(app, expectedProcessKey: otherKey) } } private func observeDuplicateLaunches() { + guard workspaceObserver == nil else { return } guard let bundleId = Bundle.main.bundleIdentifier else { return } let embeddedCLIURL = Bundle.main.bundleURL .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) @@ -8977,10 +9044,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier), Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { + dilog("single_instance", "pid=\(app.processIdentifier) outcome=ignored reason=election") return } - Self.terminateDuplicateApplication(app) - NSRunningApplication.current.activate(options: [.activateAllWindows]) + MainActor.assumeIsolated { + Self.terminateDuplicateApplication(app, expectedProcessKey: otherKey) + NSRunningApplication.current.activate(options: [.activateAllWindows]) + } } } diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index c7e8b974..1050e677 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -200,7 +200,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { func testDuplicateInstanceTerminationWaitsForGraceBeforeForcing() throws { var gracefulTerminationCount = 0 var forcedTerminationCount = 0 - var scheduledGraceAction: (() -> Void)? + var scheduledGraceAction: (@MainActor () -> Void)? AppDelegate.scheduleDuplicateTerminationForTesting( requestTermination: { diff --git a/programaUITests/MultiWindowNotificationsUITests.swift b/programaUITests/MultiWindowNotificationsUITests.swift index 610e9517..b1fb659d 100644 --- a/programaUITests/MultiWindowNotificationsUITests.swift +++ b/programaUITests/MultiWindowNotificationsUITests.swift @@ -6,6 +6,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { private var dataPath = "" private var socketPath = "" private var launchTag = "" + private var launchedApplication: XCUIApplication? override func setUp() { super.setUp() @@ -18,13 +19,21 @@ final class MultiWindowNotificationsUITests: XCTestCase { } override func tearDown() { + launchedApplication?.terminate() + launchedApplication = nil try? FileManager.default.removeItem(atPath: dataPath) try? FileManager.default.removeItem(atPath: socketPath) super.tearDown() } + private func makeTrackedApplication() -> XCUIApplication { + let application = XCUIApplication() + launchedApplication = application + return application + } + func testNotificationsRouteToCorrectWindow() { - let app = XCUIApplication() + let app = makeTrackedApplication() app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1" app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath app.launchEnvironment["PROGRAMA_TAG"] = launchTag @@ -111,7 +120,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { } func testNotificationsPopoverCanCloseViaShortcutAndEscape() { - let app = XCUIApplication() + let app = makeTrackedApplication() app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1" app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath app.launchEnvironment["PROGRAMA_TAG"] = launchTag @@ -148,7 +157,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { } func testNotificationsPopoverJumpToLatestButtonShowsShortcut() { - let app = XCUIApplication() + let app = makeTrackedApplication() app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1" app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath app.launchEnvironment["PROGRAMA_TAG"] = launchTag @@ -173,7 +182,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { } func testEmptyNotificationsPopoverBlocksTerminalTyping() throws { - let app = XCUIApplication() + let app = makeTrackedApplication() app.launchArguments += ["-socketControlMode", "allowAll"] app.launchEnvironment["PROGRAMA_SOCKET_PATH"] = socketPath app.launchEnvironment["PROGRAMA_SOCKET_MODE"] = "allowAll" @@ -222,7 +231,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { } func testNotifyCLIDoesNotStealFocusAcrossWindows() throws { - let app = XCUIApplication() + let app = makeTrackedApplication() app.launchArguments += ["-socketControlMode", "allowAll"] app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1" app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath From 2ef6316598f7882ce58f09cd7dc53ca3416045a4 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 27 Aug 2026 10:56:36 -0300 Subject: [PATCH 6/7] test: cover validated duplicate shutdown --- Sources/AppDelegate.swift | 73 +++++++++ .../AppDelegateShortcutRoutingTests.swift | 154 ++++++++++++++++++ 2 files changed, 227 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 099c4475..12c6fcc4 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8854,6 +8854,51 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } #endif + struct SingleInstanceShutdownRequest: Codable, Equatable, Sendable { + static let currentVersion = 1 + + let version: Int + let targetStartSeconds: Int64 + let targetStartMicroseconds: Int64 + let targetProcessIdentifier: pid_t + let requesterStartSeconds: Int64 + let requesterStartMicroseconds: Int64 + let requesterProcessIdentifier: pid_t + let createdAtUnixSeconds: TimeInterval + + init( + version: Int = Self.currentVersion, + target: ProgramaSingleInstanceProcessKey, + requester: ProgramaSingleInstanceProcessKey, + createdAtUnixSeconds: TimeInterval + ) { + self.version = version + targetStartSeconds = target.startSeconds + targetStartMicroseconds = target.startMicroseconds + targetProcessIdentifier = target.processIdentifier + requesterStartSeconds = requester.startSeconds + requesterStartMicroseconds = requester.startMicroseconds + requesterProcessIdentifier = requester.processIdentifier + self.createdAtUnixSeconds = createdAtUnixSeconds + } + + var target: ProgramaSingleInstanceProcessKey { + ProgramaSingleInstanceProcessKey( + startSeconds: targetStartSeconds, + startMicroseconds: targetStartMicroseconds, + processIdentifier: targetProcessIdentifier + ) + } + + var requester: ProgramaSingleInstanceProcessKey { + ProgramaSingleInstanceProcessKey( + startSeconds: requesterStartSeconds, + startMicroseconds: requesterStartMicroseconds, + processIdentifier: requesterProcessIdentifier + ) + } + } + nonisolated static func singleInstanceProcessKey( for processIdentifier: pid_t ) -> ProgramaSingleInstanceProcessKey? { @@ -8929,6 +8974,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } #if DEBUG + nonisolated static func shouldAcceptDuplicateShutdownRequestForTesting( + _ request: SingleInstanceShutdownRequest?, + currentProcessKey: ProgramaSingleInstanceProcessKey, + now: TimeInterval, + resolvedRequesterKey: ProgramaSingleInstanceProcessKey?, + requesterIsProgramaGUI: Bool + ) -> Bool { + false + } + + nonisolated static func shouldWarnBeforeTerminationForTesting( + isTaggedDevBuild: Bool, + isQuitWarningConfirmed: Bool, + hasValidatedDuplicateShutdownRequest: Bool, + isQuitWarningEnabled: Bool + ) -> Bool { + guard !isTaggedDevBuild, !isQuitWarningConfirmed else { return false } + return isQuitWarningEnabled + } + + nonisolated static func shouldForceDuplicateTerminationForTesting( + expectedProcessKey: ProgramaSingleInstanceProcessKey, + resolvedProcessKey: ProgramaSingleInstanceProcessKey?, + isTerminated: Bool + ) -> Bool { + resolvedProcessKey == expectedProcessKey && !isTerminated + } + static func scheduleDuplicateTerminationForTesting( requestTermination: () -> Void, scheduleGrace: (@escaping @MainActor () -> Void) -> Void, diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 1050e677..c994076d 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -224,6 +224,160 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(forcedTerminationCount, 1) } + func testValidatedDuplicateShutdownRequestTargetsExactCurrentProcessAndBypassesWarning() { + let current = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 + ) + let requester = ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: 0, + processIdentifier: 200 + ) + let request = AppDelegate.SingleInstanceShutdownRequest( + target: current, + requester: requester, + createdAtUnixSeconds: 10_000 + ) + + let accepted = AppDelegate.shouldAcceptDuplicateShutdownRequestForTesting( + request, + currentProcessKey: current, + now: 10_001, + resolvedRequesterKey: requester, + requesterIsProgramaGUI: true + ) + + XCTAssertTrue(accepted) + XCTAssertFalse(AppDelegate.shouldWarnBeforeTerminationForTesting( + isTaggedDevBuild: false, + isQuitWarningConfirmed: false, + hasValidatedDuplicateShutdownRequest: accepted, + isQuitWarningEnabled: true + )) + } + + func testDuplicateShutdownRequestFailsClosedWhenMissingStaleMalformedOrMismatched() { + let current = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 + ) + let requester = ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: 0, + processIdentifier: 200 + ) + let wrongTarget = ProgramaSingleInstanceProcessKey( + startSeconds: 999, + startMicroseconds: 999, + processIdentifier: 99 + ) + let staleRequest = AppDelegate.SingleInstanceShutdownRequest( + target: current, + requester: requester, + createdAtUnixSeconds: 9_000 + ) + let malformedVersionRequest = AppDelegate.SingleInstanceShutdownRequest( + version: AppDelegate.SingleInstanceShutdownRequest.currentVersion + 1, + target: current, + requester: requester, + createdAtUnixSeconds: 10_000 + ) + let mismatchedTargetRequest = AppDelegate.SingleInstanceShutdownRequest( + target: wrongTarget, + requester: requester, + createdAtUnixSeconds: 10_000 + ) + + XCTAssertFalse(AppDelegate.shouldAcceptDuplicateShutdownRequestForTesting( + nil, + currentProcessKey: current, + now: 10_001, + resolvedRequesterKey: requester, + requesterIsProgramaGUI: true + )) + XCTAssertFalse(AppDelegate.shouldAcceptDuplicateShutdownRequestForTesting( + staleRequest, + currentProcessKey: current, + now: 10_001, + resolvedRequesterKey: requester, + requesterIsProgramaGUI: true + )) + XCTAssertFalse(AppDelegate.shouldAcceptDuplicateShutdownRequestForTesting( + malformedVersionRequest, + currentProcessKey: current, + now: 10_001, + resolvedRequesterKey: requester, + requesterIsProgramaGUI: true + )) + XCTAssertFalse(AppDelegate.shouldAcceptDuplicateShutdownRequestForTesting( + mismatchedTargetRequest, + currentProcessKey: current, + now: 10_001, + resolvedRequesterKey: requester, + requesterIsProgramaGUI: true + )) + XCTAssertFalse(AppDelegate.shouldAcceptDuplicateShutdownRequestForTesting( + staleRequest, + currentProcessKey: current, + now: 9_001, + resolvedRequesterKey: wrongTarget, + requesterIsProgramaGUI: true + )) + XCTAssertFalse(AppDelegate.shouldAcceptDuplicateShutdownRequestForTesting( + staleRequest, + currentProcessKey: current, + now: 9_001, + resolvedRequesterKey: requester, + requesterIsProgramaGUI: false + )) + } + + func testOrdinaryQuitStillWarnsWhenWarningIsEnabled() { + XCTAssertTrue(AppDelegate.shouldWarnBeforeTerminationForTesting( + isTaggedDevBuild: false, + isQuitWarningConfirmed: false, + hasValidatedDuplicateShutdownRequest: false, + isQuitWarningEnabled: true + )) + } + + func testDuplicateForceFallbackRequiresSameLiveProcessIdentity() { + let expected = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 + ) + let changed = ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: 0, + processIdentifier: 100 + ) + + XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( + expectedProcessKey: expected, + resolvedProcessKey: nil, + isTerminated: false + )) + XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( + expectedProcessKey: expected, + resolvedProcessKey: changed, + isTerminated: false + )) + XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( + expectedProcessKey: expected, + resolvedProcessKey: expected, + isTerminated: true + )) + XCTAssertTrue(AppDelegate.shouldForceDuplicateTerminationForTesting( + expectedProcessKey: expected, + resolvedProcessKey: expected, + isTerminated: false + )) + } + func testDuplicateInstanceCandidateRejectsCurrentProcessAndDifferentBundle() { let embeddedCLIURL = URL( fileURLWithPath: "/Applications/Programa.app/Contents/Resources/bin/programa" From 89c1f7fe6cc1dd0e76f39ae90e28657c39b4671e Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 27 Aug 2026 11:04:21 -0300 Subject: [PATCH 7/7] fix: validate duplicate shutdown requests --- .github/workflows/test-e2e.yml | 8 + CHANGELOG.md | 2 +- Sources/AppDelegate.swift | 250 +++++++++++++++--- .../AppDelegateShortcutRoutingTests.swift | 19 +- 4 files changed, 241 insertions(+), 38 deletions(-) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 82373a9e..f2ed5b61 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -315,6 +315,14 @@ jobs: -exec cp {} "$REPORTS_DIR/" \; fi + PROGRAMA_LOGS_DIR="$HOME/Library/Logs/Programa" + mkdir -p "$DIAGNOSTICS_DIR/ProgramaLogs" + for log_name in diagnostics.log diagnostics.log.1; do + if [ -f "$PROGRAMA_LOGS_DIR/$log_name" ]; then + cp "$PROGRAMA_LOGS_DIR/$log_name" "$DIAGNOSTICS_DIR/ProgramaLogs/$log_name" + fi + done + if [ -d /tmp/programa-e2e.xcresult ]; then xcrun xcresulttool get test-results summary \ --path /tmp/programa-e2e.xcresult \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 36a1fa8a..f1720e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - Revoking a paired mobile device now also blocks connections still being admitted, and disabling the bridge closes active phone sessions. - An unreadable browser history file no longer causes repeated disk reads on every omnibar keystroke. - Clearing browser history now stays cleared after a temporary disk deletion failure or app termination. -- Launching two copies of Programa at nearly the same time now deterministically keeps the newer instance instead of allowing both processes to terminate each other. Candidates with incomplete process metadata are ignored, and an unresponsive older copy is force-closed only after a grace period and a fresh process-identity check. +- Launching two copies of Programa at nearly the same time now deterministically keeps the newer instance instead of allowing both processes to terminate each other. A responsive older copy bypasses only the quit-confirmation dialog and exits through the normal session-persistence path; incomplete or stale requests are ignored, and force-close remains a bounded fallback only for an unresponsive process whose identity still matches. - Browser imports now treat Unicode domains and their Punycode forms as the same filter, so internationalized domains no longer silently import zero matching cookies or history entries. - Socket automation no longer hangs on split Unicode requests or unsubscribe races, and malformed telemetry can no longer crash the app or grow retained workspace state without bounds. - Large command output no longer deadlocks the CLI or background Git checks, and stalled Git probes now time out instead of accumulating work. diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 12c6fcc4..fbcb51ec 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1531,28 +1531,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + // Consume the exact-process arbitration request before synchronous persistence begins. + // Removing the request file acknowledges that this process is responsive, preventing + // the winner's bounded fallback from force-closing us while the snapshot is being saved. + let hasValidatedDuplicateShutdownRequest = consumeValidatedDuplicateShutdownRequest() isTerminatingApp = true SessionMachineryGate.isApplicationTerminating = true // A warning dialog can still cancel this termination request. The final // `applicationWillTerminate` callback is the only point that records a clean exit. _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) - // Tagged DEV builds are ephemeral, skip quit confirmation entirely. - if SocketControlSettings.isTaggedDevBuild() { - return .terminateNow - } - - // If the user already confirmed via the Cmd+Q shortcut warning dialog - // (handleQuitShortcutWarning), skip the check to avoid a second alert. - if isQuitWarningConfirmed { - return .terminateNow - } - - // Respect the "Warn Before Quit" setting even when Cmd+Q arrives via - // the Cmd+Tab app switcher, bypassing handleCustomShortcut. - guard QuitWarningSettings.isEnabled() else { + let shouldWarn = Self.shouldWarnBeforeTermination( + isTaggedDevBuild: SocketControlSettings.isTaggedDevBuild(), + isQuitWarningConfirmed: isQuitWarningConfirmed, + hasValidatedDuplicateShutdownRequest: hasValidatedDuplicateShutdownRequest, + isQuitWarningEnabled: QuitWarningSettings.isEnabled() + ) + guard shouldWarn else { + let reason = hasValidatedDuplicateShutdownRequest ? "duplicate_request" : "warning_bypassed" + dilog("single_instance", "pid=\(getpid()) outcome=terminate_now reason=\(reason)") return .terminateNow } + dilog("single_instance", "pid=\(getpid()) outcome=warning reason=ordinary_quit") // Show the same confirmation dialog used by the Cmd+Q shortcut path, // then reply asynchronously so we can return .terminateLater now. @@ -8933,6 +8933,58 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return current.processIdentifier > other.processIdentifier } + private nonisolated static let duplicateShutdownRequestMaxAge: TimeInterval = 10 + private nonisolated static let duplicateShutdownRequestMaxBytes = 4_096 + private nonisolated static let duplicateTerminationGraceInterval: TimeInterval = 2 + + nonisolated static func shouldAcceptDuplicateShutdownRequest( + _ request: SingleInstanceShutdownRequest?, + currentProcessKey: ProgramaSingleInstanceProcessKey, + now: TimeInterval, + resolvedRequesterKey: ProgramaSingleInstanceProcessKey?, + requesterIsProgramaGUI: Bool + ) -> Bool { + guard let request, + request.version == SingleInstanceShutdownRequest.currentVersion, + request.target == currentProcessKey, + request.createdAtUnixSeconds.isFinite else { + return false + } + + let age = now - request.createdAtUnixSeconds + guard age >= 0, age <= duplicateShutdownRequestMaxAge, + request.requester != currentProcessKey, + resolvedRequesterKey == request.requester, + requesterIsProgramaGUI else { + return false + } + + return shouldTerminateDuplicateInstance(current: request.requester, other: currentProcessKey) + } + + nonisolated static func shouldWarnBeforeTermination( + isTaggedDevBuild: Bool, + isQuitWarningConfirmed: Bool, + hasValidatedDuplicateShutdownRequest: Bool, + isQuitWarningEnabled: Bool + ) -> Bool { + guard !isTaggedDevBuild, + !isQuitWarningConfirmed, + !hasValidatedDuplicateShutdownRequest else { + return false + } + return isQuitWarningEnabled + } + + nonisolated static func shouldForceDuplicateTermination( + expectedProcessKey: ProgramaSingleInstanceProcessKey, + resolvedProcessKey: ProgramaSingleInstanceProcessKey?, + isTerminated: Bool, + requestIsPending: Bool + ) -> Bool { + requestIsPending && resolvedProcessKey == expectedProcessKey && !isTerminated + } + nonisolated static func shouldConsiderDuplicateApplication( candidateBundleIdentifier: String?, candidateProcessIdentifier: pid_t, @@ -8963,11 +9015,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } private static func scheduleDuplicateTermination( - requestTermination: () -> Void, + requestTermination: () -> Bool, scheduleGrace: (@escaping @MainActor () -> Void) -> Void, forceTerminationIfStillMatching: @escaping @MainActor () -> Bool ) { - requestTermination() + guard requestTermination() else { return } scheduleGrace { _ = forceTerminationIfStillMatching() } @@ -8981,7 +9033,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser resolvedRequesterKey: ProgramaSingleInstanceProcessKey?, requesterIsProgramaGUI: Bool ) -> Bool { - false + shouldAcceptDuplicateShutdownRequest( + request, + currentProcessKey: currentProcessKey, + now: now, + resolvedRequesterKey: resolvedRequesterKey, + requesterIsProgramaGUI: requesterIsProgramaGUI + ) } nonisolated static func shouldWarnBeforeTerminationForTesting( @@ -8990,20 +9048,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser hasValidatedDuplicateShutdownRequest: Bool, isQuitWarningEnabled: Bool ) -> Bool { - guard !isTaggedDevBuild, !isQuitWarningConfirmed else { return false } - return isQuitWarningEnabled + shouldWarnBeforeTermination( + isTaggedDevBuild: isTaggedDevBuild, + isQuitWarningConfirmed: isQuitWarningConfirmed, + hasValidatedDuplicateShutdownRequest: hasValidatedDuplicateShutdownRequest, + isQuitWarningEnabled: isQuitWarningEnabled + ) } nonisolated static func shouldForceDuplicateTerminationForTesting( expectedProcessKey: ProgramaSingleInstanceProcessKey, resolvedProcessKey: ProgramaSingleInstanceProcessKey?, - isTerminated: Bool + isTerminated: Bool, + requestIsPending: Bool ) -> Bool { - resolvedProcessKey == expectedProcessKey && !isTerminated + shouldForceDuplicateTermination( + expectedProcessKey: expectedProcessKey, + resolvedProcessKey: resolvedProcessKey, + isTerminated: isTerminated, + requestIsPending: requestIsPending + ) } static func scheduleDuplicateTerminationForTesting( - requestTermination: () -> Void, + requestTermination: () -> Bool, scheduleGrace: (@escaping @MainActor () -> Void) -> Void, forceTerminationIfStillMatching: @escaping @MainActor () -> Bool ) { @@ -9015,36 +9083,144 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } #endif + nonisolated private static func duplicateShutdownRequestURL( + for processIdentifier: pid_t + ) -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + "programa-single-instance-\(getuid())-\(processIdentifier).json", + isDirectory: false + ) + } + + private static func writeDuplicateShutdownRequest( + target: ProgramaSingleInstanceProcessKey, + requester: ProgramaSingleInstanceProcessKey + ) -> Bool { + let request = SingleInstanceShutdownRequest( + target: target, + requester: requester, + createdAtUnixSeconds: Date().timeIntervalSince1970 + ) + let requestURL = duplicateShutdownRequestURL(for: target.processIdentifier) + do { + let data = try JSONEncoder().encode(request) + guard data.count <= duplicateShutdownRequestMaxBytes else { + dilog("single_instance", "pid=\(target.processIdentifier) outcome=rejected reason=request_too_large") + return false + } + try data.write(to: requestURL, options: .atomic) + dilog("single_instance", "pid=\(target.processIdentifier) outcome=written reason=shutdown_request") + return true + } catch { + dilog("single_instance", "pid=\(target.processIdentifier) outcome=failed reason=request_write") + return false + } + } + + private func consumeValidatedDuplicateShutdownRequest() -> Bool { + let currentProcessIdentifier = getpid() + let requestURL = Self.duplicateShutdownRequestURL(for: currentProcessIdentifier) + guard let fileHandle = try? FileHandle(forReadingFrom: requestURL) else { + dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=missing reason=shutdown_request") + return false + } + defer { + try? fileHandle.close() + try? FileManager.default.removeItem(at: requestURL) + } + + guard let data = try? fileHandle.read(upToCount: Self.duplicateShutdownRequestMaxBytes + 1), + data.count <= Self.duplicateShutdownRequestMaxBytes, + let request = try? JSONDecoder().decode(SingleInstanceShutdownRequest.self, from: data), + let currentKey = Self.singleInstanceProcessKey(for: currentProcessIdentifier), + let bundleIdentifier = Bundle.main.bundleIdentifier else { + dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=rejected reason=malformed_request") + return false + } + + let requesterApplication = NSRunningApplication( + processIdentifier: request.requesterProcessIdentifier + ) + let embeddedCLIURL = Bundle.main.bundleURL + .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) + .standardizedFileURL + .resolvingSymlinksInPath() + let requesterIsProgramaGUI = requesterApplication.map { application in + Self.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: application.bundleIdentifier, + candidateProcessIdentifier: application.processIdentifier, + candidateExecutableURL: application.executableURL, + expectedBundleIdentifier: bundleIdentifier, + currentProcessIdentifier: currentProcessIdentifier, + embeddedCLIURL: embeddedCLIURL + ) + } ?? false + let accepted = Self.shouldAcceptDuplicateShutdownRequest( + request, + currentProcessKey: currentKey, + now: Date().timeIntervalSince1970, + resolvedRequesterKey: Self.singleInstanceProcessKey( + for: request.requesterProcessIdentifier + ), + requesterIsProgramaGUI: requesterIsProgramaGUI + ) + dilog( + "single_instance", + "pid=\(currentProcessIdentifier) outcome=\(accepted ? "accepted" : "rejected") reason=shutdown_request" + ) + return accepted + } + private static func terminateDuplicateApplication( _ app: NSRunningApplication, - expectedProcessKey: ProgramaSingleInstanceProcessKey + expectedProcessKey: ProgramaSingleInstanceProcessKey, + requesterProcessKey: ProgramaSingleInstanceProcessKey ) { let processIdentifier = app.processIdentifier + let requestURL = duplicateShutdownRequestURL(for: processIdentifier) scheduleDuplicateTermination( requestTermination: { + guard writeDuplicateShutdownRequest( + target: expectedProcessKey, + requester: requesterProcessKey + ) else { + return false + } let accepted = app.terminate() dilog( "single_instance", "pid=\(processIdentifier) outcome=\(accepted ? "requested" : "request_rejected") reason=graceful_terminate" ) + if !accepted { + try? FileManager.default.removeItem(at: requestURL) + } + return accepted }, scheduleGrace: { action in - DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { @MainActor in + DispatchQueue.main.asyncAfter(deadline: .now() + duplicateTerminationGraceInterval) { @MainActor in action() } }, forceTerminationIfStillMatching: { - guard let resolvedApplication = NSRunningApplication(processIdentifier: processIdentifier) else { - dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=no_longer_running") + let requestIsPending = FileManager.default.fileExists(atPath: requestURL.path) + defer { try? FileManager.default.removeItem(at: requestURL) } + guard requestIsPending else { + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=request_acknowledged") return false } - guard let resolvedKey = singleInstanceProcessKey(for: processIdentifier), - resolvedKey == expectedProcessKey else { - dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=identity_changed") + guard let resolvedApplication = NSRunningApplication(processIdentifier: processIdentifier) else { + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=no_longer_running") return false } - guard !resolvedApplication.isTerminated else { - dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=already_terminated") + let resolvedKey = singleInstanceProcessKey(for: processIdentifier) + guard shouldForceDuplicateTermination( + expectedProcessKey: expectedProcessKey, + resolvedProcessKey: resolvedKey, + isTerminated: resolvedApplication.isTerminated, + requestIsPending: requestIsPending + ) else { + let reason = resolvedKey == expectedProcessKey ? "already_terminated" : "identity_changed" + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=\(reason)") return false } @@ -9083,7 +9259,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser dilog("single_instance", "pid=\(app.processIdentifier) outcome=ignored reason=election") continue } - Self.terminateDuplicateApplication(app, expectedProcessKey: otherKey) + Self.terminateDuplicateApplication( + app, + expectedProcessKey: otherKey, + requesterProcessKey: currentKey + ) } } @@ -9121,7 +9301,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return } MainActor.assumeIsolated { - Self.terminateDuplicateApplication(app, expectedProcessKey: otherKey) + Self.terminateDuplicateApplication( + app, + expectedProcessKey: otherKey, + requesterProcessKey: currentKey + ) NSRunningApplication.current.activate(options: [.activateAllWindows]) } } diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index c994076d..2118ab94 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -205,6 +205,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { AppDelegate.scheduleDuplicateTerminationForTesting( requestTermination: { gracefulTerminationCount += 1 + return true }, scheduleGrace: { action in scheduledGraceAction = action @@ -359,22 +360,32 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( expectedProcessKey: expected, resolvedProcessKey: nil, - isTerminated: false + isTerminated: false, + requestIsPending: true )) XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( expectedProcessKey: expected, resolvedProcessKey: changed, - isTerminated: false + isTerminated: false, + requestIsPending: true + )) + XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( + expectedProcessKey: expected, + resolvedProcessKey: expected, + isTerminated: true, + requestIsPending: true )) XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( expectedProcessKey: expected, resolvedProcessKey: expected, - isTerminated: true + isTerminated: false, + requestIsPending: false )) XCTAssertTrue(AppDelegate.shouldForceDuplicateTerminationForTesting( expectedProcessKey: expected, resolvedProcessKey: expected, - isTerminated: false + isTerminated: false, + requestIsPending: true )) }