Skip to content

fix: Server recovery and the setup wizard no longer wait for the Unity window to be focused - #3171

Merged
hatayama merged 5 commits into
mainfrom
fix/replace-delaycall-with-dispatcher
Oct 6, 2026
Merged

hatayama merged 5 commits into
mainfrom
fix/replace-delaycall-with-dispatcher

Conversation

@hatayama

@hatayama hatayama commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Six places in the Editor package deferred work with EditorApplication.delayCall. In the Editors measured for this change, delayCall did not run at all while the Editor window was unfocused, so that work waited until someone focused Unity.
  • Five of them now go through uloop's main-thread dispatcher. The dispatcher drains its queue on update and tick. update keeps running while the Editor is unfocused, and the dispatcher also signals a tick when work is queued.
  • The setup wizard's auto-show is already on the main thread, so it now runs directly.

Closes #3168

User Impact

  • Before: with Unity in the background, which is the normal case when an agent drives it, the following steps waited for the Unity window to be focused:

    • the server recovery after an unexpected server loop exit
    • the settings file recovery at startup
    • the abort after the compile watchdog faults
    • the setup wizard's automatic display

    This was observed directly only for the setup wizard, whose pending show ran right after the first focus (see the measurements below). For the other steps it follows from the same mechanism: they could not be triggered in a real Editor here.

  • After: they run on the next update or tick, and update keeps running while unfocused.

  • The startup server recovery and the skill file sync after a tool toggle also used delayCall, but mattered less in practice:

    • In the cold starts measured here, the after-reload recovery started the server without it.
    • A tool toggle happens while the user is working in the window.

    They are moved too, so no code in the Editor package waits on delayCall, which has also been reported to stop after the cold-start compiler-errors dialog.

The six places

# Place Caller thread Before After
1 Startup server recovery (UnityCliLoopServerControllerService.InitializeForEditorStartup) Main (InitializeOnLoad) delayCall += MainThreadSwitcher.AddContinuation
2 Server recovery after an unexpected server loop exit (OnServerLoopUnexpectedlyExited) Thread pool delayCall += plus a manual SignalTick() AddContinuation (it signals a tick itself)
3 Settings file recovery at startup (UnityCliLoopEditorSettingsRecoveryScheduler) Main (InitializeOnLoad) delayCall += AddContinuation
4 Setup wizard auto-show (SetupWizardStartupFlow.EvaluateVersionChange) Main (continuation of an update handler) delayCall += Direct call
5 Abort after the compile watchdog faults (CompileLifecycleRecoveryCoordinator) Thread pool delayCall += AddContinuation
6 Skill sync after a tool toggle (UnityCliLoopSettingsWindow.HandleToolToggled) Main (UI event) delayCall += AddContinuation
  • chore(main): release 1.0.0 #1 is queued before the dispatcher subscribes to update and tick. That is fine: the dispatcher's queue keeps what was added before Initialize(), and the first update or tick after it drains the queue.
  • chore(main): release 1.0.0 #4: every await before the show resumes on Unity's synchronization context (none uses ConfigureAwait(false)), and its only caller is a self-removing update handler, so the show already runs on the main thread. delayCall only added a hop.
  • Out of scope: UloopPausePointRawCaptureLifecycle in the Runtime assembly keeps its delayCall. The Runtime assembly cannot use the Editor dispatcher. That code also relies on delayCall running after the rest of the tick: a Step can unpause and re-pause for a moment, and the check reads the pause state after it settles. Moving it to update changes that timing in a way EditMode tests cannot tell apart, so it gets a separate issue.

Measurements in a real Editor

The probe registers three callbacks, and each writes a timestamp file:

  • one on delayCall
  • one self-removing update handler
  • one continuation through the main-thread dispatcher (MainThreadSwitcher.SwitchToMainThread from a thread-pool task)

Times are UTC on 2026-10-06. Platform: macOS, Unity 2022.3.62f3.

Before the fix

