Skip to content

fix: Send the compile again when Unity lost the request or rejected it as already compiling - #3192

Merged
hatayama merged 6 commits into
feature/hot-reload-large-project-feedbackfrom
fix/compile-resend-lost-or-busy-request
Oct 6, 2026
Merged

hatayama merged 6 commits into
feature/hot-reload-large-project-feedbackfrom
fix/compile-resend-lost-or-busy-request

Conversation

@hatayama

@hatayama hatayama commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • uloop compile no longer waits out its whole timeout (600 s by default) when Unity loses the compile request in a domain reload. It notices the loss within a few seconds and sends the compile again.
  • uloop compile no longer fails with COMPILE_ALREADY_IN_PROGRESS / COMPILE_EDITOR_UPDATING after it has already waited for a compile it did not start (for example one started by an asset refresh). Once the Editor is idle it sends the compile again and returns the real result.
  • The same applies to the hot-reload fallback compile and the run-tests implicit compile, which go through the same entry.

User Impact

Problem 1 (lost request). If the compile request reaches Unity in the few tens of milliseconds just before a domain reload, the IPC thread accepts it, but the reload starts before the main thread handles it. Unity keeps no record of it. After the reload, every get-compile-status answer is Ready=true / HasResult=false. The CLI then waited the full timeout and ended with COMPILE_WAIT_TIMEOUT, and it kept the Editor in front for about 590 s of that wait. The hot-reload fallback compile is sent right after the stale-assembly failure, so it hits this window most often (reproduced twice).

Problem 2 (busy rejection). If Unity is already compiling or updating when the request arrives, it rejects the compile and stores that rejection as the request's result. The CLI waited for the Editor to become Ready (the running compile plus its domain reload, about 19 s on a large project). It then returned the stored rejection with exit 1. The CLI had waited, but no compile ran.

After this change the CLI sends the compile again in both cases, with a new request ID. It sends at most 3 times in total, and all the sends share the one wait the user asked for.

How a lost request is recognized

Facts about Unity's get-compile-status (CompileStatusBridgeCommand.BuildResponse):

  • Ready is Editor-wide: not compiling, not updating, and not reloading.
  • Unity registers a pending request as soon as the compile tool starts on the main thread, before its first await. Once a domain reload has been observed, a Ready query for that request builds and stores a result from the pending entry. So a request that reached the main thread always has a result by the first Ready answer after a reload.
  • Without a server restart, a live request also answers Ready=true / HasResult=false for a few seconds before its compile starts (the Play Mode exit wait, the compile-start wait, or an unfocused Editor). Resending then would be rejected as busy, because the first request still holds the single-flight slot.

So a request counts as lost only when both of these hold:

  1. The server was seen to be recreated. Only three things count as evidence:

    • the compile send ended with a dropped connection after the request was dispatched;
    • a status query failed with a dropped connection, or with a connection failure that is neither a timeout nor a permission denial;
    • an answer had IsDomainReloadInProgress=true.

    These do not count, because each can happen while the server is alive:

    • a query that was acknowledged but never answered;
    • a connect timeout (a Windows named pipe with every instance busy);
    • a denied connect (a sandbox);
    • RPC errors and JSON failures.
  2. 3 Ready answers without a result in a row after that evidence. This is the same streak the reattach wait already uses. A busy answer resets the streak.

Changes

  • Only the fresh-compile path behind runCompileWithReattachPolicy resends. That path serves uloop compile, the hot-reload fallback compile, and the run-tests implicit compile.

  • Each resend uses a new RequestId. Unity keeps the old rejection under the old ID, so a query with that ID would return it at once.

  • At most 3 sends. The last attempt detects nothing and behaves exactly as before:

    • a rejection is returned as is;
    • a lost request waits until the deadline and ends with COMPILE_WAIT_TIMEOUT.

    Reaching the limit never creates a new kind of failure, and no new error code is added.

  • No resend with less than 10 s of the wait left. The resent compile would start in Unity just as the CLI times out, which adds a compile and turns a rejection into a timeout. In that case a rejection is returned and a lost request keeps waiting, both as before.

  • The first attempt's wait limit is still exactly --timeout-seconds. That value appears in timeout_ms and in timed out after …ms. A resent attempt waits only for the time that is left.

  • Each resend writes a cli_compile_request_resend CLI VibeLog entry, when ULOOP_DEBUG is set, with these fields:

    • reason: request_missing or editor_busy
    • attempt
    • request_id: the ID being replaced
  • Preparatory refactor (behavior unchanged): the body of the fresh compile was moved into one-attempt function. The busy-rejection check was moved next to the new code, renamed, and is now shared with pause-point recovery.

  • The compile skill's ErrorCode line now says the busy codes are what remains after the CLI resent twice. The generated .claude/ and .agents/ copies were regenerated with skills install.

