Skip to content

Conversation

@slepp
Copy link

@slepp slepp commented Jan 19, 2026

Potentially Fixes GitHub issue #577: 28ms UI stutters every ~1 second when MCP plugin is running

Root Cause

The MCPForUnityEditorWindow.OnEditorUpdate() hook was being called every frame (60+ times/sec) to poll connection status. This triggered UpdateConnectionStatus()IsLocalHttpServerReachable(), which makes network socket connection attempts with 50ms timeouts on the main thread.

Architectural Changes

  • Remove OnEditorUpdate hook that polled every frame
  • Replace continuous polling with event-driven connection status updates:
    • User actions (manual refreshes via RefreshAllData)
    • Bridge state changes (via RequestHealthVerification)
    • Window focus events (via OnFocus)

Connection status now only updates when actually needed.

Performance Improvements

  1. Eliminated main thread blocking network I/O

    • Network socket checks no longer execute 60+ times per second
    • Main thread stays responsive for gameplay
  2. TransportCommandDispatcher: Early exit optimization

    • Early Pending.Count check prevents List allocations when idle
    • Before: ~90 allocations/sec → 28ms GC spike every ~1 second
    • After: 0 allocations/sec when idle → no GC stuttering
  3. EditorStateCache: Sequence-based caching

    • Only increments sequence number when state actually changes
    • Prevents unnecessary DeepClone() calls on every GetSnapshot()
  4. StdioBridgeHost: Consistent early exit pattern

    • Added same early commandQueue.Count check as TransportCommandDispatcher

Testing

  • Verified MCP plugin runs without stuttering
  • Scene hierarchy queries work correctly
  • Editor menu items function properly
  • Smooth gameplay without frame drops

Summary by Sourcery

Reduce editor stuttering and GC pressure by avoiding unnecessary per-frame work and allocations in MCP editor integration.

Bug Fixes:

  • Stop per-frame connection status polling in the MCP editor window to prevent main-thread blocking network calls that caused UI stutters.
  • Avoid repeated DeepClone allocations in editor state snapshots by only updating the cached clone when state actually changes.

Enhancements:

  • Short-circuit idle command processing in TransportCommandDispatcher and StdioBridgeHost to eliminate per-frame list allocations when no work is queued.
  • Track editor state changes with a sequence-based mechanism so snapshots are only marked updated when relevant conditions change.

Tests:

  • Add performance and behavior tests for TransportCommandDispatcher to verify idle operation does not allocate excessively and that commands are still processed correctly under various conditions.

Summary by CodeRabbit

  • Performance

    • Reduced garbage collection overhead through smart caching and optimized allocation patterns.
    • Eliminated per-frame allocations and processing when no pending tasks exist.
    • Conditional hook management reduces continuous polling overhead.
  • Improvements

    • Status monitoring now event-driven rather than continuously polled.
  • Tests

    • Added comprehensive test suites validating optimization behavior, performance metrics, and error handling scenarios.

✏️ Tip: You can customize this high-level summary in your review settings.

…alls

Fixes GitHub issue CoplayDev#577: 28ms UI stutters every ~1 second when MCP plugin is running

## Root Cause

The `MCPForUnityEditorWindow.OnEditorUpdate()` hook was being called every frame (60+ times/sec) to poll connection status. This triggered `UpdateConnectionStatus()` → `IsLocalHttpServerReachable()`, which makes network socket connection attempts with 50ms timeouts on the main thread.

## Architectural Changes

- Remove `OnEditorUpdate` hook that polled every frame
- Replace continuous polling with event-driven connection status updates:
  - User actions (manual refreshes via `RefreshAllData`)
  - Bridge state changes (via `RequestHealthVerification`)
  - Window focus events (via `OnFocus`)

Connection status now only updates when actually needed.

## Performance Improvements

1. **Eliminated main thread blocking network I/O**
   - Network socket checks no longer execute 60+ times per second
   - Main thread stays responsive for gameplay

2. **TransportCommandDispatcher: Early exit optimization**
   - Early `Pending.Count` check prevents List allocations when idle
   - Before: ~90 allocations/sec → 28ms GC spike every ~1 second
   - After: 0 allocations/sec when idle → no GC stuttering

