Skip to content

fix: prevent duplicate instance on notification tap (Mac Catalyst)#369

Merged
PureWeen merged 4 commits intomainfrom
fix/when-i-get-a-notification-from-a-session-20260313-0958
Mar 13, 2026
Merged

fix: prevent duplicate instance on notification tap (Mac Catalyst)#369
PureWeen merged 4 commits intomainfrom
fix/when-i-get-a-notification-from-a-session-20260313-0958

Conversation

@StephaneDelcroix
Copy link
Copy Markdown
Collaborator

Bug

When a user taps a notification from a session on Mac Catalyst, macOS launches a new PolyPilot instance (with a different version) instead of activating the existing one and navigating to the session.

Root Cause

Two issues:

  1. No single-instance enforcement — macOS Launch Services may resolve a different .app bundle (build output vs staging dir), launching a second process.
  2. NotificationTapped event never subscribed — the event was defined on INotificationManagerService but nobody listened for it, so tapping a notification did nothing even in the correct instance.

Fix (3 changes)

1. Single-instance lock in Program.cs (Mac Catalyst)

Acquires an exclusive file lock (~/.polypilot/instance.lock) at startup. If another instance already holds the lock, activates the existing window via AppleScript and exits immediately. The OS releases the lock automatically when the process terminates (including crashes).

2. Wire NotificationTapped → session navigation in App.xaml.cs

Subscribes to the notification service's NotificationTapped event and calls SwitchToSessionById() on the main thread.

3. Add SwitchToSessionById to CopilotService

Notifications carry the SDK session GUID, but sessions are keyed by name. This method looks up the session by SessionId (case-insensitive) and delegates to SwitchSession().

Tests

  • 10 new tests in NotificationNavigationTests.cs covering:
    • SwitchToSessionById — correct lookup, non-existent ID, empty dict, case-insensitive, multiple sessions, remote mode bridge integration
    • NotificationTappedEventArgs — round-trip and null default
    • Instance lock file — exclusive access semantics and release-after-dispose
  • All 2497 tests pass ✅
  • Mac Catalyst build succeeds ✅

StephaneDelcroix and others added 4 commits March 13, 2026 11:10
When a user taps a notification on Mac Catalyst, macOS Launch Services may
resolve a different .app bundle (e.g. build output vs staging directory),
launching a second PolyPilot instance instead of activating the existing one.

Three fixes:

1. Single-instance enforcement in Program.cs (Mac Catalyst):
   Acquires an exclusive file lock (~/.polypilot/instance.lock) at startup.
   If another instance already holds the lock, activates its window via
   AppleScript and exits immediately.

2. Wire NotificationTapped event in App.xaml.cs:
   Subscribes to the notification service's NotificationTapped event and
   calls SwitchToSessionById to navigate to the correct session.
   Previously the event was defined but never subscribed to.

3. Add SwitchToSessionById to CopilotService:
   Notifications carry the SDK session GUID but sessions are keyed by name.
   This method looks up the session by SessionId and delegates to
   SwitchSession.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- TryAcquireInstanceLock: catch{return false} instead of catch{return true}
  to prevent duplicate instances on unexpected lock errors (UnauthorizedAccessException, etc.)

- ActivateExistingInstance: accepts args[], extracts --session-id=<id>, writes
  ~/.polypilot/pending-navigation.json so the running instance can navigate

- NotificationManagerService.SendNotificationAsync: writes pending-navigation.json
  when sending a notification with a sessionId; OnNotificationTapped deletes it
  when the delegate fires normally (avoiding double-navigation)

- App.xaml.cs: CheckPendingNavigation() reads+deletes the sidecar on window.Activated
  and OnResume(), then calls SwitchToSessionById on the main thread

- Fix JsonDocument disposal (using var) in CheckPendingNavigation

- Add 3 sidecar roundtrip tests to NotificationNavigationTests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ation

WritePendingNavigation now includes a writtenAt timestamp in the JSON payload.
CheckPendingNavigation discards sidecars older than 30 seconds — preventing
spurious session switches when the user ignored/dismissed the notification and
later brings the app to the foreground via Cmd+Tab or dock click.

The 30s window covers the brief second-instance race (the OS typically launches
a second instance within ~2s of notification tap), while reliably discarding
sidecars from notifications the user never interacted with.

Add 3 TTL-specific tests to NotificationNavigationTests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ActivateExistingInstance now includes writtenAt=DateTime.UtcNow in the
sidecar payload, matching the schema written by WritePendingNavigation.
This ensures the 30s TTL in CheckPendingNavigation is applied even if the
AppleScript activation fails and the sidecar is left on disk.

The 30s window is orders of magnitude larger than the normal second-instance
latency (~100ms), so it will never expire during normal operation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen
Copy link
Copy Markdown
Owner

Multi-Model Review -- Round 3 Final Report

CI: ⚠️ No checks configured | Tests: 16/16 passing | Models: 5/5 reporting

Findings Status (3 rounds of fixes applied)

Finding Severity Status
F1: ActivateExistingInstance drops notification SessionId 🔴 CRITICAL ✅ FIXED -- sidecar file mechanism added
F2: TryAcquireInstanceLock fails-open on non-IOException 🟡 MODERATE ✅ FIXED -- catch { return false } (fail-closed)
F3: Directory.CreateDirectory IOException masked as lock contention 🟢 MINOR ⚠️ Still present -- non-blocking (both paths fail-closed)
F4: Stale sidecar causes spurious session navigation 🟡 MODERATE ✅ FIXED -- 30s TTL on sidecar
F5: Second-instance sidecar bypasses TTL (missing writtenAt) 🟡 MODERATE ✅ FIXED -- writtenAt added to both write paths

Verdict: ✅ Approve

All consensus findings resolved across 3 fix rounds. Only remaining item (F3) is a diagnostic clarity edge case with no functional impact. 16 unit tests cover all key paths including TTL expiry.

@PureWeen
Copy link
Copy Markdown
Owner

Multi-Model Review — Round 3 Final Report

CI: ⚠️ No checks configured | Tests: 16/16 passing | Models: 5/5 reporting

Findings Status (3 rounds of fixes applied)

Finding Severity Status
F1: ActivateExistingInstance drops notification SessionId 🔴 CRITICAL ✅ FIXED -- sidecar file mechanism added
F2: TryAcquireInstanceLock fails-open on non-IOException 🟡 MODERATE ✅ FIXED -- catch { return false } (fail-closed)
F3: Directory.CreateDirectory IOException masked as lock contention 🟢 MINOR ⚠️ Still present -- non-blocking (both paths fail-closed)
F4: Stale sidecar causes spurious session navigation 🟡 MODERATE ✅ FIXED -- 30s TTL on sidecar
F5: Second-instance sidecar bypasses TTL (missing writtenAt) 🟡 MODERATE ✅ FIXED -- writtenAt added to both write paths

Verdict: ✅ Approve

All consensus findings resolved across 3 fix rounds. Only remaining item (F3) is a diagnostic clarity edge case with no functional impact. 16 unit tests cover all key paths including TTL expiry.

@PureWeen PureWeen merged commit e1e10c4 into main Mar 13, 2026
@PureWeen PureWeen deleted the fix/when-i-get-a-notification-from-a-session-20260313-0958 branch March 13, 2026 18:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants