Skip to content

refactor: Caller scans go through the compiled-callers run, so they cannot run outside one - #3300

Merged
hatayama merged 8 commits into
mainfrom
refactor/hot-reload-find-call-sites-on-run
Oct 13, 2026
Merged

hatayama merged 8 commits into
mainfrom
refactor/hot-reload-find-call-sites-on-run

Conversation

@hatayama

@hatayama hatayama commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Summary

HotReloadCompiledCallers.FindCallSites reads every compiled assembly it needs, with no budget. It was safe only while a run held the call-site cache: outside a run, a read on another thread could evict and dispose an assembly the scan was still reading. Until now, only an XML comment enforced that.

This PR moves FindCallSites onto HotReloadCompiledCallersRun, so a caller scan needs a run object. There is no behavior change. Scans can no longer be called from outside a run.

  • HotReloadCompiledCallersRun.FindCallSites(projectRoot, targets): same body as before (HotReloadCallSiteScanner.FindCallSites(projectRoot, targets).Hits). Calling it after End or Dispose triggers Debug.Assert.
  • The orchestrator passes its run through ProcessPlannedGroupAsync → HotReloadGroupProcessor.ProcessGroupAsync → the private transform/apply methods → HotReloadApplyContext.CompiledCallersRun.
  • HotReloadSignatureChangeGate scans through context.CompiledCallersRun. This was the only production caller.
  • Removed: the facade's FindCallSites, and HotReloadGroupStageCollaborators.CompiledCallers, which had no other user.

Preparatory refactor (separate commit)

HotReloadGroupProcessor.cs was close to the 500 SLOC limit. Before passing the run through it, the first commit moves SnapshotGroupState into HotReloadGroupStateSnapshot.Capture(domain, playMode, files) (Move Function, body unchanged).

SLOC of HotReloadGroupProcessor.cs (check-file-length):

Point SLOC
Before 493
After the move 468
After passing the run through 477

Tests

  • Test helpers that build an HotReloadApplyContext by hand get a run from HotReloadCompiledCallersTestRuns.OpenIsolated(). That run is on a fresh cache with an idle backfill, so it begins synchronously and its hold never touches the shared cache. Before this PR, these gate tests also read the shared cache without a hold: they called GateAndCompileAsync outside any run. So they behave the same.
  • Tests that call ProcessGroupAsync directly open a real run with await BeginRunAsync() inside using.
  • New: FindCallSites_AfterTheRunEnded_Asserts and FindCallSites_AfterTheRunWasDisposedWithoutEnd_Asserts. Mutation checks:
    • Removing the Debug.Assert makes the after-End test fail ("Expected log did not appear").
    • Removing _disposed = true; from Dispose makes the Dispose-without-End test fail the same way.

Verification

Class-filtered EditMode runs, all passing:

Test class Tests
HotReloadCompiledCallersTests 5
HotReloadGroupProcessorTests 50
HotReloadGroupProcessorLeaveOutTests 12
HotReloadOrchestratorTests 184
HotReloadSignatureChangeCoverageTests 16
HotReloadCrossAssemblyStaleSignatureE2ETests 7
HotReloadIntroducedTypeActivationTests 26
HotReloadCallerNoteBackfillE2ETests 4
The 14 E2E classes that rebuild collaborators via WithInternalAccessGrant 79

The 14 E2E classes are HotReloadIntroducedType*E2ETests, HotReloadCarriedInNextStepE2ETests, HotReloadDefaultSelectionEnumLeaveOutE2ETests, HotReloadStaleRowSiblingE2ETests and HotReloadIntroducedTypeInternalAccessE2ETests.

File-length and CA1502 complexity checkers report no findings.

The backfill reads only the dlls the run's note step refused at the first incomplete caller level, so the next level's dlls are found only by the next run. Pin the intended behavior before implementing it: after reading, the backfill recomputes the refused dlls (a function passed to Start), reads the new ones, skips ones it already tried, stops at the caller-depth round limit, and honors cancels before and inside the recompute.

Start takes the recompute function now; the stub ignores it, so the new recompute tests fail while the guard tests (empty list, cancel before the recompute) pass.
…vel needs

The note step's caller closure stops at the first level with a refused dll, so the backfill only learned that level's dlls and the next run still read one dll and refused another. After reading, the backfill now calls the run's recompute function, reads the dlls it newly refuses, and repeats until nothing new is refused or the caller-depth limit of rounds is reached.

Tried paths are remembered so a dll that fails to read is not retried, the cancel is checked before each recompute so a waiting run is not held up by it, and the complete entry gains rounds while requested now counts the distinct dlls tried.
…notes

The run remembers the project root and note requests, and on End passes the backfill a function that, on the main thread under a fresh cache hold, rebuilds the notes with a load budget of zero and returns the dlls it refused. Nothing is read by it and it writes no budget-exhausted entry, since no run's note was omitted. A run that built no notes returns an empty list without hopping threads.
…un reads nothing

Add a two-level cross-assembly caller chain (host in the test assembly, relay in the cross-assembly fixture, caller in a new level-2 fixture assembly) and an E2E test: the cold run refuses only the relay's dll, the backfill reads it and then the level-2 dll found by its recompute, writes no extra budget-exhausted entry, and the next run reads no dll.

