Skip to content

fix: Hot reload sent while another uloop command runs waits for it instead of failing as busy - #3226

Merged
hatayama merged 5 commits into
feature/hot-reload-large-project-feedback-2from
feat/cli-hot-reload-waits-for-the-running-command
Oct 7, 2026
Merged

hatayama merged 5 commits into
feature/hot-reload-large-project-feedback-2from
feat/cli-hot-reload-waits-for-the-running-command

Conversation

@hatayama

@hatayama hatayama commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • uloop hot-reload sent while another uloop command (typically uloop compile) still holds the Editor no longer fails with UNITY_SERVER_BUSY after ten seconds and no longer brings the Editor to the front. It waits for that command to finish and the Editor to be ready, then sends the same request once.

The Editor runs one tool at a time: a request that arrives while another tool holds the slot is answered with a server_busy error, and compile keeps the slot until Unity's compile finishes. The CLI used to resend such a request every second for ten seconds and then report the last busy answer; after five seconds of busy answers it also brought the Editor to the front (the busy_stall focus). A hot-reload sent two seconds after a compile therefore failed every time, and the settle wait added in the previous change never ran, because no answer ever came back.

Aim

This is not about speed: when a compile holds the Editor, most of the time goes into that compile either way. What the wait protects is the user's flow and the Play session: the command no longer fails, the Editor is not pulled to the front, and the edit is applied as a patch (or reported as NothingToApply when the compile already took it in) instead of the user having to rerun it or fall back to another compile.

User Impact

  • Before: uloop compile followed by uloop hot-reload --files … exited 1 after about 10.8 seconds with UNITY_SERVER_BUSY, and the Editor came to the front during the wait.
  • After: stderr prints hot-reload: the Editor is busy running 'compile'; waiting for it to finish, then applying..., the command waits (up to 10 minutes, the same budget as the settle wait), sends once, and reports that answer with EditorReadyRetryNote and Timing.EditorReadyWaitMs. The Editor is not brought to the front.

Behaviour change

First answer Before After
Not busy (an object, null, another RPC error, a transport failure) as is unchanged
Busy resent every second for 10 s, then UNITY_SERVER_BUSY, exit 1; Editor brought to the front after 5 s waits on the Editor status until ready, sends the same request once, reports that answer with the note and wait time
Busy, and the request after the wait is busy again — UNITY_SERVER_BUSY, exit 1, no second wait
Busy, held by a cancelled execute-dynamic-code the 1 s resends let the Editor take the slot back after its 5 s grace the wait resends every 5 s while that tool holds the Editor, so the slot is still taken back

Every other tool keeps the bounded busy retry and the busy_stall focus.

Changes

  • The send loop has a returnBusyWithoutRetry flag that hands the first busy answer back. Only hot reload sets it; its default is off.

  • runPlainTool is split. sendPlainTool sends the request and returns the failure without writing it, so hot reload can decide what the user sees. runPlainTool still writes the same failure, so its other callers are unchanged.

  • New hot_reload_busy_wait.go:

    • The status polling is the settle wait's, with the same defaults and test hook.
    • Every probe failure counts as "not ready yet", because the domain reload after a compile drops the server.
    • A resend is added only for an execute-dynamic-code holder: the Editor takes back a cancelled request's slot only when another tool request arrives, never on a status answer.
    • When the budget runs out, the request is still sent once.
  • The settle wait (RetryAfterEditorReady) can run right after a busy wait. It now appends its sentence to EditorReadyRetryNote and adds its time to Timing.EditorReadyWaitMs. Before, it overwrote both.

    • This holds when the settle wait runs out too. That path now carries EditorReadyWaitMs even without a busy wait, because its note already said how long the command waited.
  • Two vibe log entries: cli_hot_reload_busy_wait_decided (busy answers only) and cli_hot_reload_busy_wait_complete. The second is written once on every way out of a wait that started.

  • Documentation updated:

    • the hot-reload references (scope-and-limits.md, output.md) and their generated copies
    • docs/vibe-logs.md
    • the single-flight notes in AGENTS.md and docs/soak-testing.md

    The references also say to give --files when the command that ran was a compile, because with no files the Editor re-selects changed files, finds none, and fails validation.