The Editor from the investigation had not been focused since it was launched about 10.5 hours earlier. No code was changed for this run.

  • At 01:14:26, the probe was registered. The update and dispatcher files appeared within about 1 s.
  • After 17 s and again after 61 s, the delayCall file was still missing.
  • The setup wizard window was not open, and the user settings file did not exist. With no last-seen version, the wizard's startup check had queued its show on delayCall at launch, and that show had never run.
  • uloop status reported Ready.

Focus ends the stall

  • At 68 s (01:15:34.117), the delayCall file appeared. That was 152 ms after the Editor gained focus for the first time since launch: the tool's debug log has no focus event between the launch and 01:15:33.965. The focus did not come from the test.
  • At the same moment, the wizard's pending show ran (still the old code) and recorded the last-seen version in the user settings file.
  • One focus is not enough. After the Editor lost focus again, a new probe at 01:31:14 saw no focus change while it ran: the update and dispatcher files appeared within about 1 s, and no delayCall file appeared within 12 s.

After the fix

  • Same Editor, with all six places changed and compiled, and unfocused: the update and dispatcher files appeared within about 1 s, and the delayCall file was missing after 12 s. The changed places no longer depend on delayCall.
  • The setup wizard was not open, as expected. The last-seen state recorded at 01:15:34 (3.11.5, dispatcher minimum 3.0.0-beta.31) equals the current package version and dispatcher minimum, so the check returns early and has nothing to show. The new unit tests cover the direct-show path instead.
  • Restart with uloop launch -r (cold start, no compiler-errors dialog): Ready in 14 s, and uloop status reported Ready. The debug log shows no focus event after the restart, so the launch did not focus the window.
    • On a cold start, the after-reload recovery also starts the server, and the startup recovery joins it. So this shows that startup still works with the change; it does not single out the startup recovery's dispatcher hop.
    • Afterwards the Console had no errors, and the restarted process's Editor log had no exception line. The dispatcher logs any exception thrown by the work it runs, so nothing it ran during the cold start threw.
  • Fresh process that was never focused: the update and dispatcher files appeared within about 1 s, and no delayCall file appeared within 15 s. The stall reproduces in a new process, without the compiler-errors dialog.

Open question, not resolved here

The issue asks what stops delayCall besides the cold-start compiler-errors dialog. In both processes measured here, the condition was that the Editor window did not have focus: delayCall waited while unfocused and flushed right after focus.

  • This was not compared with a fresh Editor that has focus, or on other platforms or Unity versions, so it is an observation, not a general rule.
  • It does not explain the dialog case.

Changes

  • ToolContracts now exposes its internals to Infrastructure, Presentation, and the Compile tool, because MainThreadSwitcher.AddContinuation is internal. No asmdef reference changed.
  • The startup scheduler's parameter scheduleDelayCall is renamed to scheduleOnEditorTick.
  • Comments that described delayCall as the mechanism now name the dispatcher. Comments that explain why delayCall is avoided are kept.
  • Tests:
    • New UnityCliLoopEditorSettingsRecoverySchedulerTests: the recovery is queued on the dispatcher, not run inline, and runs once when the queue drains.
    • Two new setup wizard tests: a package change with an outdated dispatcher, or with outdated skills, shows the window synchronously.
    • The source pin for the wizard now checks that the flow registers nothing on delayCall, instead of requiring it to.
    • A test counter named delayCallCount counted readiness retry waits, not delayCall registrations, and is renamed readinessRetryWaitCount.
  • Left for a separate chore pull request: a Go comment in the dispatcher still cites the removed SignalTick lines by line number. Changing it here would make this fix cut a dispatcher release.

Verification

Unity EditMode (local Editor, filtered to the changed classes)

  • UnityCliLoopEditorSettingsRecoverySchedulerTests: Red first (Expected: 1 / But was: 0 after draining the queue), then 1/1.
  • UnityCliLoopServerControllerRecoveryTests: 23/23.
  • CompileLifecycleRecoveryCoordinatorWatchdogTests: 5/5.
  • SetupWizardStartupFlowVersionChangeTests|OnionAssemblyDependencyTests: Red first with exactly 3 failures (the two new tests and the source pin) and 95 passing, including the existing "without showing" tests. Then 98/98.
  • Mutation check: putting delayCall back in both the settings recovery and the wizard fails exactly 4 of 99 tests (the scheduler test, the two new wizard tests, and the source pin). Reverted.
  • uloop compile: 0 errors.

