diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 02fa8dec..f2ed5b61 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,50 @@ 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 + + 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 \ + > "$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 837c0428..f1720e24 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. 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 74fc75d5..fbcb51ec 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? @@ -1199,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() } } @@ -1522,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. @@ -8845,26 +8854,428 @@ 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? { + 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 + } + + 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, + candidateExecutableURL: URL?, + expectedBundleIdentifier: String, + currentProcessIdentifier: pid_t, + embeddedCLIURL: URL + ) -> Bool { + 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 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 + } + + private static func scheduleDuplicateTermination( + requestTermination: () -> Bool, + scheduleGrace: (@escaping @MainActor () -> Void) -> Void, + forceTerminationIfStillMatching: @escaping @MainActor () -> Bool + ) { + guard requestTermination() else { return } + scheduleGrace { + _ = forceTerminationIfStillMatching() + } + } + +#if DEBUG + nonisolated static func shouldAcceptDuplicateShutdownRequestForTesting( + _ request: SingleInstanceShutdownRequest?, + currentProcessKey: ProgramaSingleInstanceProcessKey, + now: TimeInterval, + resolvedRequesterKey: ProgramaSingleInstanceProcessKey?, + requesterIsProgramaGUI: Bool + ) -> Bool { + shouldAcceptDuplicateShutdownRequest( + request, + currentProcessKey: currentProcessKey, + now: now, + resolvedRequesterKey: resolvedRequesterKey, + requesterIsProgramaGUI: requesterIsProgramaGUI + ) + } + + nonisolated static func shouldWarnBeforeTerminationForTesting( + isTaggedDevBuild: Bool, + isQuitWarningConfirmed: Bool, + hasValidatedDuplicateShutdownRequest: Bool, + isQuitWarningEnabled: Bool + ) -> Bool { + shouldWarnBeforeTermination( + isTaggedDevBuild: isTaggedDevBuild, + isQuitWarningConfirmed: isQuitWarningConfirmed, + hasValidatedDuplicateShutdownRequest: hasValidatedDuplicateShutdownRequest, + isQuitWarningEnabled: isQuitWarningEnabled + ) + } + + nonisolated static func shouldForceDuplicateTerminationForTesting( + expectedProcessKey: ProgramaSingleInstanceProcessKey, + resolvedProcessKey: ProgramaSingleInstanceProcessKey?, + isTerminated: Bool, + requestIsPending: Bool + ) -> Bool { + shouldForceDuplicateTermination( + expectedProcessKey: expectedProcessKey, + resolvedProcessKey: resolvedProcessKey, + isTerminated: isTerminated, + requestIsPending: requestIsPending + ) + } + + static func scheduleDuplicateTerminationForTesting( + requestTermination: () -> Bool, + scheduleGrace: (@escaping @MainActor () -> Void) -> Void, + forceTerminationIfStillMatching: @escaping @MainActor () -> Bool + ) { + scheduleDuplicateTermination( + requestTermination: requestTermination, + scheduleGrace: scheduleGrace, + forceTerminationIfStillMatching: forceTerminationIfStillMatching + ) + } +#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, + 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() + duplicateTerminationGraceInterval) { @MainActor in + action() + } + }, + forceTerminationIfStillMatching: { + 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 resolvedApplication = NSRunningApplication(processIdentifier: processIdentifier) else { + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=no_longer_running") + return false + } + 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 + } + + 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 currentPid = ProcessInfo.processInfo.processIdentifier + 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 else { continue } - app.terminate() - if !app.isTerminated { - _ = app.forceTerminate() + 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 { + dilog("single_instance", "pid=\(app.processIdentifier) outcome=ignored reason=election") + continue + } + Self.terminateDuplicateApplication( + app, + expectedProcessKey: otherKey, + requesterProcessKey: currentKey + ) } } 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) .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, @@ -8873,19 +9284,30 @@ 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 } - app.terminate() - if !app.isTerminated { - _ = app.forceTerminate() + 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 + } + MainActor.assumeIsolated { + Self.terminateDuplicateApplication( + app, + expectedProcessKey: otherKey, + requesterProcessKey: currentKey + ) + NSRunningApplication.current.activate(options: [.activateAllWindows]) } - NSRunningApplication.current.activate(options: [.activateAllWindows]) } } diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 72528787..2118ab94 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,328 @@ 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 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 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: (@MainActor () -> Void)? + + AppDelegate.scheduleDuplicateTerminationForTesting( + requestTermination: { + gracefulTerminationCount += 1 + return true + }, + 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 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, + requestIsPending: true + )) + XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( + expectedProcessKey: expected, + resolvedProcessKey: changed, + isTerminated: false, + requestIsPending: true + )) + XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( + expectedProcessKey: expected, + resolvedProcessKey: expected, + isTerminated: true, + requestIsPending: true + )) + XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( + expectedProcessKey: expected, + resolvedProcessKey: expected, + isTerminated: false, + requestIsPending: false + )) + XCTAssertTrue(AppDelegate.shouldForceDuplicateTerminationForTesting( + expectedProcessKey: expected, + resolvedProcessKey: expected, + isTerminated: false, + requestIsPending: true + )) + } + + 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)) + + 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") 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