3. **EditorStateCache: Sequence-based caching**
   - Only increments sequence number when state actually changes
   - Prevents unnecessary `DeepClone()` calls on every `GetSnapshot()`

4. **StdioBridgeHost: Consistent early exit pattern**
   - Added same early `commandQueue.Count` check as TransportCommandDispatcher

## Testing

- Verified MCP plugin runs without stuttering
- Scene hierarchy queries work correctly
- Editor menu items function properly
- Smooth gameplay without frame drops

Co-Authored-By: Claude Haiku 4.5 <[email protected]>
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Jan 19, 2026

Reviewer's Guide

Reworks editor networking and command dispatch to eliminate per-frame main-thread network checks and idle allocations, adds early-exit patterns for queues, and optimizes editor state caching to avoid unnecessary cloning, plus regression tests to guard against GC spikes and dispatcher regressions.

Sequence diagram for event-driven main thread command dispatch

sequenceDiagram
    actor BackgroundTask
    participant TransportCommandDispatcher
    participant SynchronizationContext
    participant UnityEditorLoop

    BackgroundTask->>TransportCommandDispatcher: EnqueuePendingCommand
    activate TransportCommandDispatcher
    TransportCommandDispatcher->>TransportCommandDispatcher: RequestMainThreadPump
    TransportCommandDispatcher->>SynchronizationContext: Post(ProcessQueue)
    TransportCommandDispatcher->>UnityEditorLoop: QueuePlayerLoopUpdate
    deactivate TransportCommandDispatcher

    UnityEditorLoop->>TransportCommandDispatcher: EditorApplication.update -> ProcessQueue
    activate TransportCommandDispatcher
    TransportCommandDispatcher->>TransportCommandDispatcher: lock PendingLock
    alt Pending.Count == 0
        TransportCommandDispatcher->>TransportCommandDispatcher: UnhookUpdateIfIdle
        TransportCommandDispatcher-->>UnityEditorLoop: return
    else Pending.Count > 0
        TransportCommandDispatcher->>TransportCommandDispatcher: ready = new List(Pending.Count)
        TransportCommandDispatcher->>TransportCommandDispatcher: Move commands to ready
        TransportCommandDispatcher-->>UnityEditorLoop: Process ready commands
        TransportCommandDispatcher->>TransportCommandDispatcher: UnhookUpdateIfIdle (if now idle)
    end
    deactivate TransportCommandDispatcher
Loading

Sequence diagram for event-driven connection status updates in MCP editor window

sequenceDiagram
    actor User
    participant MCPForUnityEditorWindow
    participant ConnectionSection
    participant Bridge

    User->>MCPForUnityEditorWindow: Open window
    activate MCPForUnityEditorWindow
    MCPForUnityEditorWindow->>MCPForUnityEditorWindow: OnEnable
    MCPForUnityEditorWindow->>MCPForUnityEditorWindow: Do not hook OnEditorUpdate

    User->>MCPForUnityEditorWindow: Focus window
    MCPForUnityEditorWindow->>MCPForUnityEditorWindow: OnFocus
    MCPForUnityEditorWindow->>MCPForUnityEditorWindow: RefreshAllData
    MCPForUnityEditorWindow->>ConnectionSection: UpdateConnectionStatus

    Bridge-->>MCPForUnityEditorWindow: RequestHealthVerification / state change
    activate Bridge
    MCPForUnityEditorWindow->>ConnectionSection: UpdateConnectionStatus
    deactivate Bridge

    User->>MCPForUnityEditorWindow: Manual refresh action
    MCPForUnityEditorWindow->>MCPForUnityEditorWindow: RefreshAllData
    MCPForUnityEditorWindow->>ConnectionSection: UpdateConnectionStatus

    deactivate MCPForUnityEditorWindow
Loading

Updated class diagram for editor networking, dispatch, and state cache