Static checks (local)

  • asmdef reference policy: no violation.
  • uloop compile-check: 0 errors. Its 7 warnings are in unchanged test fixtures.
  • Complexity, failing on findings:
    • Go: 0 issues in each module.
    • C#: 0 findings above 15. The largest changed method, EvaluateVersionChange, is at 13.
    • The C# tool skips every path that contains /.claude/, and this checkout is under one, so it ran on a copy of Packages/src. It reports 1009 findings at threshold 5 there, so the run was not empty.
  • Dead code gate with the CI arguments: passes, with 35 public candidates (maximum 37).
  • File length: no file over 500 SLOC.

CI (head 08f255c)

  • Every repository check on this head finished. 13 passed: build-cli, test-unity-package, test-windows-installers, Complexity Report, File Length Report, Dead Code Gate, check-pr-title, the protocol minimum version comment, C# and Go security analysis, CodeQL, and the Unity compile checks on 2022.3.62f1 and 6000.5.4f1. check-package-release-pin was skipped.

…tead of delayCall

Some Editor sessions stop flushing EditorApplication.delayCall while
EditorApplication.update keeps running, so the settings file recovery
queued at Editor startup never ran there. The main-thread dispatcher
drains its queue on both update and tick, so the recovery now rides on it.

ToolContracts now exposes its internals to Infrastructure, Presentation,
and the Compile tool, so the remaining delayCall sites in those
assemblies can hand their work to the same dispatcher.
…f delayCall

The startup recovery and the recovery after an unexpected server loop
exit were queued on EditorApplication.delayCall, which some Editor
sessions stop flushing while update keeps running. There the server
would not come back on its own. The dispatcher drains on update and
tick and signals a tick when work is enqueued, so the manual SignalTick
after the old delayCall registration is no longer needed.

The startup scheduler parameter is renamed to describe what it does now,
and a test counter that counted readiness retry waits, not delayCall
registrations, gets a name that says so.
…yCall

When the compile watchdog faults, its continuation runs on the thread
pool and queued the abort on EditorApplication.delayCall. In an Editor
session that stops flushing delayCall, the abort never ran, so a compile
request that only the watchdog could end might stay unanswered. The
dispatcher drains on update and tick, so the abort now reaches the main
thread there too.
The version check that decides whether to show the setup wizard queued
the window on EditorApplication.delayCall. In an Editor session that
does not flush delayCall, the wizard never appeared, even when the CLI
needed an update or the installed skills were outdated. The check
already resumes on Unity's synchronization context after each await,
so it is on the main thread and can show the window itself.

New tests pin that a package change with an outdated CLI or outdated
skills shows the window synchronously, and the source pin now checks
that the flow registers nothing on delayCall instead of requiring it.
… instead of delayCall

Toggling a tool in the settings window deferred the skill file sync to
EditorApplication.delayCall to keep the UI responsive. In an Editor
session that does not flush delayCall, the skill files never followed
the toggle. The dispatcher drains on the next update or tick, so the
work is still deferred but now runs.
@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: b1977e3f-16b4-4bd2-8dbb-5a04f3f07a3c
📥 Commits

Reviewing files that changed from the base of the PR and between f2c0e9c and 08f255c.

⛔ Files ignored due to path filters (1)
  • Assets/Tests/Editor/UnityCliLoopEditorSettingsRecoverySchedulerTests.cs.meta is excluded by none and included by none
📒 Files selected for processing (12)
  • Assets/Tests/Editor/OnionAssemblyDependencyTests.cs
  • Assets/Tests/Editor/SetupWizardStartupFlowVersionChangeTests.cs
  • Assets/Tests/Editor/UnityCliLoopEditorSettingsRecoverySchedulerTests.cs
  • Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs
  • Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleRecoveryCoordinator.cs
  • Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs
  • Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerRecoveryTrackingService.cs
  • Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSettingsRecoveryScheduler.cs
  • Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs
  • Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlow.cs
  • Packages/src/Editor/Presentation/UnityCliLoopSettingsWindow.cs
  • Packages/src/Editor/ToolContracts/AssemblyInfo.cs

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