Input space

Axes: A = first answer, B = runningToolName in the busy data, C = the status answers seen during the wait, D = the answer after the wait, E = parameters.

# A B C D E Before After Test
1 object (not busy) — — — any settle wait / fallback path same; no probe, no note existing TestRunHotReload*
2 not an object (null) — — — any written as is, exit 1 same existing
3 RPC error other than busy — — — any classified error, exit 1 same, no probe WritesANonBusyErrorWithoutWaiting
4 transport failure — — — any undispatched retried, others classified same (the flag lives inside the busy branch) StillRetriesAnUndispatchedFailureWhenBusyReturnsAtOnce + existing
5 busy compile Ready applied Files 10 s busy loop → UNITY_SERVER_BUSY, focus at 5 s waiting line, same params sent once, note + EditorReadyWaitMs, no focus WaitsForTheRunningCommandAndAppliesOnce, ReturnsTheFirstBusyAnswerWhenAsked
6 busy compile Busy → no answer (reload) → Compiling → Ready applied Files same failed probes and Compiling are "not yet" WaitsForTheRunningCommandAndAppliesOnce
7 busy compile not ready within the budget applied Files same sent once anyway; not-ready note + EditorReadyWaitMs AppliesOnceMoreWhenTheBudgetEndsBeforeTheEditorIsReady
8 busy compile not ready within the budget busy Files same UNITY_SERVER_BUSY, exit 1, stdout empty same path as 10
9 busy compile cancelled — Files same waiting line + classified cancel, exit 1, one request ReportsACancelWhileWaitingForTheBusyEditor
10 busy compile Ready busy (another command got in) Files same no second wait, UNITY_SERVER_BUSY, exit 1 ReportsASecondBusyAnswerWithoutWaitingAgain
11 busy compile Ready RetryAfterEditorReady: true Files same settle wait follows; busy sentence + settle sentence, waits summed, third request uses SelectedFiles KeepsBothNotesWhen…, AddHotReloadEditorReadyWaitMs…, ComposeHotReloadEditorReadyNote…
12 busy compile Ready not an object Files same classified error, exit 1, stdout empty FailsWhenTheApplyAfterTheBusyWaitIsNotAnObject
13 busy compile Ready transport failure Files same classified error, exit 1, stdout empty ReportsATransportFailureOfTheApplyAfterTheBusyWait
14 busy absent / unreadable Ready applied Files same waiting line and note say another uloop command HotReloadBusyRunningToolNameFallsBack…
15 busy compile Ready Editor validation failure (no changed files) no Files same same params (no Files) sent; failure passed through with the note, exit 1 PassesTheEditorAnswerThroughAfterTheBusyWaitWhenNoFilesWereGiven
16 busy compile Ready status / revert-all answer (no Timing) Status / RevertAll same same wait; note added, no Timing created StatusWaitsForTheRunningCommandToo
17 busy compile Ready CompileFallback: Requested Files same fallback compile runs; note and EditorReadyWaitMs survive the merge RunsTheFallbackCompileAfterTheBusyWait
18 busy execute-dynamic-code (cancelled lease) stays Busy a resend gets in, applied Files the 1 s resends revoke the lease after 5 s resends every 5 s revoke it; note names 'execute-dynamic-code' SendsAgainWhileACancelledExecuteDynamicCodeHoldsTheEditor
19 busy execute-dynamic-code (live) Busy → Ready within the interval applied Files applied if done within 10 s no resend inside the interval; waits on status, sends once DoesNotSendAgainBeforeTheResendInterval
20 busy compile Ready RetryAfterEditorReady: true, then the settle wait runs out Files same busy sentence + gave-up sentence, EditorReadyWaitMs is both waits, exit 1, no compile KeepsBothNotesWhenTheEditorDoesNotSettleAfterTheBusyWait

Exits of sendHotReloadWaitingForBusyEditor