classDiagram
    class EditorStateCache {
        <<static>>
        -double MinUpdateIntervalSeconds
        -JObject _cached
        -JObject _cachedClone
        -long _cachedSequence
        -string _lastTrackedScenePath
        -bool _isUpdating
        -bool? _lastTrackedIsFocused
        -bool? _lastTrackedIsPlaying
        -bool? _lastTrackedIsUpdating
        -string _lastTrackedActivityPhase
        -long _sequence
        -long _observedUnixMs
        +JObject GetSnapshot()
        -JObject BuildSnapshot(string reason)
        -void ForceUpdate(string reason)
    }

    class EditorStateSnapshot {
        +string SchemaVersion
        +long Sequence
        +long ObservedUnixMs
        +string ScenePath
        +bool IsFocused
        +bool IsPlaying
        +bool IsUpdating
        +string ActivityPhase
        +string TestsMode
        +string CurrentJobId
    }

    class TransportCommandDispatcher {
        <<static>>
        -Dictionary~string, PendingCommand~ Pending
        -object PendingLock
        -bool updateHooked
        +void EnqueuePendingCommand(string id, PendingCommand command)
        +void RequestMainThreadPump()
        -void HookUpdate()
        -void UnhookUpdateIfIdle()
        -void ProcessQueue()
    }

    class PendingCommand {
        +string Id
        +object Payload
        +void Execute()
    }

    class StdioBridgeHost {
        <<static>>
        -Dictionary~string, QueuedCommand~ commandQueue
        -object lockObj
        -void ProcessCommands()
    }

    class QueuedCommand {
        +string Id
        +object Payload
        +void Execute()
    }

    class MCPForUnityEditorWindow {
        -bool guiCreated
        -bool toolsLoaded
        -VisualElement rootVisualElement
        -ConnectionSection connectionSection
        +void OnEnable()
        +void OnDisable()
        +void OnFocus()
        -void RefreshAllData()
        -void EnsureToolsLoaded()
    }

    class ConnectionSection {
        +void UpdateConnectionStatus()
    }

    EditorStateCache "1" *-- "1" EditorStateSnapshot
    TransportCommandDispatcher "1" o-- "*" PendingCommand
    StdioBridgeHost "1" o-- "*" QueuedCommand
    MCPForUnityEditorWindow --> ConnectionSection
    StdioBridgeHost --> TransportCommandDispatcher
    MCPForUnityEditorWindow --> EditorStateCache
Loading

File-Level Changes

Change Details Files
Optimize editor state caching to avoid unnecessary DeepClone allocations and only bump sequence when state actually changes.
  • Add cached clone and sequence tracking fields for editor state snapshots.
  • Compute editor play/focus/update flags once and use them for both activity phase and change detection.
  • Detect meaningful state changes (scene path, focus, play mode, updating, activity phase) and only increment sequence when they change.
  • Maintain last-tracked state fields to compare against new snapshot inputs.
  • Cache a cloned JObject snapshot keyed by sequence and reuse it in GetSnapshot instead of DeepCloning on every call.
MCPForUnity/Editor/Services/EditorStateCache.cs
Remove per-frame editor window polling that performed blocking network checks and switch to event-driven connection status updates.
  • Stop registering OnEditorUpdate with EditorApplication.update in OnEnable and update comments to explain removal.
  • Remove OnEditorUpdate method that previously called UpdateConnectionStatus every frame.
  • Rely on OnFocus and RefreshAllData to trigger connection status updates only when needed.
MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs
Prevent idle per-frame allocations in TransportCommandDispatcher by early exiting when there are no pending commands and unhooking the update callback when idle.
  • Remove permanent EditorApplication.update hook setup from static constructor in favor of on-demand hooking.
  • Implement UnhookUpdateIfIdle to detach the update callback when the pending queue is empty, using RequestMainThreadPump to rehook when work arrives.
  • Add an early Pending.Count check in ProcessQueue to avoid allocating a List when there are no pending commands and to unhook when idle.
MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs
Apply the same idle early-exit allocation optimization to StdioBridgeHost command processing.
  • Add early commandQueue.Count check in ProcessCommands to return immediately without allocating work lists when idle.
MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs
Add performance and behavior regression tests around TransportCommandDispatcher to ensure no idle allocations, correct processing, and wakeup behavior.
  • Introduce performance tests simulating editor update frames to assert minimal memory growth when idle.
  • Verify commands are processed correctly when pending, including ping commands completing successfully.
  • Test that RequestMainThreadPump and unhook/rehook behavior still ensures commands are processed after idle periods.
  • Add a GC-focused idle-frame test to confirm absence of GC-pressure spikes.
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TransportCommandDispatcherPerformanceTests.cs
Add functional and edge-case tests for TransportCommandDispatcher optimization and API behavior.
  • Test successful execution of simple ping commands with timeouts.
  • Verify cancellation behavior when tokens are canceled before processing.
  • Exercise multiple rapid commands to confirm they all complete successfully.
  • Validate error handling for invalid, empty, and null JSON inputs.
  • Check ordering and success of rapid-fire ping commands returning pong responses.
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TransportCommandDispatcherOptimizationTests.cs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 19, 2026

📝 Walkthrough

Walkthrough

This PR optimizes MCPForUnity editor services by eliminating continuous polling and reducing garbage collection overhead. Changes include conditional editor update hook management, state caching with change tracking, early-return optimizations in queue processing, and comprehensive performance test coverage to validate reduced allocations during idle states.

Changes

Cohort / File(s) Summary
Transport Optimization
MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs, MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs
Replaces permanent EditorApplication.update subscription with conditional hooking/unhooking based on queue state. UnhookUpdateIfIdle removes the hook when idle; early-return optimizations skip list allocation when no pending commands exist.
Editor State & Window
MCPForUnity/Editor/Services/EditorStateCache.cs, MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs
EditorStateCache adds state tracking fields and caches cloned JObject snapshots, regenerating only on sequence changes; Window removes per-frame update hook and shifts to event-driven status updates, eliminating continuous polling overhead.
Test Coverage
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TransportCommandDispatcherOptimizationTests.cs, TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TransportCommandDispatcherPerformanceTests.cs
New NUnit test suites validating command dispatch under various conditions (ping, cancellation, ordering, error handling) and performance under idle/active states, confirming no GC spikes during idle frames and proper rehooking after idle periods.

Sequence Diagram(s)

sequenceDiagram
    participant EA as EditorApplication
    participant TCD as TransportCommandDispatcher
    participant Q as Command Queue
    
    rect rgba(100, 149, 237, 0.5)
    Note over TCD,Q: Idle State - Hook Removed
    EA->>TCD: Frame update (no pending commands)
    TCD->>Q: ProcessQueue()
    alt Queue is empty
        TCD->>TCD: UnhookUpdateIfIdle()
        TCD->>EA: Unsubscribe from update
    end
    end
    
    rect rgba(144, 238, 144, 0.5)
    Note over TCD,Q: Command Arrives - Hook Re-added
    Q->>TCD: ExecuteCommandJsonAsync(cmd)
    TCD->>Q: QueueCommand()
    TCD->>TCD: RequestMainThreadPump()
    TCD->>EA: Subscribe to update
    end
    
    rect rgba(255, 165, 0, 0.5)
    Note over TCD,Q: Processing - Hook Active
    EA->>TCD: Frame update (commands pending)
    TCD->>Q: ProcessQueue()
    TCD->>Q: Dequeue and execute
    Q-->>TCD: Command result
    alt All commands processed
        TCD->>TCD: UnhookUpdateIfIdle()
        TCD->>EA: Unsubscribe from update
    end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • Scriptwonder

Poem

