Skip to content

fix: Commands no longer run several times slower while the Unity Editor stays in the background on macOS - #3200

Merged
hatayama merged 6 commits into
mainfrom
fix/macos-background-editor-command-throttling
Oct 7, 2026
Merged

hatayama merged 6 commits into
mainfrom
fix/macos-background-editor-command-throttling

Conversation

@hatayama

@hatayama hatayama commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • On macOS, an Editor left in the background is throttled by App Nap, and every uloop command it serves runs several times slower. The Editor now holds a macOS activity while it processes a command, so a background Editor serves commands as fast as one in front.
  • The activity is held only while a command runs and is released before every domain reload. An idle Editor is throttled as before.

User Impact

  • Before: after the Editor had sat in the background for about a minute, commands slowed down several times. Measured on this branch with the router's hold removed, a fixed CPU loop inside a command took a median of 189 ms (97–469 ms) instead of 48 ms, and all 20 commands started throttled. In the earlier investigation, a probe measured hot reload of 200 methods at 16.7–20.2 s while throttled against about 2.0–2.3 s while not.
  • After: with this PR, none of 20 commands started throttled and the same loop took a median of 47.7 ms (45.6–52.2 ms), within 8% of the value right after a compile. The Editor is throttled again within a second after a command ends.

Changes

  • EditorExecutionActivity (new, Infrastructure/ExecutionActivity/) is the only entry point. Hold() begins an activity and returns a handle whose Dispose() ends it. ReleaseAllAndClose(), subscribed to beforeAssemblyReload, ends every live activity and starts no more. A token leaves the set of live tokens before End is called, so each token is ended exactly once, by whichever comes first.
  • MacProcessActivityApi (new) calls NSProcessInfo beginActivityWithOptions:reason: with NSActivityUserInitiatedAllowingIdleSystemSleep through libobjc. It retains the autoreleased token before its own autorelease pool drains, and releases it after endActivity:. There is no #if: the declarations resolve only when called. Other platforms use InertProcessActivityApi, which starts nothing.
  • UnityCliLoopExecutionRouter wraps internal bridge commands and tools in using (Hold()). The get-editor-status early return is unchanged, so it still answers while the main thread is stuck.
  • The composition root creates one registry per domain and passes it to the router through UnityCliLoopBridgeServerInstanceFactory.
  • When the native entry points are missing, the Editor logs one warning and runs commands without the activity until the next domain reload.
  • ADR 0012 records the decision, the rejected alternatives, and when to reopen it.

Input space

# Platform Registry Begin gives Request Ends by main This PR Test
A1 macOS open token tool its task stays pending, then completes no activity held while the tool's task is pending, then ended once T7
A2 macOS open token tool exception no activity exception propagates; ended once T8
A3 macOS open token internal bridge command canceled before start no activity OperationCanceledException propagates; ended once T9
A4 macOS open token internal bridge command return no activity held, then ended once T11
A5 macOS open — get-editor-status return no activity unchanged; Begin is not called T10
A6 macOS open token tool domain reload while running no activity the close ends it once; the later Dispose does nothing T4
A7 macOS closed (before reload) not called any any no activity the command runs as before without Begin T5
A8 macOS open IntPtr.Zero any any no activity runs without an activity; End is not called T6
A9 macOS open two overlapping tokens any either order no activity each token ended once T3
A10 macOS open token any Dispose twice — ended once T2
A11 other open IntPtr.Zero (inert) any any no activity unchanged; no native call T6 path, T13
A12 macOS open token from the real OS — — — non-zero token; End does not throw T12 (macOS only)
A13 macOS open DllNotFoundException / EntryPointNotFoundException any any no activity the command still runs; one warning; Begin is not tried again T15
A14 macOS open token any End throws in Dispose — the exception propagates; the token already left the set, so nothing ends it again T16
A15 macOS open two tokens any End throws during the close — logged instead of thrown, so later beforeAssemblyReload subscribers still run; the other token is still ended; a later Dispose ends nothing T17
A16 macOS open token command or tool never returns no activity held until the next domain reload or Editor exit (intended) not tested: EditMode tests must not create requests that never end; T4 pins the release at reload
Test names
  • EditorExecutionActivityTests: T1 Hold_ThenDispose_BeginsOneActivityAndEndsThatToken, T2 Dispose_CalledTwice_EndsTheActivityOnce, T3 Hold_TwiceOverlapping_EndsEachTokenOnceInEitherOrder (2 cases), T4 ReleaseAllAndClose_EndsEveryLiveActivity_AndALaterDisposeEndsNothing, T5 Hold_AfterReleaseAllAndClose_DoesNotBeginAnotherActivity, T6 Hold_WhenThePlatformReturnsNoToken_ReturnsAHandleThatEndsNothing, T13 InertProcessActivityApi_Begin_ReturnsNoToken, T15 Hold_WhenThePlatformLacksTheNativeEntryPoints_RunsWithoutAnActivity_AndDoesNotTryAgain (2 cases), T16 Dispose_WhenEndThrows_DoesNotEndTheSameTokenAgain, T17 ReleaseAllAndClose_WhenEndThrows_StillEndsTheOthers_AndEndsNoTokenTwice
  • UnityCliLoopExecutionRouterActivityTests: T7 ExecuteAsync_Tool_HoldsTheActivityWhileTheToolIsPending_AndReleasesItWhenItCompletes, T8 ExecuteAsync_ToolThrows_ReleasesTheActivity_AndRethrows, T9 ExecuteAsync_InternalCommandCanceledBeforeItStarts_ReleasesTheActivity, T10 ExecuteAsync_EditorStatus_DoesNotBeginAnActivity, T11 ExecuteAsync_InternalCommand_HoldsAndReleasesAnActivity
  • MacProcessActivityApiTests: T12 BeginThenEnd_OnMacOS_ReturnsATokenAndDoesNotThrow, T14 End_WithNoToken_Throws

Mutations

Each mutation was applied alone, and every one made at least one test fail.

Mutation Tests that failed
m1 the router does not hold T7, T8, T9, T11
m2 Release does not call End T1, T2, T3 (both), T7, T8, T9, T11, T16
m3 Release ends a token that is not in the set T2, T4, T16, T17
m4 the close does not end tokens T4, T17
m5 the close does not mark the registry closed T5
m6 a zero token gets a live handle T6
m7 the get-editor-status early return also holds T10
m8 the close does not clear the set T4, T17
m9 no catch for missing native entry points T15 (both)
m10 the catch does not mark the API unavailable T15 (both)
m11 no per-token catch in the close T17
m12 Release ends the token before removing it T16
m13 the router releases the hold before awaiting the command T7

T7 originally used a tool that completed synchronously, so the whole call finished before ExecuteAsync returned, and all five router tests still passed under m13. It now uses a tool whose task stays pending, and checks the activity is still held while it is pending.

Verification

Checked on Unity 2022.3.62f3 and macOS 26.4.1 (Apple silicon).

  • Unit: EditorExecutionActivityTests, UnityCliLoopExecutionRouterActivityTests, and MacProcessActivityApiTests passed 19/19, with none skipped (the real-OS test ran).
  • Mutations: see the table above. m4–m13 ran with the live Editor temporarily switched to the inert API. Under m3, the live Editor crashed with SIGTRAP inside endActivity: when a reload closed the registry and a command's hold was disposed afterwards, ending the same token twice. The tests use a fake API, so the switch does not change their results.
  • Regression: JsonRpcRequestProcessorTests, JsonRpcRequestProcessorCliVersionGateTests, JsonRpcResponseFactoryWireShapeCharacterizationTests, UnityCliLoopBridgeClientSessionManagerTests, UnityCliLoopBridgeServerLoopTests, UnityCliLoopBridgeServerInstanceFactoryTests, and UnityCliLoopToolRegistryTests passed 88/88.
  • scripts/check-code-complexity.sh, scripts/check-file-length.sh, scripts/check-dead-code.sh with the CI arguments, and the asmdef policy check passed.
  • Real machine, measured with this PR's code. A temporary probe tool and a once-a-second sampler of the process's suppression state were used and not committed. The Editor stayed in the background throughout, and every step was a separate CLI command.
    • V1 (effect): 2 alternating pairs. Each set was 10 commands sent 3 s apart, after the Editor had been idle for 71 s and was throttled. With the router's hold removed, 20/20 commands started throttled, and the loop took a median of 189 ms (97–469). With this PR, 0/20 started throttled, and the loop took a median of 47.7 ms (45.6–52.2). The value right after a compile was a median of 48.3 ms over 12 commands.
    • V2 (release): in the 120 s after the last command, 114/114 samples were throttled, the first one 0.8 s after the command ended.
    • V3 (domain reload): compile --force-recompile three times with 180 s idle after each. Throttling came back 63.2, 63.1, and 63.4 s after the compile returned, so no activity outlived a reload.
    • V4 (the retained token keeps the activity alive): one 20 s command on a throttled Editor read unthrottled at every 2 s reading (10/10). Throttling came back within a second after it ended.
    • A Begin and End pair costs about 4 µs inside the Editor (median of 100 pairs; the slowest took 0.11 ms).

