Skip to content

Fix device connectivity, bridge protocol hardening, and 60+ integration tests#159

Merged
PureWeen merged 19 commits intomainfrom
fix/device-fixes
Feb 20, 2026
Merged

Fix device connectivity, bridge protocol hardening, and 60+ integration tests#159
PureWeen merged 19 commits intomainfrom
fix/device-fixes

Conversation

@PureWeen
Copy link
Owner

@PureWeen PureWeen commented Feb 20, 2026

Summary

Fixes device connectivity issues (mobile session switching, QR code scanning, model sync), hardens the WebSocket bridge protocol against race conditions and resource leaks, and adds 60+ integration tests with real WebSocket connections. All findings from 3 rounds of multi-model code review (Opus 4.6, Sonnet 4.6, Codex 5.3) have been addressed.

21 files changed, +1,879 -72 lines | 801 tests passing


Bridge Protocol Hardening

  • ODE protection on SendToClientAsync, WsBridgeClient.SendAsync, and Broadcast - prevents ObjectDisposedException crashes when semaphores are disposed during concurrent shutdown
  • RenameSession server handler now checks return value and sends ErrorEvent on failure (matching ChangeModel pattern)
  • RenameSession remote mode adds _pendingRemoteSessions protection to prevent SyncRemoteSessions from pruning optimistic renames, plus error logging on fire-and-forget
  • ChangeModelAsync returns false for non-existent remote sessions instead of silently succeeding
  • Directory list fallback only triggers for null RequestId (legacy server), not unknown IDs
  • Receive loop drains pending _dirListRequests on disconnect so callers don't hang for 10s
  • Event handler leak prevented with _bridgeEventsWired guard on reconnect

Device Connectivity Fixes

  • Model switching now works in remote mode - bridge protocol extended with change_model command
  • Session switching on mobile - expandedSession stays in sync with active session
  • QR code scanning handles both polypilot:// URIs and JSON formats with Uri.TryCreate (no more crashes on malformed input)
  • Direct share QR codes - black-on-white, 12px/module, 280px display with white padding
  • Chat history loads immediately on initial remote connect (was empty until first message)
  • HandleComplete regression - removed harmful InvokeAsync wrapper that deferred StateHasChanged() causing stuck "Thinking" indicators

New Tests (60+ integration tests)

  • WsBridgeIntegrationTests.cs - real localhost WebSocket server+client pairs covering: state sync, session lifecycle (create/close/rename/switch), message flow (send/queue/turn events), model changes, organization commands, directory listing, auth, history sync, attention events, error propagation
  • RepoManagerTests.cs - URL parsing for HTTPS/SSH repo IDs and normalization
  • HandleComplete_MustNotUseInvokeAsync_RegressionGuard - source-scanning test that prevents re-wrapping HandleComplete in InvokeAsync
  • Replaced hardcoded Task.Delay with WaitForAsync polling in flaky tests
  • Fixed SwitchSession test false positive (asserts ActiveSessionName, not list membership)

Multi-Model Review Summary

Round Reviewers Issues Found Issues Fixed
1 2x Opus 4.6, 2x Sonnet 4.6, 2x Codex 5.3 8 8
2 3x Opus 4.6, 2x Sonnet 4.6, 1x Codex 5.3 8 8
3 3x Opus 4.6, 2x Sonnet 4.6, 1x Codex 5.3 6 6
Total 18 reviews 22 22

PureWeen and others added 19 commits February 20, 2026 11:21
ChangeModelAsync had no remote mode path — it tried to use the local
CopilotClient which is null on mobile, silently failing. The bridge
protocol had no message type for model changes.

Added the full bridge plumbing:
- BridgeMessageTypes.ChangeModel constant
- ChangeModelPayload with SessionName + NewModel
- IWsBridgeClient.ChangeModelAsync interface method
- WsBridgeClient.ChangeModelAsync implementation
- WsBridgeServer handler dispatching to CopilotService.ChangeModelAsync
- CopilotService.ChangeModelAsync remote mode delegation with
  optimistic local state update

Tests:
- ChangeModel message type constant
- Round-trip serialization
- Payload defaults
- CamelCase serialization
- AllClientToServerTypes uniqueness updated

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Stand up a real WsBridgeServer on localhost, connect a real
WsBridgeClient, and verify end-to-end message flows — simulating
the mobile → devtunnel → desktop path without needing a real tunnel.

Tests cover: connect, create session, change model, abort, close,
and session list synchronization.

Also fix ChangeModelAsync for demo mode — it was hitting the SDK
path which requires a real CopilotClient. Demo mode now just
updates the model label locally.

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

- 31 integration tests using real WsBridgeServer/Client on localhost
- Covers: connection, session CRUD, messaging, content deltas, queuing,
  model switching, session switching, directory listing, security (path
  traversal), and all organization commands (create/rename/delete group,
  pin/unpin, move, toggle collapsed, sort mode, broadcast)