Exit Reached when hot-reload requests stdout stderr exit _decided / _complete Caller
E1 first answered no error 1 not written spinner only caller decides — / — continues
E2 first failed, not busy error, not busy 1 empty classified error 1 — / — ends
E3 cancel during the wait busy → wait error 1 empty waiting line + cancel 1 1 / 1 (ready false, no second) ends
E4 request after the wait failed (busy again, transport, other RPC) busy → send error 2 empty waiting line + classified error 1 1 / 1 (second_result false) ends
E5 answer after the wait not an object busy → note injection fails 2 empty waiting line + classified error 1 1 / 1 (second_result true) ends
E6 answer after the wait busy → merged 2 not written waiting line caller decides 1 / 1 (INFO) continues with the noted answer
E7 a resend during the wait got an answer or failed busy, execute-dynamic-code holder, resend not busy 2 + resends as E4–E6 as E4–E6 as E4–E6 1 / 1 (resends ≥ 1) as E4–E6

Invariants:

  • With a holder other than execute-dynamic-code, this function sends at most two hot-reload requests, and the command sends at most three with the settle wait.
  • Once a wait starts, _decided and _complete are each written exactly once.
  • The function never writes stdout, and every exit-1 path leaves stdout empty.
  • The caller's params map is not modified.

Verification

  • In cli/project-runner:
    • gofmt -l .: empty. go vet ./... and golangci-lint run ./...: clean.
    • The cyclop config (cli/.golangci-complexity.yml): 0 issues.
    • scripts/check-file-length.sh: no findings.
    • check-skill-size: no SKILL.md over the limit; SKILL.md is unchanged.
    • scripts/sync-tool-docs.sh --check: the catalog matches.
  • go test ./... -count=1: everything passes except TestSendWithTransientConnectionRetryAbortsOnRefusedConnect. That test cannot bind a Unix socket inside the local sandbox; it is untouched by this change and runs on CI.
  • Coverage of the module: 95.4%. The baseline is 95.2.
  • The new scripted tests take at most 0.1 s each. The fake Editor can now answer a step with a JSON-RPC error, and it reports any request that comes after the script.
  • Mutations. Each was applied alone to the committed code, run against the hot-reload and send-loop tests, and reverted. Every mutation was killed:
Mutation Tests that failed
send loop ignores returnBusyWithoutRetry (back to the 1 s busy loop) ReturnsTheFirstBusyAnswerWhenAsked, WaitsForTheRunningCommand… and every other busy-wait scenario
busy is written as a failure without a wait (as before) WaitsForTheRunningCommand…, WritesBusyWaitVibeLogs and every other busy-wait scenario
no request after the wait WaitsForTheRunningCommand…, AppliesOnceMoreWhenTheBudgetEnds…, and 9 more
Files dropped from the request after the wait WaitsForTheRunningCommand…, ReportsASecondBusyAnswer…, AppliesOnceMoreWhenTheBudgetEnds…, DoesNotSendAgainBeforeTheResendInterval
a second busy answer waits again ReportsASecondBusyAnswerWithoutWaitingAgain
no note WaitsForTheRunningCommand…, AppliesOnceMoreWhenTheBudgetEnds…, and 6 more
no EditorReadyWaitMs WaitsForTheRunningCommand…, AppliesOnceMoreWhenTheBudgetEnds…, and 3 more
Timing created on an answer without one StatusWaitsForTheRunningCommandToo
a budget that runs out fails instead of sending AppliesOnceMoreWhenTheBudgetEnds…
a cancel is ignored and the request is sent ReportsACancelWhileWaitingForTheBusyEditor (through the vibe log: a request on a cancelled context fails at the dial, so stderr and the request count look the same)
settle note overwrites instead of appending KeepsBothNotesWhen…, ComposeHotReloadEditorReadyNote…
settle wait time overwrites instead of adding (in the helper) KeepsBothNotesWhen…, AddHotReloadEditorReadyWaitMs…
the settle wait still calls the overwriting addHotReloadTimingMs KeepsBothNotesWhen… (EditorReadyWaitMs below 20)
_complete not written on a cancel ReportsACancelWhileWaitingForTheBusyEditor
_complete written on success only ReportsASecondBusyAnswerWithoutWaitingAgain
a non-busy error enters the wait WritesANonBusyErrorWithoutWaiting
no resend while execute-dynamic-code holds the Editor SendsAgainWhileACancelledExecuteDynamicCodeHoldsTheEditor
resend for every holder WaitsForTheRunningCommand… and 5 more (a request where the script expects a status)
resend interval ignored DoesNotSendAgainBeforeTheResendInterval
the never-settled note overwrites instead of appending KeepsBothNotesWhenTheEditorDoesNotSettleAfterTheBusyWait
the never-settled path does not add its wait KeepsBothNotesWhenTheEditorDoesNotSettleAfterTheBusyWait, GivesUpWhenTheEditorDoesNotSettle
  • Manual check against a running Editor with the rebuilt dist binaries:
    • Setup: a harmless edit in <FILE>, then uloop compile --force-recompile in the background and, two seconds later, uloop hot-reload --files <FILE> --compile-on-skip off --project-path <PROJECT_ROOT>.

    • stderr carried hot-reload: the Editor is busy running 'compile'; waiting for it to finish, then applying....

    • stdout had EditorReadyRetryNote = "The Editor was busy running 'compile' when this request arrived, so this command waited 31s for it to finish; …", Timing.EditorReadyWaitMs = 31430, and Outcome = NothingToApply (the compile had taken the edit in). The exit code was 0.

    • The CLI vibe log had, in order:

      1. cli_tool_request_failed (rpc:server_busy)
      2. cli_hot_reload_busy_wait_decided (running_tool_name compile)
      3. a second cli_tool_request_sent
      4. cli_hot_reload_busy_wait_complete (ready true, resends 0, second_outcome NothingToApply)

      There was no cli_connection_retry_focus_attempt entry.

  • This PR targets the integration branch, so the repository CI does not run on it. Every check above was run locally, on macOS only. The Windows Go tests can be dispatched with gh workflow run build-and-test.yml --ref <branch>.