🐰 Hooking less, unhooking more,
Our queues now rest when they've no chore.
Caches clone once, not frame by frame,
Memory smiles—optimization's our game!
Events now wake us, no endless poll,
Performant and spry, that's our new role. 🎯✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.35% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly summarizes the main objective of the PR: eliminating UI stuttering caused by main thread blocking network calls, which aligns perfectly with the primary architectural changes and performance improvements documented.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 5 issues, and left some high level feedback:

  • The new _cachedClone reuse in EditorStateCache.GetSnapshot assumes callers never mutate the returned JObject; if any consumer currently modifies the snapshot, this change alters behavior (shared state, cross-call contamination) and may require either maintaining per-call cloning or enforcing immutability at the boundaries.
  • The new performance tests that assert on GC.GetTotalMemory deltas and memory growth thresholds are likely to be environment- and runtime-dependent; consider loosening these assertions or restructuring them to avoid hard numeric limits that can cause intermittent CI flakiness.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `_cachedClone` reuse in `EditorStateCache.GetSnapshot` assumes callers never mutate the returned `JObject`; if any consumer currently modifies the snapshot, this change alters behavior (shared state, cross-call contamination) and may require either maintaining per-call cloning or enforcing immutability at the boundaries.
- The new performance tests that assert on `GC.GetTotalMemory` deltas and memory growth thresholds are likely to be environment- and runtime-dependent; consider loosening these assertions or restructuring them to avoid hard numeric limits that can cause intermittent CI flakiness.

## Individual Comments

### Comment 1
<location> `MCPForUnity/Editor/Services/EditorStateCache.cs:346-355` </location>
<code_context>

+            // Only increment sequence if something actually changed.
+            // This prevents unnecessary DeepClone() calls in GetSnapshot() when state is idle.
+            bool hasChanges = _lastTrackedScenePath != scenePath
+                || _lastTrackedIsFocused != isFocused
+                || _lastTrackedIsPlaying != isPlaying
+                || _lastTrackedIsUpdating != isUpdating
+                || _lastTrackedActivityPhase != activityPhase;
+
+            if (hasChanges)
+            {
+                _sequence++;
+            }
+
+            // Update tracked state
+            _lastTrackedScenePath = scenePath;
+            _lastTrackedIsFocused = isFocused;
+            _lastTrackedIsPlaying = isPlaying;
+            _lastTrackedIsUpdating = isUpdating;
+            _lastTrackedActivityPhase = activityPhase;
+
             var snapshot = new EditorStateSnapshot
</code_context>

<issue_to_address>
**issue (bug_risk):** Sequence increment is now decoupled from actual snapshot changes, which can cause stale snapshots to be served.

`_sequence` now only changes when the tracked fields change, but `BuildSnapshot` includes additional fields (e.g., compile state, test status, job IDs, timestamps). Those can change without updating `_sequence`, so `_cachedClone` may not be regenerated and callers can get a stale snapshot that doesn’t match `_cached` or the editor state.

If `_sequence` is meant to represent “snapshot content changed”, it should either be incremented on every `BuildSnapshot` call, or `hasChanges` should be expanded to include all snapshot fields that affect consumers. Otherwise `_cached` and `_cachedClone` can diverge in ways that are likely to break callers expecting current state.
</issue_to_address>

### Comment 2
<location> `MCPForUnity/Editor/Services/EditorStateCache.cs:467` </location>
<code_context>
+                    _cachedSequence = _sequence;
+                }
+
+                return _cachedClone;
             }
         }
</code_context>

<issue_to_address>
**issue (bug_risk):** Returning the shared cached clone allows external mutation of the cache state.

Previously `GetSnapshot()` always returned a fresh `DeepClone()`, so callers effectively saw an immutable snapshot. Reusing a single `_cachedClone` means any mutation of the returned `JObject` (intentional or accidental) will corrupt the shared cache for all callers and may desynchronize it from `_cached` and the editor state.

Unless we can guarantee all consumers treat the snapshot as read-only, this optimization is risky. A safer approach is to keep `_cached` as the authoritative state and still `DeepClone()` on each `GetSnapshot()`, only avoiding rebuilding `_cached` when `_sequence` hasn’t changed. If you keep the shared clone, please explicitly document/enforce an immutable-view contract for `GetSnapshot()` and audit current usages.
</issue_to_address>