- Fix demo mode CreateSessionAsync missing SessionMeta in Organization
- Fix StubDemoService to fire content/turn events for realistic testing

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Bug fixes:
- AbortSessionAsync: Add IsDemoMode guard to prevent NRE when Session
  is null in demo mode
- RenameSession: Add full bridge plumbing (message type, payload,
  client method, server handler, remote mode delegation) so renames
  propagate over devtunnel instead of silently reverting on next sync
- EnqueueMessage: Add IsRemoteMode delegation to forward queued
  messages to the bridge server
- CloseSessionAsync: Change guard from IsConnected to IsRemoteMode
  for consistency with all other bridge-delegating methods
- ListDirectoriesAsync: Replace single _dirListTcs field with
  ConcurrentDictionary keyed by RequestId to prevent race condition
  when concurrent directory listing calls corrupt each other's state

Tests: 9 new tests covering all 5 fixes including concurrent
directory listing, demo mode abort, rename via bridge, and protocol
round-trip serialization.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Root cause: SyncRemoteSessions updated IsProcessing and MessageCount
from server broadcasts but NOT the Model field. When ChangeModelAsync
succeeded on the server and broadcast the updated sessions list, the
mobile client ignored the model update for existing sessions.

Also fixed:
- WsBridgeServer ChangeModel handler: check return value, send error
  to client on failure, and always broadcast sessions list for sync
- SessionErrorEvent: reset ActiveToolCallCount and HasUsedToolsThisTurn
  to match abort/watchdog cleanup (prevents stale tool count on next turn)
- DeletePersistedSession: add IsDemoMode guard to prevent deleting
  real session data from disk while in demo mode

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
SaveActiveSessionsToDisk now skips writes in demo mode and during
restore, preventing integration tests from overwriting the real
~/.polypilot/active-sessions.json file with empty/partial data.

Also fix SessionDisposalResilienceTests to account for demo service
now generating assistant responses.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
InitializeRemoteAsync now waits for SessionsList and SessionHistory
messages to arrive from the server before returning. Previously,
ConnectAsync returned as soon as the WebSocket handshake completed,
but the receive loop hadn't processed the initial state yet. The
Dashboard's RefreshState guard (_initializationComplete) then dropped
all OnStateChanged events during initialization, so history synced
by SyncRemoteSessions was never rendered until the next user action.

Now we poll for sessions and history before returning, then explicitly
call SyncRemoteSessions to ensure all history is in local session
objects before the Dashboard renders.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Bugs fixed:
- SessionHistories changed from Dictionary to ConcurrentDictionary
  (was read/written from 2 threads, risked InvalidOperationException)
- close_session handler now guards against empty SessionName
  (was the only handler missing this check)