Not covered

  • Work the Editor continues after a command has responded is not covered:

    • compile: the domain reload after the response.
    • control-play-mode: entering or leaving Play Mode and its reload.
    • execute-dynamic-code, when it waits for a domain reload.
    • run-tests in Play Mode: the run continues in the new domain.
    • record-video, replay-input, and enable-watch: per-frame work after they return.
    • The internal bridge command set-code-optimization-debug.

    Holding for a while after the last command was not adopted: accepting a command is delayed by only tens of milliseconds, and a hold across reloads would have to carry state over.

  • Background throttling on Windows and Linux has not been investigated.

  • There is no setting or environment variable to turn this off.

  • get-editor-status does not hold an activity.

  • Speeding up hot reload's patch stage itself is separate work.

macOS throttles a background Editor, and the fix holds an operating-system
activity while a command runs. Ending the same native token twice touches
released memory, so one registry owns the live tokens: each is ended by its
handle or by the close before a domain reload, whichever comes first, and
no activity starts after the close. A platform without the native entry
points runs commands without an activity instead of failing them, and a
failure while closing is logged so the other reload subscribers still run.
A request that reaches a throttled background Editor on macOS is served
several times slower, so the router now holds an activity from the moment
it dispatches a tool or internal bridge command until that call returns,
throws or is canceled. The editor status answer stays outside the hold
because it must answer while the main thread is stuck. The registry is
created once per domain in the composition root and handed to every
router the server factory builds; the test double moves to its own file
so the router tests can watch the hold from inside a tool.
The registry now uses NSProcessInfo's beginActivityWithOptions:reason:
with NSActivityUserInitiatedAllowingIdleSystemSleep, the smallest option
measured to lift App Nap, and leaves idle system sleep alone. Begin runs
inside its own autorelease pool because thread-pool threads have none,
and retains the token before the pool drains so the activity outlives the
call. Other platforms keep the inert API, and the declarations resolve
only when called, so no conditional compilation is needed.
A mutation test that let a hold end its token after the registry had
already closed brought the Editor down with SIGTRAP inside endActivity:.
The comments on the two places that end a token now say that a second
end is a crash rather than an exception, so the remove-before-End order
is not mistaken for defensive tidiness.
The decision has alternatives that look simpler (holding for as long as
the server runs, asking users to disable App Nap with defaults, other
activity options), so the ADR keeps the measurements that ruled them
out, what the hold does not cover, and when to reopen the question.
@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: hatayama/unity-cli-loop/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 8f928968-8496-481f-9a6d-a05fe8fc1f3f
📥 Commits

Reviewing files that changed from the base of the PR and between ffc9cf8 and a0da9d8.

⛔ Files ignored due to path filters (9)
  • Assets/Tests/Editor/EditorExecutionActivityTests.cs.meta is excluded by none and included by none
  • Assets/Tests/Editor/MacProcessActivityApiTests.cs.meta is excluded by none and included by none
  • Assets/Tests/Editor/RecordingProcessActivityApi.cs.meta is excluded by none and included by none
  • Assets/Tests/Editor/UnityCliLoopExecutionRouterActivityTests.cs.meta is excluded by none and included by none
  • Packages/src/Editor/Infrastructure/ExecutionActivity.meta is excluded by none and included by none
  • Packages/src/Editor/Infrastructure/ExecutionActivity/EditorExecutionActivity.cs.meta is excluded by none and included by none
  • Packages/src/Editor/Infrastructure/ExecutionActivity/IProcessActivityApi.cs.meta is excluded by none and included by none
  • Packages/src/Editor/Infrastructure/ExecutionActivity/InertProcessActivityApi.cs.meta is excluded by none and included by none
  • Packages/src/Editor/Infrastructure/ExecutionActivity/MacProcessActivityApi.cs.meta is excluded by none and included by none
📒 Files selected for processing (19)
  • Assets/Tests/Editor/EditorExecutionActivityTests.cs
  • Assets/Tests/Editor/JsonRpcRequestProcessorCliVersionGateTests.cs
  • Assets/Tests/Editor/JsonRpcRequestProcessorTests.cs
  • Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs
  • Assets/Tests/Editor/MacProcessActivityApiTests.cs
  • Assets/Tests/Editor/RecordingProcessActivityApi.cs
  • Assets/Tests/Editor/UnityCliLoopBridgeClientSessionManagerTests.cs
  • Assets/Tests/Editor/UnityCliLoopBridgeServerInstanceFactoryTests.cs
  • Assets/Tests/Editor/UnityCliLoopBridgeServerLoopTests.cs
  • Assets/Tests/Editor/UnityCliLoopExecutionRouterActivityTests.cs
  • Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs
  • Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs
  • Packages/src/Editor/Infrastructure/Api/UnityCliLoopExecutionRouter.cs
  • Packages/src/Editor/Infrastructure/ExecutionActivity/EditorExecutionActivity.cs
  • Packages/src/Editor/Infrastructure/ExecutionActivity/IProcessActivityApi.cs
  • Packages/src/Editor/Infrastructure/ExecutionActivity/InertProcessActivityApi.cs
  • Packages/src/Editor/Infrastructure/ExecutionActivity/MacProcessActivityApi.cs
  • Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServerInstanceFactory.cs
  • docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The change adds command-scoped macOS process activities for routed commands. It tracks and releases activity tokens, closes live activities before domain reload, and uses inert activity behavior on other platforms. Status requests do not hold an activity.