Editor startup and recovery callbacks now use the main-thread dispatcher instead of EditorApplication.delayCall in the changed paths. The setup wizard displays its window directly after version checks. Tests cover wizard display conditions and queued settings recovery.

Changes

Editor callback dispatch

Layer / File(s) Summary
Version-change wizard display
Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlow.cs, Assets/Tests/Editor/SetupWizardStartupFlowVersionChangeTests.cs, Assets/Tests/Editor/OnionAssemblyDependencyTests.cs
The setup flow invokes the window callback directly after its awaited checks. Tests cover display for a dispatcher version below minimum and outdated installed skills, and assert that the flow does not subscribe to EditorApplication.delayCall.
Startup recovery dispatch
Packages/src/Editor/Infrastructure/Server/*, Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSettingsRecoveryScheduler.cs, Packages/src/Editor/ToolContracts/AssemblyInfo.cs, Assets/Tests/Editor/UnityCliLoopEditorSettingsRecoverySchedulerTests.cs, Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs
Server and settings startup recovery use MainThreadSwitcher.AddContinuation. A test verifies that settings recovery runs once after the dispatcher queue drains. Server startup-lock and editor-readiness retry tests retain their assertions with updated wording and counter names.
Later lifecycle callbacks
Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleRecoveryCoordinator.cs, Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs, Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs, Packages/src/Editor/Presentation/UnityCliLoopSettingsWindow.cs
Unexpected-server-exit recovery, compile watchdog abort, and skill-toggle side effects use MainThreadSwitcher.AddContinuation. The bridge-server comment now describes lifecycle-event deferral through the dispatcher.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix · Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 08f25

No actionable merge-blocking issue remains in the changed callback paths; the PR is mergeable after normal checks.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 08f25

The inspected safeguards and permissions remain in place, and no introduced security issue was established. The remaining uncertainty concerns pending recovery work during shutdown and behavior in an actual background Editor session.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The changed paths affect execution within the existing Editor process, its bridge recovery, and settings or skill-file workflows. The inspected scheduler changes do not add a new remote entrypoint or change the destinations and authority of those workflows; this is not a complete authentication or filesystem audit.

Trust Boundaries and Controls

  • observed — The watchdog fault handler crosses from the thread pool to the main-thread queue. It validates compile-request identity before scheduling and again before aborting, preventing a stale queued fault from terminating a replacement request. Terminal completion clears the matching task and ends compile-consent state in a finally block.
  • observed — Tool enabled-state persistence still occurs before queued skill synchronization. Installation retains its disabled-tools check, and the callback invokes the same enable or disable workflow. Asynchronous overlap and callbacks not bound to window lifetime predate this PR.

Resilience and Maintainability Implications

  • observed — The dispatcher logs exceptions from individual callbacks and continues draining. Controller-owned quit cleanup still disposes the bridge and clears session state, but the inspected queue and cleanup paths do not establish cancellation or ordering of pending recovery during quitting.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 30 functions across 12 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Issue #3168 requires six Editor paths to stop depending on EditorApplication.delayCall. The server startup and unexpected-exit recovery, settings recovery, compile-watchdog abort, and tool-toggle sk…
Out of Scope Changes check ✅ Passed The changes support issue #3168. The added tests verify the new scheduling behavior. ToolContracts internals access enables the affected assemblies to call the dispatcher. Renamed parameters, commen…
Title check ✅ Passed The title clearly summarizes the main goal: prevent server recovery and setup wizard behavior from waiting for the Unity window to regain focus.
Description check ✅ Passed The description directly explains the six affected Editor paths, the dispatcher changes, the observed behavior, scope limits, and validation results.
  • 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.

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.

Six uloop Editor paths still depend on EditorApplication.delayCall, which some sessions never flush

1 participant