- URL normalization now handles ws:// and wss:// schemes without
  double-prefixing (was producing wss://ws://...)

New test coverage (23 tests):
- ResumeSession: GUID validation, non-existent session error
- Broadcast events: TurnStart/TurnEnd reach client
- ChangeModel guards: processing, non-existent, same, empty
- Multi-client: both receive sessions list and content deltas
- Close session: empty name ignored
- URL normalization: ws:// and http:// connect correctly
- DiffParser: 7 tests covering all parse paths

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- WsBridgeClient: Add SemaphoreSlim around SendAsync to prevent concurrent
  WebSocket sends (ClientWebSocket prohibits this)
- WsBridgeServer: Fix Broadcast semaphore Release/Dispose ordering to prevent
  ObjectDisposedException and leaked waiters
- RepoManager: Fix RepoIdFromUrl stripping .git from middle of repo names
  by only removing trailing .git suffix
- WsBridgeIntegrationTests: Replace fixed Task.Delay with polling loops
  (WaitForAsync helper) for reliability under parallel test load
- Add RepoManagerTests with 10 tests covering URL parsing and normalization

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
WsBridgeClient:
- Fix ReconnectAsync CTS race: capture CTS as local to prevent
  ConnectAsync from replacing it mid-loop, causing dual connections
- Fix HttpMessageInvoker leak on successful connection path
- Dispose _sendLock in Dispose() to prevent SemaphoreSlim leak

WsBridgeServer:
- Fix Broadcast ObjectDisposedException: move WaitAsync inside try block
  so Stop()-disposed semaphores don't cause unobserved task exceptions
- Add BroadcastSessionsList after SwitchSession for multi-client sync
- Guard BuildSessionsListPayload against null _copilot in HandleClientAsync

CopilotService:
- Fix remote-mode RenameSession: add duplicate name check before overwrite
- Fix remote-mode ChangeModelAsync: add IsProcessing and same-model guards
- Fix SessionStartEvent: wrap SaveActiveSessionsToDisk in Invoke() to
  marshal file write to UI thread, preventing concurrent file corruption
- Set IsRemoteMode before SyncRemoteSessions in InitializeRemoteAsync
  to prevent ReconcileOrganization from running unnecessary file I/O

RepoManager:
  ssh:// protocol URLs and credential-embedded HTTPS URLs correctly
- Add tests for ssh:// and credential URL parsing

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

- TurnEnd broadcast: verify client receives turn_end event after demo prompt
- Auth loopback bypass: verify connection succeeds with AccessToken set
  when connecting via localhost (loopback trusted)
- SwitchSession broadcast: verify session list is broadcast after switch

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Added HasReceivedSessionsList flag to WsBridgeClient to distinguish
'server replied with zero sessions' from 'server hasn't replied yet'.
Previously Sessions.Any() was used which stays false for empty lists,
causing a 5-second busy-wait timeout on every connect to an empty server.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace Task.Delay(500) with WaitForAsync polling in more bridge
integration tests for reliability under parallel test load:
- CreateSession_AppearsOnServer
- CreateSession_WithModel_SetsModelOnServer
- SendMessage_AddsUserMessageToServerHistory
- ResumeSession_InvalidGuid_ReturnsErrorToClient
- ResumeSession_NonExistentGuid_ReturnsResumeFailedError
- ChangeModel_WhileProcessing_SendsErrorToClient

All 771 tests pass consistently (5/5 runs clean).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix UriFormatException crash on malformed polypilot:// QR codes (use Uri.TryCreate)
- Fix event handler leak: guard InitializeRemoteAsync to only wire bridge events once
- Fix RenameSession remote: check IsConnected, return false when session not found
- Fix Broadcast ODE catch: clean up stale client on ObjectDisposedException
- Fix WsBridgeClient SendAsync: protect _sendLock against ObjectDisposedException
- Fix directory list fallback: only use legacy fallback when RequestId is null
- Fix expandedSession sync: keep in sync with active session on mobile
- Bigger/clearer direct share QR code (black on white, 280px)
- Regenerate direct QR code after IP discovery

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix ChangeModelAsync returning true for non-existent remote sessions (4/6 reviewers)
- Fix RenameSession remote: add _pendingRemoteSessions protection (3/6 reviewers)
- Fix RenameSession server: check return value, send ErrorEvent on failure (3/6 reviewers)
- Fix SendToClientAsync: add ODE protection matching Broadcast pattern (2/6 reviewers)
- Fix RenameSession fire-and-forget: add error logging continuation
- Fix SwitchSession test: assert ActiveSessionName instead of list membership
- Fix ListDirectoriesAsync: drain pending requests on receive loop exit
- Fix HasHistoryImmediately test: replace hardcoded delay with WaitForAsync

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix EnqueueMessage: warn about dropped images in remote mode, add error logging
- Fix rename ghost duplicate: track old name in _pendingRemoteRenames to prevent
  SyncRemoteSessions from re-adding it during the rename window
- Fix CancellationTokenSource leak: dispose old CTS in Stop() before creating new
- Skip re-adding old names from pending renames in SyncRemoteSessions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Wrap remote ChangeModelAsync bridge call in try/catch to match local path behavior
  (callers don't expect exceptions from this method)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
HandleComplete is called from CompleteResponse which is already marshaled
to the UI thread via Invoke(SynchronizationContext.Post). The InvokeAsync
wrapper queued a second deferral, causing StateHasChanged() to run too
late — after other RefreshState calls consumed the dirty flag, so the DOM
never updated with IsProcessing=false.

Keep InvokeAsync only on the 10-second delayed cleanup callback which
runs on the thread-pool via Task.Delay.ContinueWith.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Source-scanning test that verifies HandleComplete in Dashboard.razor has
at most one InvokeAsync call (the delayed cleanup only), and that call
must be inside a Task.Delay continuation. If someone wraps the body in
InvokeAsync again, the test fails with an explanatory message about why
it causes stuck 'Thinking' indicators.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen PureWeen changed the title Device fixes: bridge integration tests, QR codes, session switching, multi-model review fixes Fix device connectivity, bridge protocol hardening, and 60+ integration tests Feb 20, 2026
@PureWeen PureWeen merged commit c7fe132 into main Feb 20, 2026
6 checks passed
PureWeen added a commit that referenced this pull request Feb 20, 2026
Resolve 3 conflicts:
- BridgeMessages.cs: keep both multi-agent + change-model/rename constants
- CopilotService.cs: take main's fresh TCS + On() ordering, keep our
  ProcessingGeneration/HasUsedToolsThisTurn carry-forward
- WsBridgeClient.cs: keep multi-agent methods + main's ConcurrentDictionary

4 pre-existing DiffParser test failures from main PR #159 (not ours).
1014 tests passing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen PureWeen deleted the fix/device-fixes branch February 22, 2026 00:15
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.

1 participant