Not changed

  • The busy handling of every other tool: the 10 s bounded retry, UNITY_SERVER_BUSY, and the busy_stall focus itself.
  • The Editor side and the wire format. The protocol version stays as it is.

…ut writing its failure

Hot reload is about to wait for a busy Editor on its status instead of resending every
second, which also brings the Editor to the front after five seconds. The send loop gets a
flag that hands the first busy answer back, and runPlainTool is split so the sending half
returns the failure for the caller to report. Every other tool keeps the bounded busy retry
and the same stderr output.
…th UNITY_SERVER_BUSY

A hot-reload sent right after a compile was refused because the compile still held the
Editor, retried for ten seconds, brought the Editor to the front after five, and failed.
Hot reload now takes the first busy answer, waits on the Editor status until it is ready
(up to the same ten minutes as the settle wait), and sends the same request once. A second
busy answer is reported without another wait. While a cancelled execute-dynamic-code holds
the Editor, the wait resends every five seconds, because the Editor takes that slot back
only when a tool request arrives.

The settle wait that can follow now appends its note and adds its wait time to what the
busy wait left, instead of overwriting them.
The hot-reload references, the vibe log list, and the single-flight notes in the agent
instructions and the soak-testing guide said every command behind a running one fails with
UNITY_SERVER_BUSY. Hot reload now waits for the running command and sends once, so they say
so, name the two new log entries, and tell readers to give --files after a compile.
@coderabbitai

coderabbitai Bot commented Oct 7, 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: 8e03fd5d-c2b9-441b-8f62-0571f062c477
📥 Commits

Reviewing files that changed from the base of the PR and between ef9991c and e5fe95b.

📒 Files selected for processing (17)
  • .agents/skills/uloop-hot-reload/references/output.md
  • .agents/skills/uloop-hot-reload/references/scope-and-limits.md
  • .claude/skills/uloop-hot-reload/references/output.md
  • .claude/skills/uloop-hot-reload/references/scope-and-limits.md
  • AGENTS.md
  • Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md
  • Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md
  • cli/project-runner/internal/projectrunner/connection_retry.go
  • cli/project-runner/internal/projectrunner/connection_retry_test.go
  • cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go
  • cli/project-runner/internal/projectrunner/hot_reload_busy_wait_test.go
  • cli/project-runner/internal/projectrunner/hot_reload_compile_fallback.go
  • cli/project-runner/internal/projectrunner/hot_reload_editor_ready_retry.go
  • cli/project-runner/internal/projectrunner/hot_reload_editor_ready_retry_test.go
  • cli/project-runner/internal/projectrunner/run.go
  • docs/soak-testing.md
  • docs/vibe-logs.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 CLI now handles hot-reload requests that encounter a busy Unity Editor by waiting for readiness and retrying. The changes add response notes and timing, logging, tests, and updates to hot-reload guidance.