### Comment 3
<location> `MCPForUnity/Editor/Services/EditorStateCache.cs:289-292` </location>
<code_context>

         private static JObject BuildSnapshot(string reason)
         {
-            _sequence++;
             _observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

</code_context>

<issue_to_address>
**question (bug_risk):** Timestamps and other ephemeral fields will now remain constant while state is idle, which may break time-based consumers.

Because `_sequence` no longer increments on every snapshot, timestamps like `_observedUnixMs` (and any derived values) will now stay frozen while the logical state is idle. Any consumer relying on these timestamps for heartbeats, timeouts/latency, or "last seen" diagnostics will observe a static time instead of a continuously advancing one. If those use cases matter, consider either a separate monotonic heartbeat field or forcing `_sequence` to advance (and the snapshot to update) after some time threshold, even when the tracked state is unchanged.
</issue_to_address>

### Comment 4
<location> `MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs:210-219` </location>
<code_context>

         private static void UnhookUpdateIfIdle()
         {
-            // Intentionally no-op: keep update hook installed so background commands always process.
-            // This avoids "must focus Unity to re-establish contact" edge cases.
-            return;
+            // Only unhook if there are no pending commands. RequestMainThreadPump() guarantees
+            // re-hooking via SynchronizationContext.Post + QueuePlayerLoopUpdate when commands arrive.
+            if (Pending.Count == 0 && updateHooked)
+            {
+                updateHooked = false;
</code_context>

<issue_to_address>
**suggestion (bug_risk):** UnhookUpdateIfIdle reads Pending.Count without locking, which may race if called from multiple code paths.

Within `ProcessQueue`, `UnhookUpdateIfIdle()` is called while holding `PendingLock`, so `Pending.Count` is safe there. The risk is future or alternate call sites that invoke `UnhookUpdateIfIdle()` without `PendingLock`, which would make the unsynchronized `Pending.Count` read race with writers and potentially throw `InvalidOperationException`.

If this method must always be called under `PendingLock`, consider either documenting that explicitly (e.g., name/comment) or taking `PendingLock` inside `UnhookUpdateIfIdle()` before accessing `Pending` to prevent incorrect future usage.

```suggestion
        private static void UnhookUpdateIfIdle()
        {
            // Only unhook if there are no pending commands. RequestMainThreadPump() guarantees
            // re-hooking via SynchronizationContext.Post + QueuePlayerLoopUpdate when commands arrive.
            // Must hold PendingLock while inspecting Pending to avoid races with writers.
            lock (PendingLock)
            {
                if (Pending.Count == 0 && updateHooked)
                {
                    updateHooked = false;
                    EditorApplication.update -= ProcessQueue;
                }
            }
        }
```
</issue_to_address>

### Comment 5
<location> `TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TransportCommandDispatcherPerformanceTests.cs:169-193` </location>
<code_context>
+
+        // Helper: Simulates EditorApplication.update callback
+        // Uses reflection to invoke ProcessQueue on the TransportCommandDispatcher
+        private void SimulateEditorUpdate()
+        {
+            try
+            {
+                var method = typeof(TransportCommandDispatcher)
+                    .GetMethod("ProcessQueue",
+                        System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+
+                if (method != null)
+                {
+                    method.Invoke(null, null);
+                }
+            }
+            catch (System.Reflection.TargetInvocationException ex)
+            {
+                // Unwrap the inner exception from reflection
+                if (ex.InnerException != null)
+                {
+                    throw ex.InnerException;
+                }
+                throw;
+            }
+        }
</code_context>

<issue_to_address>
**suggestion (testing):** Reflection-based invocation of ProcessQueue is brittle and could be made safer/clearer

This reflection on a private `ProcessQueue` makes the tests fragile: any rename, signature, or visibility change will cause runtime failures rather than clear compile-time errors. If you must use this pattern, consider centralizing the reflection lookup in a cached static field with an explicit assertion when the method isn’t found, or exposing an internal test hook (e.g., via `InternalsVisibleTo`) so tests can call into the dispatcher without private reflection.

```suggestion
        // Helper: Simulates EditorApplication.update callback.
        // Uses a cached reflection lookup to invoke ProcessQueue on the TransportCommandDispatcher.
        // This centralizes the private reflection and fails fast with a clear assertion if the
        // target method signature or visibility changes.
        private static readonly System.Reflection.MethodInfo s_processQueueMethod =
            typeof(TransportCommandDispatcher).GetMethod(
                "ProcessQueue",
                System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);

        private void SimulateEditorUpdate()
        {
            // Fail fast with a clear message if the method can no longer be located.
            NUnit.Framework.Assert.That(
                s_processQueueMethod,
                NUnit.Framework.Is.Not.Null,
                "TransportCommandDispatcher.ProcessQueue signature or visibility has changed. " +
                "Update the test's reflection hook accordingly.");

            try
            {
                s_processQueueMethod!.Invoke(null, null);
            }
            catch (System.Reflection.TargetInvocationException ex)
            {
                // Unwrap the inner exception from reflection for clearer test failures.
                if (ex.InnerException != null)
                {
                    throw ex.InnerException;
                }

                throw;
            }
        }
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +346 to +355
bool hasChanges = _lastTrackedScenePath != scenePath
|| _lastTrackedIsFocused != isFocused
|| _lastTrackedIsPlaying != isPlaying
|| _lastTrackedIsUpdating != isUpdating
|| _lastTrackedActivityPhase != activityPhase;

if (hasChanges)
{
_sequence++;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Sequence increment is now decoupled from actual snapshot changes, which can cause stale snapshots to be served.

_sequence now only changes when the tracked fields change, but BuildSnapshot includes additional fields (e.g., compile state, test status, job IDs, timestamps). Those can change without updating _sequence, so _cachedClone may not be regenerated and callers can get a stale snapshot that doesn’t match _cached or the editor state.

If _sequence is meant to represent “snapshot content changed”, it should either be incremented on every BuildSnapshot call, or hasChanges should be expanded to include all snapshot fields that affect consumers. Otherwise _cached and _cachedClone can diverge in ways that are likely to break callers expecting current state.

_cachedSequence = _sequence;
}

return _cachedClone;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Returning the shared cached clone allows external mutation of the cache state.

Previously GetSnapshot() always returned a fresh DeepClone(), so callers effectively saw an immutable snapshot. Reusing a single _cachedClone means any mutation of the returned JObject (intentional or accidental) will corrupt the shared cache for all callers and may desynchronize it from _cached and the editor state.

Unless we can guarantee all consumers treat the snapshot as read-only, this optimization is risky. A safer approach is to keep _cached as the authoritative state and still DeepClone() on each GetSnapshot(), only avoiding rebuilding _cached when _sequence hasn’t changed. If you keep the shared clone, please explicitly document/enforce an immutable-view contract for GetSnapshot() and audit current usages.

Comment on lines -289 to 292
_sequence++;
_observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

bool isCompiling = EditorApplication.isCompiling;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (bug_risk): Timestamps and other ephemeral fields will now remain constant while state is idle, which may break time-based consumers.

Because _sequence no longer increments on every snapshot, timestamps like _observedUnixMs (and any derived values) will now stay frozen while the logical state is idle. Any consumer relying on these timestamps for heartbeats, timeouts/latency, or "last seen" diagnostics will observe a static time instead of a continuously advancing one. If those use cases matter, consider either a separate monotonic heartbeat field or forcing _sequence to advance (and the snapshot to update) after some time threshold, even when the tracked state is unchanged.

Comment on lines 210 to 219
private static void UnhookUpdateIfIdle()
{
// Intentionally no-op: keep update hook installed so background commands always process.
// This avoids "must focus Unity to re-establish contact" edge cases.
return;
// Only unhook if there are no pending commands. RequestMainThreadPump() guarantees
// re-hooking via SynchronizationContext.Post + QueuePlayerLoopUpdate when commands arrive.
if (Pending.Count == 0 && updateHooked)
{
updateHooked = false;
EditorApplication.update -= ProcessQueue;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): UnhookUpdateIfIdle reads Pending.Count without locking, which may race if called from multiple code paths.

Within ProcessQueue, UnhookUpdateIfIdle() is called while holding PendingLock, so Pending.Count is safe there. The risk is future or alternate call sites that invoke UnhookUpdateIfIdle() without PendingLock, which would make the unsynchronized Pending.Count read race with writers and potentially throw InvalidOperationException.

If this method must always be called under PendingLock, consider either documenting that explicitly (e.g., name/comment) or taking PendingLock inside UnhookUpdateIfIdle() before accessing Pending to prevent incorrect future usage.

Suggested change
private static void UnhookUpdateIfIdle()
{
// Intentionally no-op: keep update hook installed so background commands always process.
// This avoids "must focus Unity to re-establish contact" edge cases.
return;
// Only unhook if there are no pending commands. RequestMainThreadPump() guarantees
// re-hooking via SynchronizationContext.Post + QueuePlayerLoopUpdate when commands arrive.
if (Pending.Count == 0 && updateHooked)
{
updateHooked = false;
EditorApplication.update -= ProcessQueue;
}
}
private static void UnhookUpdateIfIdle()
{
// Only unhook if there are no pending commands. RequestMainThreadPump() guarantees
// re-hooking via SynchronizationContext.Post + QueuePlayerLoopUpdate when commands arrive.
// Must hold PendingLock while inspecting Pending to avoid races with writers.
lock (PendingLock)
{
if (Pending.Count == 0 && updateHooked)
{
updateHooked = false;
EditorApplication.update -= ProcessQueue;
}
}
}

Comment on lines +169 to +193
// Helper: Simulates EditorApplication.update callback
// Uses reflection to invoke ProcessQueue on the TransportCommandDispatcher
private void SimulateEditorUpdate()
{
try
{
var method = typeof(TransportCommandDispatcher)
.GetMethod("ProcessQueue",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);

if (method != null)
{
method.Invoke(null, null);
}
}
catch (System.Reflection.TargetInvocationException ex)
{
// Unwrap the inner exception from reflection
if (ex.InnerException != null)
{
throw ex.InnerException;
}
throw;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Reflection-based invocation of ProcessQueue is brittle and could be made safer/clearer

This reflection on a private ProcessQueue makes the tests fragile: any rename, signature, or visibility change will cause runtime failures rather than clear compile-time errors. If you must use this pattern, consider centralizing the reflection lookup in a cached static field with an explicit assertion when the method isn’t found, or exposing an internal test hook (e.g., via InternalsVisibleTo) so tests can call into the dispatcher without private reflection.

Suggested change
// Helper: Simulates EditorApplication.update callback
// Uses reflection to invoke ProcessQueue on the TransportCommandDispatcher
private void SimulateEditorUpdate()
{
try
{
var method = typeof(TransportCommandDispatcher)
.GetMethod("ProcessQueue",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
if (method != null)
{
method.Invoke(null, null);
}
}
catch (System.Reflection.TargetInvocationException ex)
{
// Unwrap the inner exception from reflection
if (ex.InnerException != null)
{
throw ex.InnerException;
}
throw;
}
}
// Helper: Simulates EditorApplication.update callback.
// Uses a cached reflection lookup to invoke ProcessQueue on the TransportCommandDispatcher.
// This centralizes the private reflection and fails fast with a clear assertion if the
// target method signature or visibility changes.
private static readonly System.Reflection.MethodInfo s_processQueueMethod =
typeof(TransportCommandDispatcher).GetMethod(
"ProcessQueue",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
private void SimulateEditorUpdate()
{
// Fail fast with a clear message if the method can no longer be located.
NUnit.Framework.Assert.That(
s_processQueueMethod,
NUnit.Framework.Is.Not.Null,
"TransportCommandDispatcher.ProcessQueue signature or visibility has changed. " +
"Update the test's reflection hook accordingly.");
try
{
s_processQueueMethod!.Invoke(null, null);
}
catch (System.Reflection.TargetInvocationException ex)
{
// Unwrap the inner exception from reflection for clearer test failures.
if (ex.InnerException != null)
{
throw ex.InnerException;
}
throw;
}
}

@dsarno
Copy link
Collaborator

dsarno commented Jan 19, 2026

Thanks much. I'm reviewing this, hopefully can test tomorrow and merge.

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