Unchanged:

  • The pause-point recovery entry. It calls the non-resending function and keeps its own busy retry.
  • The reattach path for a previously timed-out request.
  • The Unity side.
  • cli/common, so no shared release inputs change.
  • The first wait limit.

Input space

# Entry How the send ended During the wait Before After Test
1 resending dispatched, then disconnected (EOF) missing ≥ 3 in a row waits to the deadline, COMPILE_WAIT_TIMEOUT resends on the 3rd missing T1
2 resending accepted, no final reply a query fails with a dropped connection / no listener, then missing ×3 same as 1 resends T2 (2 cases), T16 (the real query's error type)
2c resending same an answer with IsDomainReloadInProgress=true, then missing ×3 same as 1 resends T2c
3 resending same no restart evidence (nothing / acknowledged but unanswered / other error / connect timeout / denied connect), missing continues keeps waiting (the existing 10 s focus recovery) same, no resend T3 (5 cases)
4 resending disconnected missing ×2, then the result returns the result same T4
5 resending disconnected missing ×2, compiling, missing ×2, then the result returns the result same (not 3 in a row) T5
6 resending final reply received result is COMPILE_ALREADY_IN_PROGRESS / COMPILE_EDITOR_UPDATING after Ready returns the rejection, exit 1 resends with a new ID T6 (2 cases), T12 (from the hot-reload fallback entry)
8 resending same any other result, e.g. compile errors returns the result same T8
9 resending same rejected all 3 times returns the 1st rejection returns the 3rd rejection, exit 1, 3 sends T9
10 resending disconnected missing on all 3 attempts waits to the deadline on the 1st resends twice, the 3rd waits without detecting (ends as before) T10
11 non-resending (pause-point recovery) disconnected missing continues keeps waiting same T11
12 resending final reply received rejected, less than 10 s left returns the rejection same, no resend T13
13 resending disconnected missing continues, less than 10 s left waits to the deadline same T14
14 resending any wait limit of a resent attempt — (no 2nd attempt) the time that is left (1st attempt: exactly --timeout-seconds) T15
15 resending not dispatched / timed out before acceptance / busy retries exhausted / other RPC error — returns the send error same not covered: an existing return before the wait, unchanged
16 resending any cancellation cancellation error same T10, T14 (they end by cancellation), existing TestRunFreshCompileReportsCancellationWhileWaitingAfterDisconnect
17 a timed-out request is on record (reattach) — — handled by the reattach path same; rows 1–14 apply once it decides on a fresh compile not covered: the reattach path is unchanged (existing compile_attach tests)
18 resending invalid --timeout-seconds — error, nothing sent same not covered: today's callers validate it earlier; if it does arrive, it goes to the non-resending function

Inputs not used as axes:

  • --force-recompile: the resend reuses the same params. A forced request that spans a reload gets a COMPILE_RESULT_UNKNOWN result, which is row 8.
  • WaitForDomainReload: always true on this path.
  • ReloadExternalSceneChanges: it only changes the result's content (row 8).
  • A null or omitted Result: the completion check is unchanged.
  • Bringing the Editor to the front: done per attempt and restored at the end of it.
  • The spinner: created and stopped per attempt.
  • The pending record: a timed-out attempt writes it under its own ID, as today.
  • A queried status that comes back after the deadline: the completion check is unchanged, and no resend happens because less than 10 s is left.

Verification

This PR targets the integration branch, so the Go CI (build-cli) does not run on it. Everything below was run locally on macOS (Apple silicon).

New tests (compile_fresh_recovery_test.go): 16 test functions, 22 cases.

  • Red, before the change: these failed:

    • T1, T2 ×2, T2c, T6 ×2, T9, T10, T12, T15, T16

    These passed as guards:

    • T3 ×5, T4, T5, T8, T11, T13, T14
  • Green: all of them pass.

  • -race -count=20 is stable.

  • Each test finishes in about 10 ms.

  • T1 and T15 also check the single cli_compile_request_resend entry. T1 expects reason request_missing, attempt 1, and the first request's ID; T15 expects reason editor_busy.

  • The status query fake counts only queries made before the test cancels the context. Go 1.26 tickers are synchronous, so select can pick a tick over ctx.Done(). Counting only pre-cancel queries keeps exact count asserts from flaking.

Mutations: 14 of 14 caught. Each was applied, the new tests run, and the change reverted; git status was clean afterwards.

Mutation Fails
m1: restart evidence not required T3 ×5
m1b: any query error counts as evidence T3 (ii)–(v)
m1c: connect timeout and denied connect count T3 (iv)(v)
m1d: only a dropped connection counts T2 "nobody listening", T16
m2: a streak of 1 T4, T1, T5
m3: a busy answer keeps the streak T5
m4: request ID reused T1, T6 ×2, T10
m5: no busy-rejection branch T6 ×2, T9, T12, T15
m6: the last attempt keeps detecting T9, T10
m7: the non-resending entry detects T11
m8: no remaining-time check T13, T14
m9: a resend waits the full timeout T15
m10: the domain-reload flag ignored T2c
m11: the resend log reasons swapped T1, T15

Regression.

  • go test ./... -count=1 in cli/project-runner: 921 PASS, 1 FAIL. The baseline at the branch point is 896 PASS and the same FAIL; the 25 new PASS lines are the 22 cases plus 3 parent tests.
  • The one failure is TestSendWithTransientConnectionRetryAbortsOnRefusedConnect. The agent sandbox denies bind on a Unix socket there (bind: operation not permitted), and it fails the same way on the base.

scripts/check-go-cli.sh.

  • It stopped in cli/common at ipcendpoint, where the sandbox denied mkdir under /tmp. Everything else in cli/common passed, and this PR does not touch cli/common.
  • I then ran the remaining steps by hand for cli/dispatcher, cli/project-runner, and cli/release-automation:
    • gofmt -l: empty
    • go vet: clean
    • golangci-lint: 0 issues
    • tests: dispatcher and release-automation pass; project-runner as above
  • The dev binaries were built for darwin-arm64 with go build.

Other checks.

  • Complexity: cyclop reports 0 issues. runFreshCompileAttempt 13, waitForCompileCompletionWithDeps 13, runFreshCompileRecoveringWithDeps 5, isServerGoneError 4.
  • File length: no file over 500 SLOC (run.go is 441).
  • Coverage: cli/project-runner is 95.23%; the baseline is 95.2.
  • Skill: check-skill-size passes and scripts/sync-tool-docs.sh --check reports no drift. The regenerated .claude/ and .agents/ compile skill copies match the source byte for byte.

On a real Editor (Unity 2022.3, dev binaries, ULOOP_DEBUG=1):

  • Busy rejection (hit on the 1st try).
    • Setup: I added a comment to a test source, triggered AssetDatabase.Refresh() through execute-dynamic-code, and ran uloop compile right after it returned.
    • Unity received the request with is_compiling=true and stored "Compilation is already in progress".
    • The CLI waited across the domain reload. About 5 s later it logged cli_compile_request_resend reason=editor_busy attempt=1 and sent a new request with the remaining 594,964 ms as its wait.
    • Result: Success: true, exit 0, about 9 s after the command started. During the investigation, the same sequence without this change returned COMPILE_ALREADY_IN_PROGRESS with exit 1.
  • Lost request (hit on the 2nd of 2 tries).
    • Setup: I watched the test assembly's DLL timestamp and sent uloop compile about 200 ms after it changed.
    • The request was prepared 2 ms after Unity logged domain_reload_start, and its send ended with EOF 5 ms later. Unity never logged compile_request_received for that ID, and after the reload it answered ready=true has_result=false three times.
    • The CLI then logged cli_compile_request_resend reason=request_missing attempt=1 and sent a new ID. Unity received it at once.
    • Result: Success: true, exit 0, about 9.5 s after the first send. Before this change the wait had no other way out of these answers, so it would have run the full 600 s, as in the two fallback-compile reproductions.
    • The 1st try arrived after the reload had already started, so the send was retried and reached the new server. That request completed normally without a resend.
  • After both checks, the edited source was restored and uloop compile returned Success: true.

Not verified:

  • T16 calls the real status query against an endpoint nobody listens on. On macOS/Linux that is a refused TCP connect. On Windows, go-winio opens the address as a pipe path. I expect a not-found error that also counts as the server being gone, but it has not been run on Windows; it first runs there in the Windows Go CI when the integration branch goes to main.

Not covered

  • The error shown when the hot-reload request itself, rather than its fallback compile, is cut by a domain reload.
  • A lost request on the pause-point recovery entry. That entry also reuses the same RequestId when it resends after a busy rejection.
  • A resend made with 10 s to a few tens of seconds left can take longer than the remaining wait. It then ends with COMPILE_WAIT_TIMEOUT, and the second ID's pending record stays, so the next uloop compile reattaches to it. This is accepted by design.

Notes

  • The internal sentinel error reads the compile request has no record in Unity, in lowercase for staticcheck ST1005. It is never shown to users.
  • This is a project runner change, so users get it with the next project runner release. The protocol version is not bumped.

The fresh compile path is about to send the compile again when Unity
lost the request or rejected it as busy, which needs a single attempt
that reports how it ended. Nothing changes yet: every return reports a
final outcome and the existing entry calls the attempt once.
Unity can lose a compile request that arrives just before a domain
reload, and it rejects a compile that arrives while it is already
compiling or updating. The CLI then either waited out the whole timeout
or returned the rejection after waiting for the Editor. These tests pin
the resend behavior. The scaffold they need (a shorter status poll for
tests, the resending entry, and the server-gone check) does not resend
yet, so the resend cases fail.
A compile request that reaches Unity just before a domain reload can be
acknowledged and then dropped before the main thread registers it. The
CLI then polled Ready answers without a result until its wait ran out.
A compile that arrives while Unity is already compiling or updating is
rejected, and the CLI returned that rejection after waiting for the
Editor to become Ready.

The fresh compile entry now sends the request again with a new request
ID in both cases, at most three sends within the one wait the caller
asked for:
- A request counts as lost only after the server was seen recreated
  (the send dropped, a status query found the server gone, or an answer
  reported a domain reload) and three Ready answers without a result
  followed. Without that, a live request answers the same way until its
  compile starts.
- A busy rejection is resent once the wait has seen the Editor Ready.
- Nothing is resent with less than 10 seconds of the wait left, and the
  last attempt behaves exactly as before.

The first attempt keeps the exact --timeout-seconds wait, and
pause-point recovery's own entry still never resends.
The CLI now waits for Unity and sends the compile again when Unity
rejects it as compiling or updating, so these codes reach the caller
only after the resends ran out. The skill tells agents to run the
compile again rather than treat the codes as an immediate collision.
@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
  • Configuration used: Repository: hatayama/unity-cli-loop/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e078661c-ada3-4649-8cc1-a1b9114ae392
📥 Commits

Reviewing files that changed from the base of the PR and between f2b48b5 and 3a300ae.

📒 Files selected for processing (9)
  • .agents/skills/uloop-compile/SKILL.md
  • .claude/skills/uloop-compile/SKILL.md
  • Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md
  • cli/project-runner/internal/projectrunner/compile_fresh_recovery.go
  • cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go
  • cli/project-runner/internal/projectrunner/compile_wait.go
  • cli/project-runner/internal/projectrunner/compile_wait_deps.go
  • cli/project-runner/internal/projectrunner/pause_point_release_recovery.go
  • cli/project-runner/internal/projectrunner/run.go
 __________________________________________________________________________________________________________________________________________
< Test early. Test often. Test automatically. Tests that run with every build are much more effective than test plans that sit on a shelf. >
 ------------------------------------------------------------------------------------------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ 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.

Field verification tells a lost request from a busy rejection only by the reason on the resend log entry, so a swapped reason must fail a test rather than mislead that check.
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