Changes

Editor execution activity

Layer / File(s) Summary
Process activity APIs
Packages/src/Editor/Infrastructure/ExecutionActivity/*, Assets/Tests/Editor/MacProcessActivityApiTests.cs
The process activity interface defines Begin and End. The macOS implementation manages native activity tokens; the inert implementation returns zero. Tests cover macOS token creation and ending.
Activity hold lifecycle
Packages/src/Editor/Infrastructure/ExecutionActivity/EditorExecutionActivity.cs, Assets/Tests/Editor/EditorExecutionActivityTests.cs, Assets/Tests/Editor/RecordingProcessActivityApi.cs
EditorExecutionActivity tracks live tokens and releases them during disposal or closure. Tests cover overlapping holds, closure, unavailable entry points, zero tokens, and end failures.
Activity holds during routed commands
Packages/src/Editor/Infrastructure/Api/UnityCliLoopExecutionRouter.cs, Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServerInstanceFactory.cs, Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs, Assets/Tests/Editor/UnityCliLoopExecutionRouterActivityTests.cs, Assets/Tests/Editor/JsonRpcRequestProcessor*Tests.cs, Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs, Assets/Tests/Editor/UnityCliLoopBridge*Tests.cs, Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs, docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md
The router holds an activity during non-status request dispatch, while status responses remain outside the hold. Composition and server construction pass the activity dependency. Tests cover completion, exceptions, cancellation, and status requests. The ADR records the command-scoped activity decision and its documented consequences.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant UnityCliLoopExecutionRouter
  participant EditorExecutionActivity
  participant Tool
  Caller->>UnityCliLoopExecutionRouter: Route non-status request
  UnityCliLoopExecutionRouter->>EditorExecutionActivity: Hold activity
  UnityCliLoopExecutionRouter->>Tool: Dispatch request
  Tool-->>UnityCliLoopExecutionRouter: Return result or exception
  UnityCliLoopExecutionRouter->>EditorExecutionActivity: Dispose hold
  UnityCliLoopExecutionRouter-->>Caller: Return response or propagate exception
Loading

Merge Risk: 🔵 Low · up to a0da9

The change keeps a macOS activity alive while a command runs, and other platforms are unaffected. The native macOS calls are the only residual risk, and the author reports they passed testing. This is mergeable with minor owner awareness.

Security Architecture Review

Security architecture risk: 🔵 Low · up to a0da9

The change is confined to keeping the addressed macOS Editor responsive during commands. Existing command permissions remain in place, and cleanup is centralized. Limited uncertainty remains around native cleanup failures and commands that never finish; no new permission bypass was established.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — Each reachable non-status request can affect App Nap behavior for its addressed Editor process, including unrelated work in that process. A dispatch that never completes can prolong this effect until registry closure or process exit. The inspected change does not add a network listener or grant additional command privileges.

Trust Boundaries and Controls

  • observed — The existing tool security check still runs before a tool execution lease is issued, although activity acquisition now precedes it. The Unix listener also requires successful directory, stale-socket and socket-restriction checks before listening; the underlying filesystem policy was not independently verified in this pass.

Resilience and Maintainability Implications

  • observed — Normal native ownership retains the token before draining its autorelease pool and releases it after endActivity. The registry deliberately never retries End after an exception. Tests establish this at-most-once policy, not successful native cleanup under failure; an actual production failure before termination remains unproven.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.62% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 61 functions across 18 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: preventing command slowdowns when the Unity Editor runs in the background on macOS. It is specific and related to the changeset, though somewhat long.
Description check ✅ Passed The description explains the macOS activity behavior, implementation, tests, measurements, and limitations. It is directly related to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 42.62% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 61 functions across 18 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

…pletes

The old tool test completed synchronously, so the whole call finished
before ExecuteAsync returned, and a router that released the hold before
awaiting the tool still passed it. The test now uses a tool whose task
stays pending, checks the activity is still held while it is pending,
and completes it in finally so a failed assertion leaves nothing behind.
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