Changes

Hot-reload busy Editor handling

Layer / File(s) Summary
Send path and busy-response retry control
cli/project-runner/internal/projectrunner/connection_retry.go, cli/project-runner/internal/projectrunner/connection_retry_test.go, cli/project-runner/internal/projectrunner/run.go
The send path accepts retry dependencies and can return the first busy response without resending. Tests cover that behavior and confirm that undispatched connection failures still retry.
Busy wait, resend, and response handling
cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go, cli/project-runner/internal/projectrunner/hot_reload_busy_wait_test.go, cli/project-runner/internal/projectrunner/hot_reload_compile_fallback.go, cli/project-runner/internal/projectrunner/hot_reload_editor_ready_retry.go, cli/project-runner/internal/projectrunner/hot_reload_editor_ready_retry_test.go, .agents/skills/uloop-hot-reload/references/*, .claude/skills/uloop-hot-reload/references/*, Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/*, AGENTS.md, docs/soak-testing.md, docs/vibe-logs.md
Hot-reload waits for Editor readiness after a busy response and retries once. While ExecuteDynamicCode holds the Editor, the CLI can resend at the configured interval. Responses include wait notes and applicable timing; logs and tests cover wait outcomes. Skill guidance and documentation describe the behavior.

Priority: ➖ Normal

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

Change: Bug fix

Merge Risk: ⚪ Minimal · up to e5fe9

Hot reload’s busy-Editor wait is ready to merge after normal checks. A wait near its deadline may finish a few seconds late.

Security Architecture Review

Security architecture risk: 🔵 Low · up to e5fe9

The wait changes when a request runs, not which project or operations it can access. Existing permission and execution checks remain in place, and no introduced security bypass was identified in the reviewed flow. Connection security and behavior across Editor restarts are not fully established.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The traced change remains confined to the originally resolved project endpoint and existing hot-reload authority. It increases how long requests may wait and be attempted, but does not introduce cross-project targeting or additional tool privileges. This does not establish the transport's complete authentication boundary.

Trust Boundaries and Controls

  • observed — A status result naming ExecuteDynamicCode is not sufficient to revoke a live lease. The unchanged server reclamation logic checks that the lease belongs to that tool, that its request is cancelled, and that the grace period has elapsed. Status snapshots themselves do not enter or reclaim the slot.

Resilience and Maintainability Implications

  • inferred — The reviewed transitions preserve failure containment against automatic duplicate dispatch: explicit busy rejection is retryable, while dispatched transport failures terminate rather than trigger another apply. Lease release is identity-based, so a late or repeated disposal cannot release a replacement holder's slot. These controls do not guarantee that a failed transport request had no server-side effect.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.58% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 53 functions across 8 files. (9 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the main change: hot reload waits for another uloop command instead of failing as busy.
Description check ✅ Passed The description explains the busy-wait behavior, its rationale, implementation, tests, and user impact. It is directly related to the changeset.
Full details: Docstring Coverage

Explanation

Docstring coverage is 73.58% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 53 functions across 8 files. (9 skipped: 9 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
📝 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.

When the settle wait ran out, the note said how long the command waited but Timing did not
carry that wait; after a busy wait, Timing held only the busy part while the note named both.
The give-up path now adds its wait the same way a second apply does, and a test pins both
the appended note and the summed wait on that path.
The never-settled test shared a 200 ms budget between the busy and settle waits, so a slow
machine could run the budget out during the busy wait and fail the script, and its 280 ms
threshold left little margin. The budget is now 400 ms and the test compares
EditorReadyWaitMs with the sum of the two waits the vibe log records.
@hatayama
hatayama merged commit 78f7ebb into feature/hot-reload-large-project-feedback-2 Oct 7, 2026
3 checks passed
@hatayama
hatayama deleted the feat/cli-hot-reload-waits-for-the-running-command branch October 7, 2026 13: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