The test reads the patched host through reflection: a direct call from the test assembly is itself a compiled caller, and the closure would stop at it before walking the relay's level.
Move Function only: SnapshotGroupState becomes HotReloadGroupStateSnapshot.Capture(domain, playMode, files) with the same body. It depends only on the domain and the play-mode query, and moving it gives HotReloadGroupProcessor (493 -> 468 SLOC) room under the 500 SLOC limit before the compiled-callers run is threaded through it.
…annot run outside one

FindCallSites reads every compiled assembly it needs and was safe only while a run held the cache, a contract the facade stated in a comment alone. It moves from HotReloadCompiledCallers onto HotReloadCompiledCallersRun, which the orchestrator threads through the group processor into HotReloadApplyContext; the signature-change gate scans through context.CompiledCallersRun. Holding a run is now the precondition, and a call after End or Dispose asserts. HotReloadGroupStageCollaborators loses its now unused CompiledCallers.

No behavior change. Hand-built test contexts get a run on an isolated cache (no shared hold, no Dispose); tests that call ProcessGroupAsync directly open a real run around it.
@coderabbitai

coderabbitai Bot commented Oct 13, 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: e70f0cb7-8097-4298-94ff-7e9ac3944958
📥 Commits

Reviewing files that changed from the base of the PR and between dc187aa and f97568d.

⛔ Files ignored due to path filters (2)
  • Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTestRuns.cs.meta is excluded by none and included by none
  • Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStateSnapshot.cs.meta is excluded by none and included by none
📒 Files selected for processing (13)
  • Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTests.cs
  • Assets/Tests/Editor/HotReload/HotReloadGroupProcessorLeaveOutTests.cs
  • Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs
  • Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeE2ETestBase.cs
  • Assets/Tests/Editor/HotReload/HotReloadOrchestratorTests.cs
  • Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallers.cs
  • Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallersRun.cs
  • Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyContext.cs
  • Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs
  • Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs
  • Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStageCollaborators.cs
  • Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs
  • Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSignatureChangeGate.cs
💤 Files with no reviewable changes (1)
  • Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallers.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

Compiled caller scans now use a run-scoped API. Hot-reload group processing passes the active run into the apply context. Group-state snapshot capture moved to a dedicated helper, and tests were updated for run ownership and the disposed-run assertion.

Changes: Run-scoped compiled caller scans

Layer / File(s) Summary
Run-scoped scan API and context
Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallers.cs, Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallersRun.cs, Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyContext.cs, Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStageCollaborators.cs, Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs, Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSignatureChangeGate.cs
FindCallSites moved from HotReloadCompiledCallers to HotReloadCompiledCallersRun. The run tracks disposal, and HotReloadApplyContext stores it for caller scans. HotReloadGroupStageCollaborators no longer stores HotReloadCompiledCallers.
Run propagation and group snapshots
Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs, Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs, Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStateSnapshot.cs
HotReloadOrchestrator passes the active run through group processing into HotReloadApplyContext. HotReloadGroupStateSnapshot.Capture records per-file state, including source-baseline results.
Test setup and lifecycle coverage
Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTestRuns.cs, Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTests.cs, Assets/Tests/Editor/HotReload/HotReloadGroupProcessorLeaveOutTests.cs, Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs, Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeE2ETestBase.cs, Assets/Tests/Editor/HotReload/HotReloadOrchestratorTests.cs
Tests create and pass compiled-caller runs to group processing and apply contexts. A new test checks that FindCallSites asserts after a run ends.

Priority: ⬇️ Low

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

Change: Refactor

Merge Risk: ⚪ Minimal · up to f9756

No actionable merge-blocking risk is established; the change is ready for normal merge checks.

Security architecture risk: 🔵 Low · up to f9756

The refactor narrows access and preserves normal execution and cleanup. Invalid-lifetime use is reported rather than blocked, and concurrent misuse remains unverified. No introduced or worsened security vulnerability was established.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The inspected scan path affects compiled assemblies selected for the Unity project and the process-wide caller cache. The receiver change does not expand the scanner's assembly selection or introduce a new remote entrypoint. Exposure through external consumers of the public API remains unestablished.

Trust Boundaries and Controls

  • observed — The new run requirement is a lifetime convention, not an authorization boundary or enforced exclusion of concurrent disposal. End and Dispose release the hold independently of scanning. Use outside an active hold was already possible through the old public service method; the inspected production caller remains inside its hold, so no introduced control bypass was established.

Hardening Proposals

  • proposed — If the public run API must support untrusted lifecycle usage or concurrent callers, enforce post-lifetime rejection and retain scan ownership until every in-flight scan finishes before releasing the cache hold. This would strengthen a pre-existing limitation, not remediate an established PR-introduced vulnerability.
Pre-merge checks · Passed 4 · Failed 1

Failed Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 51.72% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 29 functions across 14 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks (4 passed)
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main refactor: caller scans now run through HotReloadCompiledCallersRun.
Description check Passed The description directly explains the caller-scan refactor, run propagation, removed APIs, tests, and verification results.
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.
  • 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
  • Autofix · 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.

The existing test covered only a scan after End, so dropping the disposed flag left every test passing. A run that threw is disposed without End and releases its hold just the same, so a scan after it must be reported too.
Base automatically changed from perf/hot-reload-backfill-follows-next-caller-level to main October 13, 2026 06:06
@hatayama
hatayama merged commit 145d242 into main Oct 13, 2026
17 checks passed
@hatayama
hatayama deleted the refactor/hot-reload-find-call-sites-on-run branch October 13, 2026 06:29
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