From 502ee761ef255875876e669e5592815e5a31c5f4 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 7 Oct 2026 23:37:01 +0900 Subject: [PATCH 01/29] fix: uloop compile returns as soon as the Editor answers instead of warming execute-dynamic-code first (#3227) --- .../GetCompileStatusResponseContractTests.cs | 7 +- .../internal/projectrunner/compile_attach.go | 21 ++--- .../projectrunner/compile_attach_test.go | 91 +++++++++++++++++-- .../compile_current_sources_test.go | 3 +- .../compile_fresh_recovery_test.go | 2 +- .../compile_status_response_contract_test.go | 10 +- .../projectrunner/compile_wait_test.go | 31 +------ .../pause_point_release_recovery_test.go | 25 ++--- .../internal/projectrunner/run.go | 33 +------ .../internal/projectrunner/run_test.go | 7 -- 10 files changed, 110 insertions(+), 120 deletions(-) diff --git a/Assets/Tests/Editor/GetCompileStatusResponseContractTests.cs b/Assets/Tests/Editor/GetCompileStatusResponseContractTests.cs index 12ad062480..e87dfee945 100644 --- a/Assets/Tests/Editor/GetCompileStatusResponseContractTests.cs +++ b/Assets/Tests/Editor/GetCompileStatusResponseContractTests.cs @@ -22,7 +22,8 @@ public void GetCompileStatusResponse_WhenSerialized_MatchesSharedContractFieldSh { // Verifies C# does not add, remove, or rename get-compile-status fields without updating the // shared CLI contract. Result must come from a real CompileResponse serialization because that - // is what CompileStatusBridgeCommand restores from ResultJson for Go compileResultStatus.Success. + // is what CompileStatusBridgeCommand restores from ResultJson for the Go CLI, which reads + // Result.Success as the compile's exit code. JObject expected = ReadSharedContractFieldShape(); JObject compileResultJson = SerializeCompileResponseResult(); GetCompileStatusResponse response = new() @@ -47,8 +48,8 @@ public void GetCompileStatusResponse_WhenSerialized_MatchesSharedContractFieldSh [Test] public void CompileResponse_WhenSerializedForCompileStatusResult_IncludesSuccessProperty() { - // Verifies the wire Result payload still exposes Success under the name Go unmarshals into - // compileResultStatus — a rename on CompileResponse must fail this test. + // Verifies the wire Result payload still exposes Success under the name the Go CLI reads for + // the exit code (toolEnvelopeExitCode) — a rename on CompileResponse must fail this test. JObject compileResultJson = SerializeCompileResponseResult(); Assert.That(compileResultJson.Property("Success"), Is.Not.Null); Assert.That(compileResultJson["Success"]!.Type, Is.EqualTo(JTokenType.Boolean)); diff --git a/cli/project-runner/internal/projectrunner/compile_attach.go b/cli/project-runner/internal/projectrunner/compile_attach.go index 3b892f2bca..54fef7deaa 100644 --- a/cli/project-runner/internal/projectrunner/compile_attach.go +++ b/cli/project-runner/internal/projectrunner/compile_attach.go @@ -90,7 +90,7 @@ func tryAttachToPendingCompile( clearCompilePendingRecord(connection.ProjectRoot) return false, compileExecutionResult{} } - return true, returnAttachedStoredCompileResult(ctx, connection, record, status.Result, stderr) + return true, returnAttachedStoredCompileResult(connection, record, status.Result, stderr) } if !status.Ready { @@ -219,7 +219,7 @@ func attachWaitForPendingCompile( spinner.Stop() return false, compileExecutionResult{} } - return true, completeCompileResult(ctx, connection, result, stderr, spinner, startedAt, unityipc.UnitySendOutcome{}) + return true, completeCompileResult(result, stderr, spinner, startedAt, unityipc.UnitySendOutcome{}) default: spinner.Stop() logCompileAttachResult(connection, record.RequestID, "error", false) @@ -289,7 +289,6 @@ func waitForAttachedCompileCompletion( } func returnAttachedStoredCompileResult( - ctx context.Context, connection unityipc.Connection, record compilePendingRecord, result json.RawMessage, @@ -300,26 +299,20 @@ func returnAttachedStoredCompileResult( spinner := clicore.NewToolSpinner(stderr, clicore.CompileCommandName) clearCompilePendingRecord(connection.ProjectRoot) logCompileAttachResult(connection, record.RequestID, "stored_result", true) - return completeCompileResult(ctx, connection, result, stderr, spinner, startedAt, unityipc.UnitySendOutcome{}) + return completeCompileResult(result, stderr, spinner, startedAt, unityipc.UnitySendOutcome{}) } +// completeCompileResult turns the compile answer into the command's result. +// Why nothing is sent after the answer: the answer came from the Editor that finished the compile, +// and a readiness probe here would hold the Editor's single-flight slot for seconds that only the +// next execute-dynamic-code would have gained. func completeCompileResult( - ctx context.Context, - connection unityipc.Connection, result json.RawMessage, stderr io.Writer, spinner *ui.TerminalSpinner, startedAt time.Time, outcome unityipc.UnitySendOutcome, ) compileExecutionResult { - switch compileResultReadinessWaitMode(result) { - case compileReadinessWaitWarmup: - spinner.Update("Warming execute-dynamic-code after compile...") - if err := clicore.WaitForToolReadiness(ctx, connection.ProjectRoot); err != nil { - spinner.Stop() - writePostCompileWarmupWarning(stderr, err) - } - } spinner.Stop() writeDebugTiming(stderr, clicore.CompileCommandName, time.Since(startedAt), outcome) return compileExecutionResult{result: result, exitCode: toolEnvelopeExitCode(result)} diff --git a/cli/project-runner/internal/projectrunner/compile_attach_test.go b/cli/project-runner/internal/projectrunner/compile_attach_test.go index 9ba0ee42b5..121e615c8a 100644 --- a/cli/project-runner/internal/projectrunner/compile_attach_test.go +++ b/cli/project-runner/internal/projectrunner/compile_attach_test.go @@ -242,6 +242,83 @@ func TestRunCompileAttachReturnsStoredResultAndClearsRecord(t *testing.T) { } } +// Verifies a successful stored result is returned as soon as it is read, with exit code 0 and +// without a post-compile warm-up. +func TestRunCompileAttachReturnsAStoredSuccessAtOnce(t *testing.T) { + projectRoot := t.TempDir() + if err := writeCompilePendingRecord(projectRoot, compilePendingRecord{ + RequestID: "compile_attach_stored_success", + TimedOutAtUtc: time.Now().UTC(), + }); err != nil { + t.Fatalf("write pending record failed: %v", err) + } + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + return compileStatusResponse{ + Ready: true, + HasResult: true, + Result: json.RawMessage(`{"Success":true,"ErrorCount":0}`), + }, nil + }) + + assertAttachedSuccessReturnsAtOnce(t, projectRoot, deps) +} + +// Verifies a successful result that an attached wait sees once the in-flight compile finishes is +// returned as soon as it arrives, with exit code 0 and without a post-compile warm-up. +func TestRunCompileAttachReturnsAWaitedSuccessAtOnce(t *testing.T) { + projectRoot := t.TempDir() + if err := writeCompilePendingRecord(projectRoot, compilePendingRecord{ + RequestID: "compile_attach_waited_success", + TimedOutAtUtc: time.Now().UTC().Add(-time.Minute), + }); err != nil { + t.Fatalf("write pending record failed: %v", err) + } + callCount := 0 + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + callCount++ + if callCount == 1 { + return compileStatusResponse{Ready: false, IsCompiling: true}, nil + } + return compileStatusResponse{ + Ready: true, + HasResult: true, + Result: json.RawMessage(`{"Success":true,"ErrorCount":0}`), + }, nil + }) + + assertAttachedSuccessReturnsAtOnce(t, projectRoot, deps) +} + +// assertAttachedSuccessReturnsAtOnce runs compile against a pending record whose result is a +// success and checks the result is written to stdout with exit code 0 well within the time a +// readiness probe would take. +func assertAttachedSuccessReturnsAtOnce(t *testing.T, projectRoot string, deps compileWaitDeps) { + t.Helper() + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{Network: "tcp", Address: "127.0.0.1:1"}, + ProjectRoot: projectRoot, + } + var stdout, stderr bytes.Buffer + startedAt := time.Now() + + code := runCompileWithDomainReloadWaitWithDeps(context.Background(), connection, map[string]any{}, &stdout, &stderr, deps) + + // Why a time limit: the post-compile warm-up this command no longer runs would wait 180 s for a + // readiness a temp project never reaches. + if elapsed := time.Since(startedAt); elapsed >= 30*time.Second { + t.Fatalf("compile took %s after a successful answer, want under 30s", elapsed) + } + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr:\n%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"Success": true`) && !strings.Contains(stdout.String(), `"Success":true`) { + t.Fatalf("the successful result missing from stdout: %s", stdout.String()) + } + if strings.Contains(stderr.String(), "warning") { + t.Fatalf("a successful compile must not warn:\n%s", stderr.String()) + } +} + // Verifies ForceRecompile clears the pending record and starts a new compile. func TestRunCompileAttachForceRecompileClearsRecordAndStartsNewCompile(t *testing.T) { if runtime.GOOS == "windows" { @@ -717,17 +794,13 @@ func TestRunCompileAttachReportsCancellationDuringWait(t *testing.T) { } } -// Verifies a successful compile whose post-compile warmup fails still returns the compile -// result and only warns about the skipped warmup. -func TestCompleteCompileResultWarnsWhenWarmupFails(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() +// Verifies a successful compile answer becomes the command's result as it is, with exit code 0 +// and no warning. +func TestCompleteCompileResultReturnsTheAnswerAsIs(t *testing.T) { var stderr bytes.Buffer result := json.RawMessage(`{"Success":true,"ErrorCount":0}`) execution := completeCompileResult( - ctx, - unityipc.Connection{ProjectRoot: t.TempDir()}, result, &stderr, clicore.NewToolSpinner(&stderr, clicore.CompileCommandName), @@ -738,8 +811,8 @@ func TestCompleteCompileResultWarnsWhenWarmupFails(t *testing.T) { if execution.exitCode != 0 || string(execution.result) != string(result) { t.Fatalf("unexpected execution: %#v", execution) } - if !strings.Contains(stderr.String(), "warning: post-compile warmup skipped: context canceled") { - t.Fatalf("stderr must warn about the skipped warmup:\n%s", stderr.String()) + if strings.Contains(stderr.String(), "warning") { + t.Fatalf("a successful compile must not warn:\n%s", stderr.String()) } } diff --git a/cli/project-runner/internal/projectrunner/compile_current_sources_test.go b/cli/project-runner/internal/projectrunner/compile_current_sources_test.go index a252cac8f8..de78946224 100644 --- a/cli/project-runner/internal/projectrunner/compile_current_sources_test.go +++ b/cli/project-runner/internal/projectrunner/compile_current_sources_test.go @@ -14,8 +14,7 @@ import ( "github.com/hatayama/unity-cli-loop/common/unityipc" ) -// Why Success:false in both: a successful result triggers the post-compile warmup, which waits for -// a tool readiness that a temp project never reaches. The error counts tell the two results apart. +// The error counts tell the two results apart. const ( earlierCompileResult = `{"Success":false,"ErrorCount":9}` currentCompileResult = `{"Success":false,"ErrorCount":1}` diff --git a/cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go b/cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go index 97f231395d..806c6d3e29 100644 --- a/cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go +++ b/cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go @@ -19,7 +19,7 @@ import ( const ( // Stops a wait that never ends instead of letting it poll through the whole default wait. compileRecoveryQueryLimit = 200 - // A compile error: definitive, and it does not start the post-compile warmup. + // A compile error: definitive. compileRecoveryDefinitiveResult = `{"Success":false,"ErrorCount":1,"WarningCount":0,"ErrorCode":null}` compileRecoveryAlreadyInProgressResult = `{"Success":false,"ErrorCode":"COMPILE_ALREADY_IN_PROGRESS","ErrorCount":1}` ) diff --git a/cli/project-runner/internal/projectrunner/compile_status_response_contract_test.go b/cli/project-runner/internal/projectrunner/compile_status_response_contract_test.go index 71d3d906a7..cbdb66bcc5 100644 --- a/cli/project-runner/internal/projectrunner/compile_status_response_contract_test.go +++ b/cli/project-runner/internal/projectrunner/compile_status_response_contract_test.go @@ -10,7 +10,7 @@ import ( const compileStatusResponseContractPath = "tests/contracts/compile_status_response_contract.json" // Verifies the Go compileStatusResponse DTO preserves every field in the shared Unity -// get-compile-status response contract, including nested Result.Success used by compileResultStatus. +// get-compile-status response contract, including a nested Result whose Success the CLI reads as the exit code. func TestCompileStatusResponseMatchesSharedContract(t *testing.T) { fixturePath := findRepoRelativeFile(t, compileStatusResponseContractPath) fixture, err := os.ReadFile(fixturePath) @@ -25,12 +25,8 @@ func TestCompileStatusResponseMatchesSharedContract(t *testing.T) { assertCompileStatusResponseFieldsPopulated(t, response) - var resultStatus compileResultStatus - if err := json.Unmarshal(response.Result, &resultStatus); err != nil { - t.Fatalf("failed to unmarshal Result into compileResultStatus: %v", err) - } - if resultStatus.Success == nil { - t.Fatal("compileResultStatus.Success must be non-nil after unmarshaling the shared contract") + if toolEnvelopeExitCode(response.Result) != 0 { + t.Fatalf("the shared contract's Result must read as a successful compile: %s", response.Result) } roundTripped, err := json.Marshal(response) diff --git a/cli/project-runner/internal/projectrunner/compile_wait_test.go b/cli/project-runner/internal/projectrunner/compile_wait_test.go index f45fe4406d..0adb3132f3 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait_test.go +++ b/cli/project-runner/internal/projectrunner/compile_wait_test.go @@ -564,8 +564,7 @@ func TestRunCompileWithDomainReloadWaitWarnsWhenTimeoutExceedsRetention(t *testi } endpoint, serverErr := startCompileAcceptOnceServer(t) - // Why Success:false: Success:true triggers post-compile warmup against a live Editor - // and would hang this unit test. The warning is emitted before send/wait. + // The warning is emitted before send/wait. deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { return compileStatusResponse{ Ready: true, @@ -669,34 +668,6 @@ func TestShouldWaitForCompileStatusAllowsAcceptedFinalResponseTimeout(t *testing } } -// Verifies compile readiness warmup only runs after confirmed successful results. -func TestCompileResultReadinessWaitMode(t *testing.T) { - cases := map[string]compileReadinessWaitMode{ - `{"Success":true}`: compileReadinessWaitWarmup, - `{"Success":false,"Errors":[{"Message":"boom"}]}`: compileReadinessWaitNone, - `{"Success":false,"Message":"indeterminate"}`: compileReadinessWaitNone, - `{"Message":"indeterminate"}`: compileReadinessWaitNone, - } - - for result, expected := range cases { - actual := compileResultReadinessWaitMode([]byte(result)) - if actual != expected { - t.Fatalf("readiness wait mode mismatch for %s: %v", result, actual) - } - } -} - -// Verifies that failed best-effort warmup reports a warning without taking over compile output. -func TestWritePostCompileWarmupWarningReportsNonFatalFailure(t *testing.T) { - var stderr bytes.Buffer - - writePostCompileWarmupWarning(&stderr, fmt.Errorf("probe failed")) - - if !strings.Contains(stderr.String(), "warning: post-compile warmup skipped: probe failed") { - t.Fatalf("warning mismatch: %s", stderr.String()) - } -} - // Verifies TimeoutSeconds is parsed from tool params with the default // kept when absent and non-positive or non-integer values rejected. func TestCompileWaitTimeoutFromParams(t *testing.T) { diff --git a/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go b/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go index 42fad59be8..1a47cfd35a 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go @@ -732,8 +732,7 @@ func TestSendCompileWithBusyRetryStopsRetrying(t *testing.T) { }) } -// A successful compile result. It starts the post-compile warmup, which waits for a Unity project -// these tests do not have, so a test that returns it cancels the command at that answer. +// A successful compile result. const pausePointRecoveryCompileSuccess = `{"Success":true}` // stubPausePointRecoveryBusyRetryWaits makes the wait between server_busy sends return at once and @@ -767,17 +766,20 @@ func runPausePointRecoveryCompile( // Verifies the recovery compile sends a request Unity lost again under a new request ID instead of // waiting out the whole timeout, and that the successful compile leaves stdout empty. func TestPausePointRecoveryCompileResendsWhenUnityLostTheRequest(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() scenario := newCompileRecoveryScenario(t, []compileRecoverySend{recoverySendDisconnected(), recoverySendAnswered()}, []compileRecoveryAnswer{recoveryMissing()}, []compileRecoveryAnswer{recoveryDone(pausePointRecoveryCompileSuccess)}, ) - scenario.cancelWhen(cancel, 1, 1) + startedAt := time.Now() - code, stdout, stderr := runPausePointRecoveryCompile(t, ctx, map[string]any{}, scenario.deps()) + code, stdout, stderr := runPausePointRecoveryCompile(t, context.Background(), map[string]any{}, scenario.deps()) + // Why a time limit: the post-compile warm-up this command no longer runs would wait 180 s for a + // readiness a temp project never reaches. + if elapsed := time.Since(startedAt); elapsed >= 30*time.Second { + t.Fatalf("the recovery compile took %s after a successful answer, want under 30s", elapsed) + } if code != 0 { t.Fatalf("exit code = %d, want 0\nstderr:\n%s", code, stderr) } @@ -804,16 +806,13 @@ func TestPausePointRecoveryCompileResendsAfterABusyRejection(t *testing.T) { for _, errorCode := range []string{"COMPILE_ALREADY_IN_PROGRESS", "COMPILE_EDITOR_UPDATING"} { t.Run(errorCode, func(t *testing.T) { waits := stubPausePointRecoveryBusyRetryWaits(t) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() scenario := newCompileRecoveryScenario(t, []compileRecoverySend{recoverySendAnswered(), recoverySendAnswered()}, compileRecoveryBusyRejectionAnswers(errorCode), []compileRecoveryAnswer{recoveryDone(pausePointRecoveryCompileSuccess)}, ) - scenario.cancelWhen(cancel, 1, 1) - code, stdout, stderr := runPausePointRecoveryCompile(t, ctx, map[string]any{}, scenario.deps()) + code, stdout, stderr := runPausePointRecoveryCompile(t, context.Background(), map[string]any{}, scenario.deps()) if code != 0 { t.Fatalf("exit code = %d, want 0\nstderr:\n%s", code, stderr) @@ -879,14 +878,10 @@ func TestPausePointRecoveryCompileWritesADefinitiveFailureOnce(t *testing.T) { // again after one wait that the time left in the compile wait caps below the retry interval. func TestPausePointRecoveryCompileRetriesAServerBusySend(t *testing.T) { waits := stubPausePointRecoveryBusyRetryWaits(t) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() scenario := newCompileRecoveryScenario(t, []compileRecoverySend{recoverySendAnswered()}, []compileRecoveryAnswer{recoveryDone(pausePointRecoveryCompileSuccess)}, ) - // The refused send never reaches the scenario, so the scenario's first send is the retry. - scenario.cancelWhen(cancel, 0, 1) deps := scenario.deps() scriptedSend := deps.sendCompile busy := serverBusyRPCError(t) @@ -908,7 +903,7 @@ func TestPausePointRecoveryCompileRetriesAServerBusySend(t *testing.T) { // Why a 1s wait: it is shorter than the retry interval, so only a budget taken from the time left // in the compile wait keeps the retry wait at or under 1s. - code, stdout, stderr := runPausePointRecoveryCompile(t, ctx, map[string]any{compileWaitTimeoutParam: 1}, deps) + code, stdout, stderr := runPausePointRecoveryCompile(t, context.Background(), map[string]any{compileWaitTimeoutParam: 1}, deps) if code != 0 { t.Fatalf("exit code = %d, want 0\nstderr:\n%s", code, stderr) diff --git a/cli/project-runner/internal/projectrunner/run.go b/cli/project-runner/internal/projectrunner/run.go index 9b6abf1d32..7e2f98517d 100644 --- a/cli/project-runner/internal/projectrunner/run.go +++ b/cli/project-runner/internal/projectrunner/run.go @@ -354,13 +354,7 @@ func runFreshCompileAttempt( spinner.Stop() return compileExecutionResult{}, freshCompileAttemptEditorBusy } - return completeCompileResult(ctx, connection, result, stderr, spinner, startedAt, outcome), freshCompileAttemptFinal -} - -func writePostCompileWarmupWarning(stderr io.Writer, err error) { - // Why: this warmup is a hidden optimization, so it must not turn a - // successful compile result into a user-visible command failure. - _, _ = fmt.Fprintf(stderr, "warning: post-compile warmup skipped: %v\n", err) + return completeCompileResult(result, stderr, spinner, startedAt, outcome), freshCompileAttemptFinal } func runList(ctx context.Context, connection unityipc.Connection, commandArgs []string, stdout io.Writer, stderr io.Writer) int { @@ -433,28 +427,3 @@ func runSync(ctx context.Context, connection unityipc.Connection, stdout io.Writ clicore.WriteFormat(stdout, "Tools synced to %s\n", cachePath) return 0 } - -type compileResultStatus struct { - Success *bool `json:"Success"` -} - -type compileReadinessWaitMode int - -const ( - compileReadinessWaitNone compileReadinessWaitMode = iota - compileReadinessWaitWarmup -) - -func compileResultReadinessWaitMode(result json.RawMessage) compileReadinessWaitMode { - var status compileResultStatus - if json.Unmarshal(result, &status) != nil { - return compileReadinessWaitNone - } - if status.Success == nil { - return compileReadinessWaitNone - } - if *status.Success { - return compileReadinessWaitWarmup - } - return compileReadinessWaitNone -} diff --git a/cli/project-runner/internal/projectrunner/run_test.go b/cli/project-runner/internal/projectrunner/run_test.go index 6a3a74b48b..69f9e08b16 100644 --- a/cli/project-runner/internal/projectrunner/run_test.go +++ b/cli/project-runner/internal/projectrunner/run_test.go @@ -341,10 +341,3 @@ func TestRunListPrintsLiveCatalog(t *testing.T) { t.Fatalf("stdout must list the live tool:\n%s", stdout.String()) } } - -// Verifies that an undecodable compile result never triggers the post-compile readiness wait. -func TestCompileResultReadinessWaitModeIgnoresMalformedResult(t *testing.T) { - if mode := compileResultReadinessWaitMode([]byte("not json")); mode != compileReadinessWaitNone { - t.Fatalf("mode = %v, want compileReadinessWaitNone", mode) - } -} From 3f1b51720f3470b3213fd5d791cf1a977e53e504 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 7 Oct 2026 23:55:52 +0900 Subject: [PATCH 02/29] fix: uloop compile no longer brings the Editor to the front while it waits for compilation to start (#3228) --- .../compile_fresh_recovery_test.go | 1 - .../internal/projectrunner/compile_wait.go | 21 -- .../projectrunner/compile_wait_deps.go | 21 -- .../projectrunner/compile_wait_focus.go | 43 ---- .../projectrunner/compile_wait_test.go | 206 ------------------ .../projectrunner/connection_retry.go | 1 - ...d-a-macos-activity-while-a-command-runs.md | 1 + 7 files changed, 1 insertion(+), 293 deletions(-) delete mode 100644 cli/project-runner/internal/projectrunner/compile_wait_focus.go diff --git a/cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go b/cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go index 806c6d3e29..4a9129b410 100644 --- a/cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go +++ b/cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go @@ -80,7 +80,6 @@ func (scenario *compileRecoveryScenario) deps() compileWaitDeps { deps := compileWaitTestDeps(scenario.query) deps.sendCompile = scenario.send deps.freshWaitPollInterval = time.Millisecond - deps.startStallFocusThreshold = time.Hour return deps } diff --git a/cli/project-runner/internal/projectrunner/compile_wait.go b/cli/project-runner/internal/projectrunner/compile_wait.go index 5a587909c4..5e1d49e91c 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait.go +++ b/cli/project-runner/internal/projectrunner/compile_wait.go @@ -38,10 +38,6 @@ const ( compileWaitPollInterval = clicore.ToolReadinessPoll compileStatusProbeTimeout = clicore.ToolReadinessProbeTimeout compileResponseTimeout = 2 * time.Second - // Why 10s: AutoTickPump keeps Unity ticking while idle, but cannot lift OS - // background throttling. After this long with no compile activity, focusing - // the Editor is the only recovery that has been observed to work. - compileStartStallFocusThreshold = 10 * time.Second ) type compileCompletionOptions struct { @@ -193,19 +189,8 @@ func waitForCompileCompletionWithDeps( attempts := 0 var lastStatus compileStatusResponse observedStatus := false - activityObserved := false var lastErr error lastObservationKey := "" - focusController := newConnectionRetryFocusController( - options.connection, - clicore.CompileCommandName, - compileWaitFocusDeps(deps), - ) - defer func() { - restoreContext, cancel := context.WithTimeout(context.Background(), focusRestoreTimeout) - defer cancel() - focusController.restore(restoreContext) - }() logCompileStatusPollStart(options, startedAt, deadline) interim := newCompileWaitInterimState(compileWaitNow(deps)) @@ -222,7 +207,6 @@ func waitForCompileCompletionWithDeps( attempts++ status, err := deps.queryCompileStatus(ctx, options.connection, options.requestID) lastErr = err - activityObserved = noteCompileActivityStarted(status, err, activityObserved) if err == nil && status.Ready && status.HasResult && len(status.Result) > 0 { logCompileStatusPollObservedIfChanged(options, startedAt, attempts, status, nil, &lastObservationKey) logCompileStatusPollComplete(options, startedAt, attempts, status) @@ -238,11 +222,6 @@ func waitForCompileCompletionWithDeps( } logCompileStatusPollObservedIfChanged(options, startedAt, attempts, status, err, &lastObservationKey) observeCompileWaitInterim(&interim, deps, status, err) - // Why: queryCompileStatus can return after the wait deadline. Focusing then - // would steal window order for a wait that has already timed out. - if time.Now().Before(deadline) { - maybeAttemptCompileStartStallFocus(ctx, startedAt, activityObserved, lastErr, focusController, deps) - } select { case <-ctx.Done(): diff --git a/cli/project-runner/internal/projectrunner/compile_wait_deps.go b/cli/project-runner/internal/projectrunner/compile_wait_deps.go index b63f259aa6..959b6807a3 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait_deps.go +++ b/cli/project-runner/internal/projectrunner/compile_wait_deps.go @@ -25,19 +25,9 @@ type compileWaitDeps struct { now func() time.Time interimReportInterval time.Duration reportInterim compileWaitInterimReporter - // Zero keeps compileStartStallFocusThreshold. Tests shorten it so they do not wait 10s. - startStallFocusThreshold time.Duration // Zero keeps compileWaitPollInterval for a fresh compile's status wait. Tests shorten it so // they do not wait 1s between status queries. freshWaitPollInterval time.Duration - focus connectionRetryDeps -} - -func compileStartStallFocusThresholdFor(deps compileWaitDeps) time.Duration { - if deps.startStallFocusThreshold > 0 { - return deps.startStallFocusThreshold - } - return compileStartStallFocusThreshold } func freshWaitPollIntervalFor(deps compileWaitDeps) time.Duration { @@ -47,17 +37,6 @@ func freshWaitPollIntervalFor(deps compileWaitDeps) time.Duration { return compileWaitPollInterval } -func compileWaitFocusDeps(deps compileWaitDeps) connectionRetryDeps { - merged := defaultConnectionRetryDeps() - if deps.focus.findRunningUnityProcess != nil { - merged.findRunningUnityProcess = deps.focus.findRunningUnityProcess - } - if deps.focus.focusUnityProcess != nil { - merged.focusUnityProcess = deps.focus.focusUnityProcess - } - return merged -} - func compileSendOrDefault(deps compileWaitDeps) compileSendFunc { if deps.sendCompile != nil { return deps.sendCompile diff --git a/cli/project-runner/internal/projectrunner/compile_wait_focus.go b/cli/project-runner/internal/projectrunner/compile_wait_focus.go deleted file mode 100644 index 584d3ed427..0000000000 --- a/cli/project-runner/internal/projectrunner/compile_wait_focus.go +++ /dev/null @@ -1,43 +0,0 @@ -package projectrunner - -import ( - "context" - "errors" - "time" -) - -var errCompileStartStallNoActivity = errors.New("compile status has not shown activity") - -func compileActivityHasStarted(status compileStatusResponse) bool { - return status.IsCompiling || status.IsUpdating || status.IsDomainReloadInProgress || status.HasResult -} - -func noteCompileActivityStarted(status compileStatusResponse, err error, activityObserved bool) bool { - if err != nil || activityObserved { - return activityObserved - } - return compileActivityHasStarted(status) -} - -func maybeAttemptCompileStartStallFocus( - ctx context.Context, - startedAt time.Time, - activityObserved bool, - lastErr error, - controller *connectionRetryFocusController, - deps compileWaitDeps, -) { - // Why: domain-reload silence after compile has started is normal. Focusing then - // would steal window order from the user for a healthy wait. - if activityObserved { - return - } - if time.Since(startedAt) < compileStartStallFocusThresholdFor(deps) { - return - } - cause := lastErr - if cause == nil { - cause = errCompileStartStallNoActivity - } - controller.tryFocus(ctx, focusReasonCompileStartStall, cause) -} diff --git a/cli/project-runner/internal/projectrunner/compile_wait_test.go b/cli/project-runner/internal/projectrunner/compile_wait_test.go index 0adb3132f3..1c146001ee 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait_test.go +++ b/cli/project-runner/internal/projectrunner/compile_wait_test.go @@ -943,190 +943,6 @@ func TestWaitForCompileCompletionReturnsLastStatusOnTimeout(t *testing.T) { } } -// Verifies all-idle compile status past the start-stall threshold focuses Unity once -// with reason compile_start_stall. -func TestWaitForCompileCompletionFocusesOnceWhenStatusStaysIdle(t *testing.T) { - enableCliVibeLog(t) - connection := compileWaitTestConnection(t) - deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { - return compileStatusResponse{}, nil - }) - probe := attachCompileWaitFocusProbe(&deps) - - _, completed, _, err := waitForCompileCompletionWithDeps(context.Background(), compileCompletionOptions{ - connection: connection, - requestID: "compile_start_stall_idle", - timeout: 200 * time.Millisecond, - pollInterval: 5 * time.Millisecond, - }, deps) - if err != nil { - t.Fatalf("waitForCompileCompletion failed: %v", err) - } - if completed { - t.Fatal("idle compile wait should time out") - } - if probe.focusCount != 1 { - t.Fatalf("focus attempts mismatch: got %d want 1", probe.focusCount) - } - - logContent := readOnlyCliVibeLog(t, connection.ProjectRoot) - attemptEntries := cliVibeEntriesForOperation(t, logContent, "cli_connection_retry_focus_attempt") - if len(attemptEntries) != 1 { - t.Fatalf("expected exactly 1 focus attempt log, got %d:\n%s", len(attemptEntries), logContent) - } - reason := vibeLogContextString(t, attemptEntries[0], "reason") - expectedReason := "compile_start_stall" - if reason != expectedReason { - t.Fatalf("focus reason mismatch: got %q want %q", reason, expectedReason) - } -} - -// Verifies observing IsCompiling before the threshold suppresses focus even when later polls fail. -func TestWaitForCompileCompletionDoesNotFocusAfterActivityStarted(t *testing.T) { - connection := compileWaitTestConnection(t) - callCount := 0 - deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { - callCount++ - if callCount == 1 { - return compileStatusResponse{IsCompiling: true}, nil - } - return compileStatusResponse{}, fmt.Errorf("status poll failed") - }) - probe := attachCompileWaitFocusProbe(&deps) - - _, completed, _, err := waitForCompileCompletionWithDeps(context.Background(), compileCompletionOptions{ - connection: connection, - requestID: "compile_start_stall_activity", - timeout: 200 * time.Millisecond, - pollInterval: 5 * time.Millisecond, - }, deps) - if err != nil { - t.Fatalf("waitForCompileCompletion failed: %v", err) - } - if completed { - t.Fatal("compile wait should time out after activity then silence") - } - if probe.focusCount != 0 { - t.Fatalf("focus attempts mismatch: got %d want 0", probe.focusCount) - } -} - -// Verifies probe errors alone past the threshold still focus Unity once. -func TestWaitForCompileCompletionFocusesOnceWhenProbesKeepFailing(t *testing.T) { - connection := compileWaitTestConnection(t) - deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { - return compileStatusResponse{}, fmt.Errorf("status probe timeout") - }) - probe := attachCompileWaitFocusProbe(&deps) - - _, completed, _, err := waitForCompileCompletionWithDeps(context.Background(), compileCompletionOptions{ - connection: connection, - requestID: "compile_start_stall_probe_error", - timeout: 200 * time.Millisecond, - pollInterval: 5 * time.Millisecond, - }, deps) - if err != nil { - t.Fatalf("waitForCompileCompletion failed: %v", err) - } - if completed { - t.Fatal("probe-error compile wait should time out") - } - if probe.focusCount != 1 { - t.Fatalf("focus attempts mismatch: got %d want 1", probe.focusCount) - } -} - -// Verifies a compile wait that focused Unity restores the previous front window on completion. -func TestWaitForCompileCompletionRestoresFocusOnCompletion(t *testing.T) { - connection := compileWaitTestConnection(t) - callCount := 0 - deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { - callCount++ - if callCount < 8 { - return compileStatusResponse{}, nil - } - return compileStatusResponse{ - Ready: true, - HasResult: true, - Result: json.RawMessage(`{"Success":true}`), - }, nil - }) - probe := attachCompileWaitFocusProbe(&deps) - - result, completed, _, err := waitForCompileCompletionWithDeps(context.Background(), compileCompletionOptions{ - connection: connection, - requestID: "compile_start_stall_restore", - timeout: 200 * time.Millisecond, - pollInterval: 5 * time.Millisecond, - }, deps) - if err != nil { - t.Fatalf("waitForCompileCompletion failed: %v", err) - } - if !completed { - t.Fatal("compile wait should complete after the stall") - } - if string(result) != `{"Success":true}` { - t.Fatalf("result mismatch: %s", result) - } - if probe.focusCount != 1 { - t.Fatalf("focus attempts mismatch: got %d want 1", probe.focusCount) - } - if probe.restoreCount != 1 { - t.Fatalf("restore calls mismatch: got %d want 1", probe.restoreCount) - } -} - -// Verifies a status probe that returns after the wait deadline does not focus Unity. -func TestWaitForCompileCompletionDoesNotFocusAfterDeadline(t *testing.T) { - connection := compileWaitTestConnection(t) - deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { - time.Sleep(250 * time.Millisecond) - return compileStatusResponse{}, nil - }) - probe := attachCompileWaitFocusProbe(&deps) - - _, completed, _, err := waitForCompileCompletionWithDeps(context.Background(), compileCompletionOptions{ - connection: connection, - requestID: "compile_start_stall_after_deadline", - timeout: 200 * time.Millisecond, - pollInterval: 5 * time.Millisecond, - }, deps) - if err != nil { - t.Fatalf("waitForCompileCompletion failed: %v", err) - } - if completed { - t.Fatal("deadline-crossing probe should time out") - } - if probe.focusCount != 0 { - t.Fatalf("focus attempts mismatch: got %d want 0", probe.focusCount) - } -} - -// Verifies compile activity is any of IsCompiling, IsUpdating, domain reload, or HasResult, -// and that Ready alone does not count. -func TestCompileActivityHasStarted(t *testing.T) { - cases := []struct { - name string - status compileStatusResponse - want bool - }{ - {name: "all false", status: compileStatusResponse{}, want: false}, - {name: "IsCompiling", status: compileStatusResponse{IsCompiling: true}, want: true}, - {name: "IsUpdating", status: compileStatusResponse{IsUpdating: true}, want: true}, - {name: "IsDomainReloadInProgress", status: compileStatusResponse{IsDomainReloadInProgress: true}, want: true}, - {name: "HasResult", status: compileStatusResponse{HasResult: true}, want: true}, - {name: "Ready alone is not activity", status: compileStatusResponse{Ready: true}, want: false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := compileActivityHasStarted(tc.status) - if got != tc.want { - t.Fatalf("compileActivityHasStarted mismatch: got %v want %v status=%#v", got, tc.want, tc.status) - } - }) - } -} - // assertCompileWaitTimeoutEnvelopeDetails checks the run/attach wiring that passes // lastStatus and WaitedMs into the COMPILE_WAIT_TIMEOUT stderr envelope. func assertCompileWaitTimeoutEnvelopeDetails(t *testing.T, stderr []byte, wantCompiling bool) { @@ -1180,28 +996,6 @@ func compileWaitTestDeps( } } -type compileWaitFocusProbe struct { - focusCount int - restoreCount int -} - -func attachCompileWaitFocusProbe(deps *compileWaitDeps) *compileWaitFocusProbe { - probe := &compileWaitFocusProbe{} - deps.startStallFocusThreshold = 20 * time.Millisecond - deps.focus = defaultConnectionRetryDeps() - deps.focus.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { - return &clicore.UnityProcess{Pid: 4242}, nil - } - deps.focus.focusUnityProcess = func(context.Context, int) (clicore.RestoreFocusFunc, error) { - probe.focusCount++ - return func(context.Context) error { - probe.restoreCount++ - return nil - }, nil - } - return probe -} - func vibeLogContextString(t *testing.T, entry map[string]any, key string) string { t.Helper() contextMap, ok := entry["context"].(map[string]any) diff --git a/cli/project-runner/internal/projectrunner/connection_retry.go b/cli/project-runner/internal/projectrunner/connection_retry.go index 494c68f808..4d5929e6e7 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry.go +++ b/cli/project-runner/internal/projectrunner/connection_retry.go @@ -52,7 +52,6 @@ const ( focusReasonHeartbeatSilenceTimeout connectionRetryFocusReason = "heartbeat_silence_timeout" focusReasonFinalResponseTimeout connectionRetryFocusReason = "final_response_timeout" focusReasonBusyStall connectionRetryFocusReason = "busy_stall" - focusReasonCompileStartStall connectionRetryFocusReason = "compile_start_stall" ) // Why: domain reload on large projects can keep the IPC endpoint down well past the diff --git a/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md b/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md index 65c3fc4622..1817d2a0bb 100644 --- a/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md +++ b/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md @@ -68,6 +68,7 @@ retains it before its own autorelease pool drains and releases it after `endActi - Windows and Linux have not been investigated; they hold nothing. - A Begin and End pair costs about 4 µs inside the Editor (median of 100 pairs; the slowest took 0.11 ms). +- `uloop compile` no longer brings the Editor to the front when compilation has not started within ten seconds. That rescue (#2341) existed for this suppression; with the activity held, a compile that has not started is a compile the Editor has not reached yet, and bringing it to the front only moves the user's windows. ## Reversal condition From 2061e4f63843a6026c93a3dd3f302228ae74203b Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 00:17:09 +0900 Subject: [PATCH 03/29] fix: Pause point timeout details carry the wait's timeout and the marker id, and busy vibe entries name the running tool and the resent requests (#3229) --- .../projectrunner/hot_reload_busy_wait.go | 36 +++++++------ .../hot_reload_busy_wait_test.go | 15 +++++- .../projectrunner/pause_point_errors.go | 32 ++++++----- .../projectrunner/pause_point_errors_test.go | 53 +++++++++++++++++++ .../projectrunner/pause_point_wait_test.go | 10 ++-- .../internal/projectrunner/plain_tool_log.go | 23 +++++++- .../projectrunner/plain_tool_log_test.go | 32 +++++++++++ docs/vibe-logs.md | 4 +- 8 files changed, 170 insertions(+), 35 deletions(-) diff --git a/cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go b/cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go index 4f3f6b490d..154d59626a 100644 --- a/cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go +++ b/cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go @@ -113,6 +113,8 @@ type hotReloadBusyWait struct { outcome unityipc.UnitySendOutcome sendErr error resends int + // resendCorrelationIDs holds the correlation id of every resent request, in order. + resendCorrelationIDs []string } // waitForBusyEditor polls the Editor status until it is ready, the budget runs out, or ctx is @@ -143,6 +145,7 @@ func waitForBusyEditor( answer, outcome, err := sendPlainTool(ctx, connection, hotReloadCommandName, params, stderr, deps) lastResend = time.Now() wait.resends++ + wait.resendCorrelationIDs = append(wait.resendCorrelationIDs, answer.correlationID) if !isUnityServerBusyRPCError(err) { wait.waited, wait.ready, wait.answered = time.Since(startedAt), err == nil, true wait.answer, wait.outcome, wait.sendErr = answer, outcome, err @@ -171,17 +174,11 @@ func isHeldByExecuteDynamicCode(status editorStatusResponse) bool { // hotReloadBusyRunningToolName names the command that held the Editor, from the busy answer's data. func hotReloadBusyRunningToolName(err error) string { - var rpcErr *unityipc.RPCError - if !errors.As(err, &rpcErr) { + name, ok := serverBusyRunningToolName(err) + if !ok { return hotReloadBusyUnknownToolName } - data := struct { - RunningToolName string `json:"runningToolName"` - }{} - if json.Unmarshal(rpcErr.Data, &data) != nil || data.RunningToolName == "" { - return hotReloadBusyUnknownToolName - } - return data.RunningToolName + return name } func hotReloadBusyWaitNote(runningToolName string, waited time.Duration, ready bool) string { @@ -242,6 +239,14 @@ func logHotReloadBusyWaitDecided(connection unityipc.Connection, correlationID s }) } +// Why never nil: a nil slice is written as null, and an empty wait should read as []. +func resendCorrelationIDsOrEmpty(wait hotReloadBusyWait) []string { + if wait.resendCorrelationIDs == nil { + return []string{} + } + return wait.resendCorrelationIDs +} + // Written once on every way out of a wait that started. second is nil when no request was sent // after the wait. func logHotReloadBusyWaitComplete( @@ -252,12 +257,13 @@ func logHotReloadBusyWaitComplete( ) { writePlainToolVibeLog(connection.ProjectRoot, func() vibelog.CLIVibeLogEntry { entryContext := map[string]any{ - "correlation_id": correlationID, - "second_correlation_id": "", - "waited_ms": wait.waited.Milliseconds(), - "ready": wait.ready, - "resends": wait.resends, - "second_result": false, + "correlation_id": correlationID, + "second_correlation_id": "", + "waited_ms": wait.waited.Milliseconds(), + "ready": wait.ready, + "resends": wait.resends, + "resend_correlation_ids": resendCorrelationIDsOrEmpty(wait), + "second_result": false, } if second != nil { entryContext["second_correlation_id"] = second.correlationID diff --git a/cli/project-runner/internal/projectrunner/hot_reload_busy_wait_test.go b/cli/project-runner/internal/projectrunner/hot_reload_busy_wait_test.go index 94b7080513..c21fd4f1a6 100644 --- a/cli/project-runner/internal/projectrunner/hot_reload_busy_wait_test.go +++ b/cli/project-runner/internal/projectrunner/hot_reload_busy_wait_test.go @@ -332,7 +332,10 @@ func TestRunHotReloadWritesBusyWaitVibeLogs(t *testing.T) { if len(failed) == 0 { t.Fatalf("cli_tool_request_failed entries = 0, want the busy answer\n%s", logContent) } - assertCliVibeContextValues(t, cliVibeEntryContext(t, failed[0]), map[string]any{"error_kind": "rpc:server_busy"}) + assertCliVibeContextValues(t, cliVibeEntryContext(t, failed[0]), map[string]any{ + "error_kind": "rpc:server_busy", + "running_tool_name": "compile", + }) decided := singleCliVibeEntry(t, logContent, hotReloadBusyWaitDecidedOperation) assertSharedCliVibeCorrelationID(t, sent[0], decided) @@ -357,6 +360,9 @@ func TestRunHotReloadWritesBusyWaitVibeLogs(t *testing.T) { "second_success": true, "second_outcome": "Applied", }) + if resent := completeContext["resend_correlation_ids"]; !reflect.DeepEqual(resent, []any{}) { + t.Fatalf("resend_correlation_ids = %#v, want an empty array", resent) + } if _, isNumber := completeContext["waited_ms"].(float64); !isNumber { t.Fatalf("waited_ms = %#v, want a number", completeContext["waited_ms"]) } @@ -562,6 +568,13 @@ func TestRunHotReloadSendsAgainWhileACancelledExecuteDynamicCodeHoldsTheEditor(t "ready": true, "second_correlation_id": vibeLogContextString(t, sent[2], "correlation_id"), }) + wantResent := []any{ + vibeLogContextString(t, sent[1], "correlation_id"), + vibeLogContextString(t, sent[2], "correlation_id"), + } + if resent := complete["resend_correlation_ids"]; !reflect.DeepEqual(resent, wantResent) { + t.Fatalf("resend_correlation_ids = %#v, want %#v", resent, wantResent) + } } // Verifies no request is sent again before the resend interval has passed, even while diff --git a/cli/project-runner/internal/projectrunner/pause_point_errors.go b/cli/project-runner/internal/projectrunner/pause_point_errors.go index 5171eeb39b..526239070b 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_errors.go +++ b/cli/project-runner/internal/projectrunner/pause_point_errors.go @@ -405,15 +405,16 @@ func pausePointStateErrorDetails( response pausePointStatusResponse, ) map[string]any { details := map[string]any{ - "Id": options.id, - "Status": response.Status, - "Expired": response.Expired, - "HitCount": response.HitCount, - "MethodEntryCount": response.MethodEntryCount, - "HitWhen": response.HitWhen, - "HitWhenSkippedCount": response.HitWhenSkippedCount, - "HitWhenErrorNote": response.HitWhenErrorNote, - "TimeoutSeconds": pausePointMarkerTimeoutSeconds(options, response), + "Id": pausePointDetailsID(options, response), + "Status": response.Status, + "Expired": response.Expired, + "HitCount": response.HitCount, + "MethodEntryCount": response.MethodEntryCount, + "HitWhen": response.HitWhen, + "HitWhenSkippedCount": response.HitWhenSkippedCount, + "HitWhenErrorNote": response.HitWhenErrorNote, + // Why the wait's value: it is what the Message quotes ("not hit within Ns"). + "TimeoutSeconds": options.timeoutSeconds, "EnabledAtUtc": response.EnabledAtUtc, "ElapsedSinceEnabledMilliseconds": response.ElapsedSinceEnabledMilliseconds, "Generation": response.Generation, @@ -423,6 +424,9 @@ func pausePointStateErrorDetails( "RecommendedNextAction": response.RecommendedNextAction, "SuppressedByHotReload": response.SuppressedByHotReload, } + if response.TimeoutSeconds > 0 { + details["MarkerTimeoutSeconds"] = response.TimeoutSeconds + } if response.ClearedReason != "" { details["ClearedReason"] = response.ClearedReason } @@ -435,11 +439,13 @@ func pausePointStateErrorDetails( return details } -func pausePointMarkerTimeoutSeconds(options waitForPausePointOptions, response pausePointStatusResponse) int { - if response.TimeoutSeconds > 0 { - return response.TimeoutSeconds +// pausePointDetailsID prefers the status answer's marker id, because the Editor +// normalizes it and it then matches the Id that the status command reports. +func pausePointDetailsID(options waitForPausePointOptions, response pausePointStatusResponse) string { + if response.Id != "" { + return response.Id } - return options.timeoutSeconds + return options.id } func pausePointRemainingMilliseconds(options waitForPausePointOptions, response pausePointStatusResponse) int64 { diff --git a/cli/project-runner/internal/projectrunner/pause_point_errors_test.go b/cli/project-runner/internal/projectrunner/pause_point_errors_test.go index 66497bc4f6..826091bd6c 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_errors_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_errors_test.go @@ -169,6 +169,59 @@ func TestPausePointStateError_DetailsIncludeSuppressedByHotReload(t *testing.T) } } +// Verifies a failed await reports its own --timeout-seconds as TimeoutSeconds, the +// marker's window as MarkerTimeoutSeconds, and the Editor's normalized marker id. +func TestPausePointStateErrorDetailsCarryTheWaitTimeoutAndTheMarkerId(t *testing.T) { + options := waitForPausePointOptions{id: "./Assets/Foo.cs:42", timeoutSeconds: 5} + response := pausePointStatusResponse{ + Id: "Assets/Foo.cs:42", + Status: pausePointStatusEnabled, + TimeoutSeconds: 30, + } + + err := pausePointStateError( + "PAUSE_POINT_WAIT_TIMEOUT", + "Pause point was not hit within 5s.", + "/tmp/project", + options, + response, + true) + + if got := err.Details["TimeoutSeconds"]; got != 5 { + t.Fatalf("TimeoutSeconds = %v, want 5", got) + } + if got := err.Details["MarkerTimeoutSeconds"]; got != 30 { + t.Fatalf("MarkerTimeoutSeconds = %v, want 30", got) + } + if got := err.Details["Id"]; got != "Assets/Foo.cs:42" { + t.Fatalf("Id = %v, want Assets/Foo.cs:42", got) + } +} + +// Verifies a failed await with no status answer keeps the typed id and omits the +// marker window instead of reporting 0. +func TestPausePointStateErrorDetailsFallBackToTheTypedIdWithoutAStatus(t *testing.T) { + options := waitForPausePointOptions{id: "./Assets/Foo.cs:42", timeoutSeconds: 5} + + err := pausePointStateError( + "PAUSE_POINT_WAIT_TIMEOUT", + "Pause point was not hit within 5s.", + "/tmp/project", + options, + pausePointStatusResponse{}, + true) + + if got := err.Details["Id"]; got != "./Assets/Foo.cs:42" { + t.Fatalf("Id = %v, want ./Assets/Foo.cs:42", got) + } + if got := err.Details["TimeoutSeconds"]; got != 5 { + t.Fatalf("TimeoutSeconds = %v, want 5", got) + } + if got, ok := err.Details["MarkerTimeoutSeconds"]; ok { + t.Fatalf("MarkerTimeoutSeconds = %v, want the key omitted", got) + } +} + // Verifies await failures expose the hit-when state that distinguishes skipped // conditional captures from a line that never ran. func TestPausePointStateErrorDetailsIncludeHitWhenDiagnostics(t *testing.T) { diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go index 707cd30bf3..5517c31a5b 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go @@ -685,7 +685,8 @@ func TestPausePointExpiredErrorUsesFileLineNextActions(t *testing.T) { } } -// Verifies recovery details use the marker lifetime instead of the wait deadline. +// Verifies recovery details carry the marker lifetime as MarkerTimeoutSeconds while +// TimeoutSeconds stays the wait's own --timeout-seconds. func TestPausePointExpiredErrorReportsMarkerTimeoutSeconds(t *testing.T) { response := pausePointStatusResponse{ Id: "jump", @@ -700,8 +701,11 @@ func TestPausePointExpiredErrorReportsMarkerTimeoutSeconds(t *testing.T) { timeoutSeconds: 5, }, response, pausePointWaitStateExpired, false, false, nil) - if cliErr.Details["TimeoutSeconds"] != 30 { - t.Fatalf("timeoutSeconds detail mismatch: %#v", cliErr.Details) + if cliErr.Details["MarkerTimeoutSeconds"] != 30 { + t.Fatalf("MarkerTimeoutSeconds detail mismatch: %#v", cliErr.Details) + } + if cliErr.Details["TimeoutSeconds"] != 5 { + t.Fatalf("TimeoutSeconds detail mismatch: %#v", cliErr.Details) } } diff --git a/cli/project-runner/internal/projectrunner/plain_tool_log.go b/cli/project-runner/internal/projectrunner/plain_tool_log.go index 7617092383..42e434d51e 100644 --- a/cli/project-runner/internal/projectrunner/plain_tool_log.go +++ b/cli/project-runner/internal/projectrunner/plain_tool_log.go @@ -5,6 +5,7 @@ package projectrunner // never a parameter value or a response body, which can name files of the project or carry code. import ( + "encoding/json" "errors" "sort" "time" @@ -90,7 +91,7 @@ func logPlainToolRequestFailed( writePlainToolVibeLog(connection.ProjectRoot, func() vibelog.CLIVibeLogEntry { // Why no err.Error(): a Unity parameter-validation RPC error quotes the raw parameter values // in its message, and this log must never carry parameter values. - return vibelog.CLIVibeLogEntry{ + entry := vibelog.CLIVibeLogEntry{ Level: "ERROR", Operation: plainToolRequestFailedOperation, Message: "The tool request to Unity failed.", @@ -103,9 +104,29 @@ func logPlainToolRequestFailed( }, CorrelationID: correlationID, } + if name, ok := serverBusyRunningToolName(err); ok { + entry.Context["running_tool_name"] = name + } + return entry }) } +// serverBusyRunningToolName reads the name of the tool that held the Editor from a busy answer's +// data. It reports false for any other error, and for a busy answer that names no tool. +func serverBusyRunningToolName(err error) (string, bool) { + var rpcErr *unityipc.RPCError + if !errors.As(err, &rpcErr) || clierrors.RPCDataType(rpcErr.Data) != "server_busy" { + return "", false + } + data := struct { + RunningToolName string `json:"runningToolName"` + }{} + if json.Unmarshal(rpcErr.Data, &data) != nil || data.RunningToolName == "" { + return "", false + } + return data.RunningToolName, true +} + // classifyPlainToolError names what failed without any of the error's text: "rpc:" followed by the // type Unity attached to its error (empty when it attached none), "final_response_timeout", or // "other". diff --git a/cli/project-runner/internal/projectrunner/plain_tool_log_test.go b/cli/project-runner/internal/projectrunner/plain_tool_log_test.go index 6e951cb051..6424862e6c 100644 --- a/cli/project-runner/internal/projectrunner/plain_tool_log_test.go +++ b/cli/project-runner/internal/projectrunner/plain_tool_log_test.go @@ -281,3 +281,35 @@ func assertCliVibeLogOmitsTheSentinel(t *testing.T, logContent string) { t.Fatalf("the vibe log must not contain a parameter value or an error message:\n%s", logContent) } } + +// Verifies the failure entry names the tool that held the Editor only for a busy answer that +// carries the name. +func TestLogPlainToolRequestFailedNamesTheRunningToolOnlyForABusyAnswer(t *testing.T) { + cases := []struct { + name string + data string + wantName bool + }{ + {name: "busy with a name", data: `{"type":"server_busy","runningToolName":"compile"}`, wantName: true}, + {name: "busy without a name", data: `{"type":"server_busy"}`}, + {name: "not busy", data: `{"type":"invalid_params","runningToolName":"compile"}`}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + enableCliVibeLog(t) + projectRoot := t.TempDir() + err := &unityipc.RPCError{Code: -32603, Message: "failed", Data: []byte(testCase.data)} + + logPlainToolRequestFailed(unityipc.Connection{ProjectRoot: projectRoot}, "get-logs", "corr-1", 0, + unityipc.UnitySendOutcome{}, err) + + failedContext := cliVibeEntryContext(t, + singleCliVibeEntry(t, readOnlyCliVibeLog(t, projectRoot), "cli_tool_request_failed")) + if testCase.wantName { + assertCliVibeContextValues(t, failedContext, map[string]any{"running_tool_name": "compile"}) + return + } + assertCliVibeContextOmits(t, failedContext, "running_tool_name") + }) + } +} diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index f9457370c6..7dd2488142 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -67,9 +67,9 @@ or `control-play-mode` that waits for a domain reload or a Play Mode change, and |---|---|---| | `cli_tool_request_sent` | before the request is sent | `command`, `correlation_id`, `project_identity`, `cli_version`, `param_keys` (sorted), `array_lengths` (element count of each array parameter) | | `cli_tool_response_received` | when Unity answered | `command`, `correlation_id`, `elapsed_ms`, `request_accepted`, `result_bytes`, `exit_code` | -| `cli_tool_request_failed` (`ERROR`) | when no answer came, or Unity answered with an error | `command`, `correlation_id`, `elapsed_ms`, `request_accepted`, `error_kind` (`rpc:`, `final_response_timeout`, or `other`) | +| `cli_tool_request_failed` (`ERROR`) | when no answer came, or Unity answered with an error | `command`, `correlation_id`, `elapsed_ms`, `request_accepted`, `error_kind` (`rpc:`, `final_response_timeout`, or `other`), `running_tool_name` (only when `error_kind` is `rpc:server_busy` and Unity named the tool that held the Editor) | | `cli_hot_reload_busy_wait_decided` | when the first `hot-reload` answer is `server_busy` (another uloop command holds the Editor; the preceding `cli_tool_request_failed` has `error_kind` `rpc:server_busy`) | `correlation_id` (the first request's), `running_tool_name`, `running_tool_phase`, `running_tool_elapsed_seconds`, `budget_ms`, `resend_interval_ms` | -| `cli_hot_reload_busy_wait_complete` (`WARN` unless the Editor became ready and the request sent after the wait answered) | after the wait and the one request sent after it, on every way out of a wait that started (a cancel, a failed send, and a bad answer included) | `correlation_id` (the first request's), `second_correlation_id` (the request sent after the wait, or empty when none was sent), `waited_ms`, `ready`, `resends` (requests sent again during the wait while a cancelled `execute-dynamic-code` held the Editor; each also has its own `cli_tool_request_sent`), `second_result`, and that answer's `second_success` and `second_outcome` | +| `cli_hot_reload_busy_wait_complete` (`WARN` unless the Editor became ready and the request sent after the wait answered) | after the wait and the one request sent after it, on every way out of a wait that started (a cancel, a failed send, and a bad answer included) | `correlation_id` (the first request's), `second_correlation_id` (the request sent after the wait, or empty when none was sent), `waited_ms`, `ready`, `resends` (requests sent again during the wait while a cancelled `execute-dynamic-code` held the Editor; each also has its own `cli_tool_request_sent`), `resend_correlation_ids` (the `correlation_id` of every request sent again during the wait, in order; its length is `resends`, and its last entry equals `second_correlation_id` when a resent request got in), `second_result`, and that answer's `second_success` and `second_outcome` | | `cli_hot_reload_editor_ready_retry_decided` | after every `hot-reload` answer (the one after a busy wait, when one ran), before the fallback decision | `correlation_id` (the request's), `requested`, `parse_error` | | `cli_hot_reload_editor_ready_retry_complete` (`WARN` unless the Editor settled and the second apply answered) | after the wait and the second apply, when a retry was requested | `correlation_id` (the first request's), `second_correlation_id` (the second request's, or empty when none was sent), `waited_ms`, `ready`, `second_result`, and the second answer's `second_success` and `second_outcome` | | `cli_hot_reload_compile_fallback_decided` | after every `hot-reload` answer the fallback decision sees: the second one after an editor-ready retry, and none when that retry ended the command (see `cli_hot_reload_editor_ready_retry_complete`) | `correlation_id` (the request's), `requested`, `parse_error`, and the answer's `success`, `outcome`, `warnings_count`, and `timing` (numbers only) | From f3daae7e669d0702391feabebf8da19acd7c918c Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 00:46:22 +0900 Subject: [PATCH 04/29] feat: Hot reload writes a timing detail vibe entry that breaks down the time outside the response's phases (#3230) --- .../HotReload/HotReloadRunTimingTests.cs | 72 +++++ .../HotReloadTimingDetailPayloadTests.cs | 79 ++++++ .../HotReloadTimingDetailPayloadTests.cs.meta | 11 + .../HotReload/HotReloadGroupFileLists.cs | 37 +++ .../HotReload/HotReloadGroupFileLists.cs.meta | 11 + .../HotReload/HotReloadGroupProcessor.cs | 250 ++++++++++-------- .../HotReload/HotReloadOrchestrator.cs | 152 +++++++---- .../HotReload/HotReloadOrchestratorLog.cs | 22 ++ .../HotReload/HotReloadRunTiming.cs | 62 +++++ .../HotReload/HotReloadTimingDetailPayload.cs | 77 ++++++ .../HotReloadTimingDetailPayload.cs.meta | 11 + .../HotReload/HotReloadTimingDetailStep.cs | 18 ++ .../HotReloadTimingDetailStep.cs.meta | 11 + .../HotReload/Shared/HotReloadConstants.cs | 22 ++ docs/vibe-logs.md | 4 + 15 files changed, 677 insertions(+), 162 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadTimingDetailPayloadTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadTimingDetailPayloadTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupFileLists.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupFileLists.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailPayload.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailPayload.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailStep.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailStep.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadRunTimingTests.cs b/Assets/Tests/Editor/HotReload/HotReloadRunTimingTests.cs index 3e561ef3c3..8d08540840 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadRunTimingTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadRunTimingTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using NUnit.Framework; @@ -113,5 +114,76 @@ public void Complete_TotalBelowThePhases_Throws() Assert.Throws(() => timing.Complete(329)); } + + /// + /// What: a step measured twice in one run, such as once per group, adds up under one entry, + /// and the steps keep the order they were first seen in. + /// + [Test] + public void AddDetail_SameStepTwice_SumsAndKeepsFirstSeenOrder() + { + HotReloadRunTiming timing = new HotReloadRunTiming(); + + timing.AddDetail("plan", 3); + timing.AddDetail("resolve_inputs", 5); + timing.AddDetail("plan", 4); + + Assert.That(timing.Details, Is.EqualTo(new[] + { + new KeyValuePair("plan", 7), + new KeyValuePair("resolve_inputs", 5), + })); + } + + /// + /// What: a negative step time is refused instead of shrinking the step's sum. + /// + [Test] + public void AddDetail_NegativeMilliseconds_Throws() + { + HotReloadRunTiming timing = new HotReloadRunTiming(); + + Assert.Throws(() => timing.AddDetail("plan", -1)); + } + + /// + /// What: a step without a name is refused, whether the name is empty or null. + /// + [Test] + public void AddDetail_EmptyStep_Throws() + { + HotReloadRunTiming timing = new HotReloadRunTiming(); + + Assert.Throws(() => timing.AddDetail(string.Empty, 1)); + Assert.Throws(() => timing.AddDetail(null, 1)); + } + + /// + /// What: disposing a measured scope adds its elapsed time to the step. + /// + [Test] + public void MeasureDetail_AddsTheElapsedOnDispose() + { + HotReloadRunTiming timing = new HotReloadRunTiming(); + + using (timing.MeasureDetail("plan")) + { + } + + Assert.That(timing.Details.Count, Is.EqualTo(1)); + Assert.That(timing.Details[0].Key, Is.EqualTo("plan")); + Assert.That(timing.Details[0].Value, Is.GreaterThanOrEqualTo(0)); + } + + /// + /// What: a run that measured no step reports no steps. + /// + [Test] + public void Details_WithoutAnyStep_IsEmpty() + { + HotReloadRunTiming timing = new HotReloadRunTiming(); + + Assert.That(timing.Details, Is.Empty); + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadTimingDetailPayloadTests.cs b/Assets/Tests/Editor/HotReload/HotReloadTimingDetailPayloadTests.cs new file mode 100644 index 0000000000..fc0a80c072 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadTimingDetailPayloadTests.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Tests the timing detail entry that breaks down the time outside an apply run's phases. + /// + public sealed class HotReloadTimingDetailPayloadTests + { + /// + /// What: the entry carries each phase, the steps in order, and the part of the time outside + /// the phases that no step covers. + /// + [Test] + public void Build_ReportsThePhasesTheStepsAndTheUnaccountedRest() + { + HotReloadTimingBreakdown breakdown = new HotReloadTimingBreakdown(100, 50, 10, 1000); + List> details = new List> + { + new KeyValuePair("plan", 300), + new KeyValuePair("sibling_detect", 400), + }; + + HotReloadTimingDetailPayload payload = HotReloadTimingDetailPayload.Build(breakdown, details, 2); + + Assert.That(payload.TotalMs, Is.EqualTo(1000)); + Assert.That(payload.AnalysisMs, Is.EqualTo(100)); + Assert.That(payload.ShimCompileMs, Is.EqualTo(50)); + Assert.That(payload.PatchMs, Is.EqualTo(10)); + Assert.That(payload.OtherMs, Is.EqualTo(840)); + Assert.That(payload.UnaccountedMs, Is.EqualTo(140)); + Assert.That(payload.GroupCount, Is.EqualTo(2)); + Assert.That(payload.Steps.Count, Is.EqualTo(2)); + Assert.That(payload.Steps[0].Step, Is.EqualTo("plan")); + Assert.That(payload.Steps[0].Ms, Is.EqualTo(300)); + Assert.That(payload.Steps[1].Step, Is.EqualTo("sibling_detect")); + Assert.That(payload.Steps[1].Ms, Is.EqualTo(400)); + } + + /// + /// What: steps that add up to more than the time outside the phases report a negative + /// unaccounted time as it is, because that is the sign that a step overlaps a phase. + /// + [Test] + public void Build_StepsOverlappingAPhase_ReportsANegativeUnaccounted() + { + HotReloadTimingBreakdown breakdown = new HotReloadTimingBreakdown(100, 50, 10, 1000); + List> details = new List> + { + new KeyValuePair("plan", 900), + }; + + HotReloadTimingDetailPayload payload = HotReloadTimingDetailPayload.Build(breakdown, details, 1); + + Assert.That(payload.UnaccountedMs, Is.EqualTo(-60)); + } + + /// + /// What: a run that measured no step leaves all of the time outside the phases unaccounted. + /// + [Test] + public void Build_WithoutSteps_ReportsOtherAsUnaccounted() + { + HotReloadTimingBreakdown breakdown = new HotReloadTimingBreakdown(100, 50, 10, 1000); + + HotReloadTimingDetailPayload payload = HotReloadTimingDetailPayload.Build( + breakdown, + new List>(), + 0); + + Assert.That(payload.UnaccountedMs, Is.EqualTo(payload.OtherMs)); + Assert.That(payload.Steps, Is.Empty); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadTimingDetailPayloadTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadTimingDetailPayloadTests.cs.meta new file mode 100644 index 0000000000..896ed30a12 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadTimingDetailPayloadTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5501d2b14655347609732859c8dc3434 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupFileLists.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupFileLists.cs new file mode 100644 index 0000000000..ec3d694f74 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupFileLists.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Lists that a group processor derives from the files of one group. + /// + internal static class HotReloadGroupFileLists + { + // Why a file that declares a new type never counts as enum-only: its artifact is prepared + // against the whole group, and leaving its owner out would drop it from the commit. + internal static List DescribeLeaveOutFiles(IReadOnlyList files) + { + List leaveOutFiles = new List(files.Count); + foreach (HotReloadGroupFile file in files) + { + leaveOutFiles.Add(new HotReloadLeaveOutFile( + file.ProjectRelativePath, + file.IsDefaultSelected, + file.DeclaresIntroducedType || file.DeclaresRefusedIntroducedType)); + } + + return leaveOutFiles; + } + + internal static List CollectProjectRelativePaths(IReadOnlyList files) + { + List projectRelativePaths = new List(files.Count); + foreach (HotReloadGroupFile file in files) + { + projectRelativePaths.Add(file.ProjectRelativePath); + } + + return projectRelativePaths; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupFileLists.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupFileLists.cs.meta new file mode 100644 index 0000000000..b9a69bdcb1 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupFileLists.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c4b48e49fb28b4f4bb7e31b0b9d0e2d6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs index ff92ac41ed..6919bd10bf 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs @@ -57,35 +57,60 @@ internal async Task> ProcessGroupAsync HotReloadGroupFile firstFile = files[0]; // Application.dataPath and the ledgers require the Unity main thread. - await MainThreadSwitcher.SwitchToMainThread(ct); - if (!_dependencies.ValidateNewSourceMembership(files)) + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepMainThreadSwitch)) { - return _fileEntryApplier.BuildUnappliedGroupResults(files); + await MainThreadSwitcher.SwitchToMainThread(ct); + } + + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepMembershipValidate)) + { + if (!_dependencies.ValidateNewSourceMembership(files)) + { + return _fileEntryApplier.BuildUnappliedGroupResults(files); + } + } + + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepSnapshotGroupState)) + { + SnapshotGroupState(files); } - SnapshotGroupState(files); // Why here and not at the leave-out decision: the ledgers need the main thread, which // the worker await leaves, and nothing changes which files hold changes before the // decision — the domain changes only after it, and a prepared type is not active. - HashSet activePaths = new HotReloadDomainCarriedInLookup(_domain).ListActivePaths(); - - HotReloadChangedSiblingScanResult siblingScan = HotReloadChangedSiblingSourceDetector.Detect( - firstFile.ProjectRoot, - firstFile.AssemblyName, - firstFile.TargetDllPath, - firstFile.CompilationAssembly.sourceFiles, - CollectProjectRelativePaths(files)); + HashSet activePaths; + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepActivePaths)) + { + activePaths = new HotReloadDomainCarriedInLookup(_domain).ListActivePaths(); + } + + HotReloadChangedSiblingScanResult siblingScan; + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepSiblingDetect)) + { + siblingScan = HotReloadChangedSiblingSourceDetector.Detect( + firstFile.ProjectRoot, + firstFile.AssemblyName, + firstFile.TargetDllPath, + firstFile.CompilationAssembly.sourceFiles, + HotReloadGroupFileLists.CollectProjectRelativePaths(files)); + } + if (!string.IsNullOrEmpty(siblingScan.ScanLimitWarning)) { firstFile.Sinks.SiblingDerivedWarnings.Add(siblingScan.ScanLimitWarning); } - TransformWorkerInputDto workerInput = HotReloadGroupWorkerInputBuilder.BuildWorkerInput(files, siblingScan, _domain); - workerInput.introducedTypeArtifacts = HotReloadIntroducedTypeArtifactRecords.CollectActive( - _domain.IntroducedTypes, - workerInput.targetAssemblyName, - workerInput.targetAssemblyMvid, - FindFullyAppliedSourceHash).ToArray(); + TransformWorkerInputDto workerInput; + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepWorkerInput)) + { + workerInput = HotReloadGroupWorkerInputBuilder.BuildWorkerInput(files, siblingScan, _domain); + workerInput.introducedTypeArtifacts = HotReloadIntroducedTypeArtifactRecords.CollectActive( + _domain.IntroducedTypes, + workerInput.targetAssemblyName, + workerInput.targetAssemblyMvid, + FindFullyAppliedSourceHash).ToArray(); + } + Stopwatch preparationWatch = Stopwatch.StartNew(); HotReloadIntroducedTypePreparationResult preparation = await _dependencies .PrepareIntroducedTypes(files, workerInput, ct) @@ -98,36 +123,40 @@ internal async Task> ProcessGroupAsync // declaration bound from a retained artifact introduces nothing, so no artifact of // this run publishes it and a run whose only change is such a declaration never // reaches an activation at all. - HotReloadIntroducedTypeOutcomeSink.Append(files, preparation.AlreadyActiveTypes); - HotReloadIntroducedTypeOutcomeSink.AppendNotices(files, preparation.Notices); - - if (!preparation.Success) + IReadOnlyList refusedTypes; + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepPreparationOutcome)) { - // Why the two failures part ways here: a refused declaration is reported as the - // type it refused, while a preparation that could not run at all refused no - // declaration and stays a run-level failure of every file of the group. - if (preparation.Failures.Count > 0) - { - HotReloadIntroducedTypeOutcomeSink.Append(files, preparation.Failures); - // Why here: the transform run that reports these for a run that continues - // never runs, and an added enum member is a likely cause of the refusal. - HotReloadIntroducedTypeOutcomeSink.AppendDeclarationDriftWarnings( - files, - preparation.DeclarationDriftWarnings); - } - else + HotReloadIntroducedTypeOutcomeSink.Append(files, preparation.AlreadyActiveTypes); + HotReloadIntroducedTypeOutcomeSink.AppendNotices(files, preparation.Notices); + + if (!preparation.Success) { - HotReloadGroupOutcomeRouter.AppendGroupFailure( - files, - "(file)", - HotReloadFailureDescription.Declaration(preparation.ErrorMessage)); + // Why the two failures part ways here: a refused declaration is reported as the + // type it refused, while a preparation that could not run at all refused no + // declaration and stays a run-level failure of every file of the group. + if (preparation.Failures.Count > 0) + { + HotReloadIntroducedTypeOutcomeSink.Append(files, preparation.Failures); + // Why here: the transform run that reports these for a run that continues + // never runs, and an added enum member is a likely cause of the refusal. + HotReloadIntroducedTypeOutcomeSink.AppendDeclarationDriftWarnings( + files, + preparation.DeclarationDriftWarnings); + } + else + { + HotReloadGroupOutcomeRouter.AppendGroupFailure( + files, + "(file)", + HotReloadFailureDescription.Declaration(preparation.ErrorMessage)); + } + + return _fileEntryApplier.BuildUnappliedGroupResults(files); } - return _fileEntryApplier.BuildUnappliedGroupResults(files); + refusedTypes = HotReloadRefusedIntroducedType.CollectFrom(preparation.Notices); } - IReadOnlyList refusedTypes = - HotReloadRefusedIntroducedType.CollectFrom(preparation.Notices); if (preparation.Prepared == null) { return await TransformAndApplyGroupAsync(files, workerInput, null, refusedTypes, activePaths, correlationId, timing, ct) @@ -171,13 +200,16 @@ private async Task> TransformAndApplyP { HotReloadIntroducedTypeArtifact artifact = prepared.Artifact; HotReloadIntroducedTypeRegistry registry = _domain.IntroducedTypes; - List records = - new List(workerInput.introducedTypeArtifacts); - TransformWorkerIntroducedTypeArtifactDto preparedRecord = - HotReloadIntroducedTypeArtifactRecords.CreateRecord(artifact); - preparedRecord.preparedByThisRun = true; - records.Add(preparedRecord); - workerInput.introducedTypeArtifacts = records.ToArray(); + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepWorkerInput)) + { + List records = + new List(workerInput.introducedTypeArtifacts); + TransformWorkerIntroducedTypeArtifactDto preparedRecord = + HotReloadIntroducedTypeArtifactRecords.CreateRecord(artifact); + preparedRecord.preparedByThisRun = true; + records.Add(preparedRecord); + workerInput.introducedTypeArtifacts = records.ToArray(); + } // The scope answers binds for the prepared assembly while nothing has activated it, so // the shim compilation and the reflection it drives can already reach the new types. @@ -238,10 +270,15 @@ private async Task> TransformAndApplyG Debug.Assert( workerOutput.files.Length == files.Count, "A group worker run must return one per-file output per edited file."); - IReadOnlyList leftOutPaths = _leaveOut.FindLeftOutPaths( - DescribeLeaveOutFiles(files), - workerOutput, - activePaths); + IReadOnlyList leftOutPaths; + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepIsolationSplit)) + { + leftOutPaths = _leaveOut.FindLeftOutPaths( + HotReloadGroupFileLists.DescribeLeaveOutFiles(files), + workerOutput, + activePaths); + } + if (leftOutPaths.Count == 0) { return await ApplyTransformedGroupAsync(files, workerInput, workerOutput, prepared, refusedTypes, correlationId, timing, ct) @@ -250,9 +287,16 @@ private async Task> TransformAndApplyG // Why the rerun input first: it refuses a leave-out the sources do not match before // anything is written to the left-out files. - TransformWorkerInputDto retryInput = _leaveOut.BuildRetryInput(workerInput, leftOutPaths); - HotReloadGroupLeaveOutSplit split = new HotReloadGroupLeaveOutSplit(files, leftOutPaths); - List leftOutResults = BuildLeftOutResults(split, files, workerOutput); + TransformWorkerInputDto retryInput; + HotReloadGroupLeaveOutSplit split; + List leftOutResults; + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepIsolationSplit)) + { + retryInput = _leaveOut.BuildRetryInput(workerInput, leftOutPaths); + split = new HotReloadGroupLeaveOutSplit(files, leftOutPaths); + leftOutResults = BuildLeftOutResults(split, files, workerOutput); + } + TransformWorkerClientResult retryResult = await RunWorkerAsync(retryInput, correlationId, timing, ct) .ConfigureAwait(false); IReadOnlyList remainingResults = retryResult.Success @@ -293,22 +337,6 @@ private List FailGroup(IReadOnlyList DescribeLeaveOutFiles(IReadOnlyList files) - { - List leaveOutFiles = new List(files.Count); - foreach (HotReloadGroupFile file in files) - { - leaveOutFiles.Add(new HotReloadLeaveOutFile( - file.ProjectRelativePath, - file.IsDefaultSelected, - file.DeclaresIntroducedType || file.DeclaresRefusedIntroducedType)); - } - - return leaveOutFiles; - } - // Why the first run's rows: the left-out file gets the notices, hash and counts it would // have had in the run, so only the added members of the other files change. private List BuildLeftOutResults( @@ -318,7 +346,7 @@ private List BuildLeftOutResults( { HotReloadWorkerRowsByFile firstRows = HotReloadWorkerRowsByFile.Build( firstOutput, - CollectProjectRelativePaths(files)); + HotReloadGroupFileLists.CollectProjectRelativePaths(files)); HotReloadGroupNotices.AppendPerFileWorkerNotices(split.LeftOutFiles, firstRows); List results = new List(split.LeftOutFiles.Count); foreach (HotReloadGroupFile file in split.LeftOutFiles) @@ -346,15 +374,19 @@ private async Task> ApplyTransformedGr Debug.Assert( workerOutput.files.Length == files.Count, "A group worker run must return one per-file output per edited file."); - HotReloadWorkerRowsByFile rows = HotReloadWorkerRowsByFile.Build( - workerOutput, - CollectProjectRelativePaths(files)); - HotReloadGroupNotices.AppendPerFileWorkerNotices(files, rows); - // Why once for the group: the worker scans the assembly's unedited siblings for const - // drift as a whole, so flowing them per file would repeat the same texts. - if (workerOutput.siblingConstDriftWarnings != null) + HotReloadWorkerRowsByFile rows; + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepWorkerNotices)) { - firstFile.Sinks.SiblingDerivedWarnings.AddRange(workerOutput.siblingConstDriftWarnings); + rows = HotReloadWorkerRowsByFile.Build( + workerOutput, + HotReloadGroupFileLists.CollectProjectRelativePaths(files)); + HotReloadGroupNotices.AppendPerFileWorkerNotices(files, rows); + // Why once for the group: the worker scans the assembly's unedited siblings for const + // drift as a whole, so flowing them per file would repeat the same texts. + if (workerOutput.siblingConstDriftWarnings != null) + { + firstFile.Sinks.SiblingDerivedWarnings.AddRange(workerOutput.siblingConstDriftWarnings); + } } // Why before the empty-entries return: all-unchanged runs exit there, and those are @@ -366,27 +398,35 @@ private async Task> ApplyTransformedGr // marks a file unapplied runs later, so the only mark a file can carry at this point // is a parse error, and the worker drops a unit with parse errors before the transform // that reports unchanged methods, leaving such a file no row to peel either way. - if (!_collaborators.CommitPolicy.CommitsIntroducedTypes(prepared, files[0].AssemblyName) - && !await RevalidateBeforeRevertAsync( - files, - ct, - () => _entryApplier.RevertUnchangedPatchesPerFile(files, rows)).ConfigureAwait(false)) + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepRevalidateBeforeRevert)) { - return _fileEntryApplier.BuildUnappliedGroupResults(files); + if (!_collaborators.CommitPolicy.CommitsIntroducedTypes(prepared, files[0].AssemblyName) + && !await RevalidateBeforeRevertAsync( + files, + ct, + () => _entryApplier.RevertUnchangedPatchesPerFile(files, rows)).ConfigureAwait(false)) + { + return _fileEntryApplier.BuildUnappliedGroupResults(files); + } + } + + HotReloadApplyContext context; + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepApplyContext)) + { + context = new HotReloadApplyContext( + firstFile.ProjectRoot, + firstFile.AssemblyName, + correlationId, + firstFile.CompilationAssembly, + firstFile.Home, + firstFile.CompilationAssembly.defines ?? Array.Empty(), + workerInput, + workerOutput, + files, + prepared, + new HotReloadCompileFailureNoteSources(workerOutput.skipped, refusedTypes)); } - HotReloadApplyContext context = new HotReloadApplyContext( - firstFile.ProjectRoot, - firstFile.AssemblyName, - correlationId, - firstFile.CompilationAssembly, - firstFile.Home, - firstFile.CompilationAssembly.defines ?? Array.Empty(), - workerInput, - workerOutput, - files, - prepared, - new HotReloadCompileFailureNoteSources(workerOutput.skipped, refusedTypes)); Stopwatch gateWatch = Stopwatch.StartNew(); HotReloadGroupGateAndCompileResult gateAndCompile = await _dependencies .GateAndCompile(context, ct) @@ -594,17 +634,5 @@ private void SnapshotGroupState(IReadOnlyList files) : HotReloadSnapshotMissReason.None; } } - - private static List CollectProjectRelativePaths(IReadOnlyList files) - { - List projectRelativePaths = new List(files.Count); - foreach (HotReloadGroupFile file in files) - { - projectRelativePaths.Add(file.ProjectRelativePath); - } - - return projectRelativePaths; - } - } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs index cb3217fa31..d4769eace9 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs @@ -83,12 +83,20 @@ public async Task RunAsync( using IDisposable callSiteCacheHold = HotReloadCompiledCallSiteCache.Shared.HoldEntriesForRun(); HotReloadRunTiming timing = new HotReloadRunTiming(); - // CompilationPipeline / Application.dataPath require the Unity main thread, and the - // groups cannot be planned before every file knows which assembly it compiles into. - await MainThreadSwitcher.SwitchToMainThread(ct); - // Why after the switch: PackageInfo is main-thread only, and script paths are - // normalized against these roots later on the background threads this run switches to. - _packageRootCapture.CaptureCurrent(); + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepMainThreadSwitch)) + { + // CompilationPipeline / Application.dataPath require the Unity main thread, and the + // groups cannot be planned before every file knows which assembly it compiles into. + await MainThreadSwitcher.SwitchToMainThread(ct); + } + + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepPackageRoots)) + { + // Why after the switch: PackageInfo is main-thread only, and script paths are + // normalized against these roots later on the background threads this run switches to. + _packageRootCapture.CaptureCurrent(); + } + // Why after the switch: the accumulator has to read the Auto Refresh hold flag out of // SessionState, which is a main-thread API. HotReloadRunAccumulator run = @@ -97,6 +105,7 @@ public async Task RunAsync( _patcher, _unityMessageForwarding, HotReloadAutoRefreshHold.IsHeld); + HotReloadInputResolutionSlot[] slots = new HotReloadInputResolutionSlot[files.Count]; for (int index = 0; index < slots.Length; index++) { @@ -105,37 +114,45 @@ public async Task RunAsync( List<(int InputIndex, string AssemblyName, string ProjectRelativePath)> plannerInput = new List<(int InputIndex, string AssemblyName, string ProjectRelativePath)>(); - for (int index = 0; index < files.Count; index++) + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepResolveInputs)) { - ct.ThrowIfCancellationRequested(); - _inputFileResolver.ResolveInputFile( - files[index], - index, - contentPathOverride, - contentPathOverrideByFile, - correlationId, - run, - slots[index], - plannerInput); + for (int index = 0; index < files.Count; index++) + { + ct.ThrowIfCancellationRequested(); + _inputFileResolver.ResolveInputFile( + files[index], + index, + contentPathOverride, + contentPathOverrideByFile, + correlationId, + run, + slots[index], + plannerInput); + } } - MarkDefaultSelectedInputs(slots, isDefaultSelection); - IReadOnlyList plans = HotReloadFileGroupPlanner.Plan(plannerInput); + IReadOnlyList plans; HashSet pathsInRun = new HashSet( HotReloadSourcePathNormalizer.ProjectRelativePathComparer()); - for (int pathIndex = 0; pathIndex < slots.Length; pathIndex++) + IReadOnlyList classifiedPlans; + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepPlan)) { - if (!string.IsNullOrEmpty(slots[pathIndex].ResultPath)) + MarkDefaultSelectedInputs(slots, isDefaultSelection); + plans = HotReloadFileGroupPlanner.Plan(plannerInput); + for (int pathIndex = 0; pathIndex < slots.Length; pathIndex++) { - pathsInRun.Add(slots[pathIndex].ResultPath); + if (!string.IsNullOrEmpty(slots[pathIndex].ResultPath)) + { + pathsInRun.Add(slots[pathIndex].ResultPath); + } } - } - IReadOnlyList classifiedPlans = - _deferredInputClassifier.ClassifyAllDeferredPlans(plans, slots); + classifiedPlans = _deferredInputClassifier.ClassifyAllDeferredPlans(plans, slots); + } List<(string Path, HotReloadFileProcessResult Result)> extraResults = new List<(string Path, HotReloadFileProcessResult Result)>(); + int groupCount = 0; for (int planIndex = 0; planIndex < plans.Count; planIndex++) { ct.ThrowIfCancellationRequested(); @@ -145,21 +162,26 @@ public async Task RunAsync( continue; } - bool isLastChangedGroup = _siblingRebindReporter.IsLastChangedPlanForAssembly( - plans, - classifiedPlans, - planIndex); + bool isLastChangedGroup; List inputIndexes = new List(plan.InputIndexes); - if (isLastChangedGroup) + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepPlan)) { - _deferredInputClassifier.AppendUniqueDeferredInputIndexes( + isLastChangedGroup = _siblingRebindReporter.IsLastChangedPlanForAssembly( plans, classifiedPlans, - planIndex, - slots, - inputIndexes); + planIndex); + if (isLastChangedGroup) + { + _deferredInputClassifier.AppendUniqueDeferredInputIndexes( + plans, + classifiedPlans, + planIndex, + slots, + inputIndexes); + } } + groupCount++; await ProcessPlannedGroupAsync( inputIndexes, slots, @@ -176,11 +198,14 @@ await ProcessPlannedGroupAsync( // Why after every changed plan: an earlier all-deferred plan must not fill its // slots before the last changed group can absorb its unique callers. - for (int planIndex = 0; planIndex < plans.Count; planIndex++) + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepPlan)) { - if (classifiedPlans[planIndex].IsAllDeferred) + for (int planIndex = 0; planIndex < plans.Count; planIndex++) { - _deferredInputClassifier.ApplyDeferredAlreadyActive(plans[planIndex], slots); + if (classifiedPlans[planIndex].IsAllDeferred) + { + _deferredInputClassifier.ApplyDeferredAlreadyActive(plans[planIndex], slots); + } } } @@ -194,15 +219,37 @@ await ProcessPlannedGroupAsync( run.AddReappliedSibling(extraResults[extraIndex].Path, extraResults[extraIndex].Result); } - run.RecordAppliedSourceHashes(); - run.DisplayedRemovedMembers.ApplyTo(_patcher); + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepRecordSourceHashes)) + { + run.RecordAppliedSourceHashes(); + } + + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepRemovedMembers)) + { + run.DisplayedRemovedMembers.ApplyTo(_patcher); + } + + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepMainThreadSwitch)) + { + await MainThreadSwitcher.SwitchToMainThread(ct); + } + + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepCallerNotes)) + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + run.ApplyOneShotCallerNotes(projectRoot); + } - await MainThreadSwitcher.SwitchToMainThread(ct); - string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - run.ApplyOneShotCallerNotes(projectRoot); + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepMainThreadSwitch)) + { + await MainThreadSwitcher.SwitchToMainThread(ct); + } - await MainThreadSwitcher.SwitchToMainThread(ct); - return run.BuildResult(correlationId, timing.Complete(total.ElapsedMilliseconds)); + HotReloadTimingBreakdown breakdown = timing.Complete(total.ElapsedMilliseconds); + HotReloadOrchestratorLog.LogHotReloadTimingDetail( + HotReloadTimingDetailPayload.Build(breakdown, timing.Details, groupCount), + correlationId); + return run.BuildResult(correlationId, breakdown); } // Why on the inputs only: a re-applied sibling joins a group later and was never selected, @@ -239,14 +286,17 @@ private async Task ProcessPlannedGroupAsync( } int inputCount = inputIndexes.Count; - if (isLastGroupOfAssembly) + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepActiveSiblings)) { - _siblingRebindReporter.AppendActiveSiblingsToGroup( - filesOfGroup, - pathsInRun, - contentPathOverrideByFile, - run, - _inputFileResolver); + if (isLastGroupOfAssembly) + { + _siblingRebindReporter.AppendActiveSiblingsToGroup( + filesOfGroup, + pathsInRun, + contentPathOverrideByFile, + run, + _inputFileResolver); + } } // Why ConfigureAwait(false): UnityCliLoopTool forbids capturing Unity's diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestratorLog.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestratorLog.cs index 4ed882fad6..0d59d8a041 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestratorLog.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestratorLog.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -129,5 +130,26 @@ internal static void LogHotReloadApplySummary( }, correlationId); } + + // Why an anonymous array for the steps: the entry keys stay camelCase like the rest of the + // entry, which the step type's PascalCase properties would not. + internal static void LogHotReloadTimingDetail(HotReloadTimingDetailPayload payload, string correlationId) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogTimingDetail, + "Hot reload timing detail.", + new + { + totalMs = payload.TotalMs, + analysisMs = payload.AnalysisMs, + shimCompileMs = payload.ShimCompileMs, + patchMs = payload.PatchMs, + otherMs = payload.OtherMs, + unaccountedMs = payload.UnaccountedMs, + groupCount = payload.GroupCount, + steps = payload.Steps.Select(step => new { step = step.Step, ms = step.Ms }).ToArray() + }, + correlationId); + } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunTiming.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunTiming.cs index 2827b23c8d..81b4ff6406 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunTiming.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunTiming.cs @@ -1,7 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// /// Sums the milliseconds one apply run spends in each phase across all of its groups. + /// Also sums the named steps outside the phases, in the order each was first seen. The + /// response does not carry them; only the hot_reload_timing_detail vibe entry reads them. /// /// /// Why an accumulator the run hands down: group results are per file and carry no group-level @@ -10,10 +16,13 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal sealed class HotReloadRunTiming { + private readonly List> _details = new List>(); private long _analysisMs; private long _shimCompileMs; private long _patchMs; + public IReadOnlyList> Details => _details; + public void AddAnalysis(long milliseconds) { _analysisMs += HotReloadTimingBreakdown.RequireElapsed(milliseconds, nameof(milliseconds)); @@ -29,9 +38,62 @@ public void AddPatch(long milliseconds) _patchMs += HotReloadTimingBreakdown.RequireElapsed(milliseconds, nameof(milliseconds)); } + public void AddDetail(string step, long milliseconds) + { + if (string.IsNullOrEmpty(step)) + { + throw new ArgumentException("step must not be null or empty.", nameof(step)); + } + + long elapsed = HotReloadTimingBreakdown.RequireElapsed(milliseconds, nameof(milliseconds)); + int index = _details.FindIndex(pair => pair.Key == step); + if (index < 0) + { + _details.Add(new KeyValuePair(step, elapsed)); + return; + } + + _details[index] = new KeyValuePair(step, _details[index].Value + elapsed); + } + + /// + /// Measures the wall-clock time until the returned scope is disposed and adds it to the step. + /// + public IDisposable MeasureDetail(string step) + { + return new DetailScope(this, step); + } + public HotReloadTimingBreakdown Complete(long totalMs) { return new HotReloadTimingBreakdown(_analysisMs, _shimCompileMs, _patchMs, totalMs); } + + private sealed class DetailScope : IDisposable + { + private readonly HotReloadRunTiming _timing; + private readonly string _step; + private readonly Stopwatch _watch = Stopwatch.StartNew(); + private bool _disposed; + + public DetailScope(HotReloadRunTiming timing, string step) + { + _timing = timing; + _step = step; + } + + public void Dispose() + { + // Why only once: a second dispose would count the same span twice. + if (_disposed) + { + return; + } + + _disposed = true; + _watch.Stop(); + _timing.AddDetail(_step, _watch.ElapsedMilliseconds); + } + } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailPayload.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailPayload.cs new file mode 100644 index 0000000000..9d5eddad8f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailPayload.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Values of the hot_reload_timing_detail vibe entry: the run's phases, the named steps outside + /// them, and the part of the time outside the phases that no step covers. + /// + internal sealed class HotReloadTimingDetailPayload + { + private HotReloadTimingDetailPayload( + HotReloadTimingBreakdown breakdown, + IReadOnlyList steps, + long unaccountedMs, + int groupCount) + { + TotalMs = breakdown.TotalMs; + AnalysisMs = breakdown.AnalysisMs; + ShimCompileMs = breakdown.ShimCompileMs; + PatchMs = breakdown.PatchMs; + OtherMs = breakdown.OtherMs; + Steps = steps; + UnaccountedMs = unaccountedMs; + GroupCount = groupCount; + } + + public long TotalMs { get; } + + public long AnalysisMs { get; } + + public long ShimCompileMs { get; } + + public long PatchMs { get; } + + public long OtherMs { get; } + + // Negative when the steps add up to more than OtherMs, which means a step overlaps a phase. + public long UnaccountedMs { get; } + + public int GroupCount { get; } + + public IReadOnlyList Steps { get; } + + public static HotReloadTimingDetailPayload Build( + HotReloadTimingBreakdown breakdown, + IReadOnlyList> details, + int groupCount) + { + if (breakdown == null) + { + throw new ArgumentNullException(nameof(breakdown)); + } + + if (details == null) + { + throw new ArgumentNullException(nameof(details)); + } + + if (groupCount < 0) + { + throw new ArgumentOutOfRangeException(nameof(groupCount), groupCount, "groupCount must not be negative."); + } + + List steps = new List(details.Count); + long stepsMs = 0; + foreach (KeyValuePair detail in details) + { + steps.Add(new HotReloadTimingDetailStep(detail.Key, detail.Value)); + stepsMs += detail.Value; + } + + // Why not clamped at zero: a negative rest is the only sign that a step overlaps a phase. + return new HotReloadTimingDetailPayload(breakdown, steps, breakdown.OtherMs - stepsMs, groupCount); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailPayload.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailPayload.cs.meta new file mode 100644 index 0000000000..4a33f20196 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailPayload.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d9064e9e9f9794d18a086bda4c06be91 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailStep.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailStep.cs new file mode 100644 index 0000000000..dc1ace1f66 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailStep.cs @@ -0,0 +1,18 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One named step outside the apply run's phases and the milliseconds the run spent in it. + /// + internal sealed class HotReloadTimingDetailStep + { + public HotReloadTimingDetailStep(string step, long ms) + { + Step = step; + Ms = ms; + } + + public string Step { get; } + + public long Ms { get; } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailStep.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailStep.cs.meta new file mode 100644 index 0000000000..54ccc64941 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingDetailStep.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4f0ca74b735e14073ab1da361fd23b94 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index e277cf713f..ed68369eb0 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -649,6 +649,28 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string VibeLogEmptyEntriesClear = "hot_reload_empty_entries_clear"; public const string VibeLogRevertFailed = "hot_reload_revert_failed"; public const string VibeLogApplySummary = "hot_reload_apply_summary"; + public const string VibeLogTimingDetail = "hot_reload_timing_detail"; + + // Steps of an apply run outside the response's Timing phases, as named in the + // hot_reload_timing_detail vibe entry. Steps that run once per group add up. + public const string TimingDetailStepMainThreadSwitch = "main_thread_switch"; + public const string TimingDetailStepPackageRoots = "package_roots"; + public const string TimingDetailStepResolveInputs = "resolve_inputs"; + public const string TimingDetailStepPlan = "plan"; + public const string TimingDetailStepActiveSiblings = "active_siblings"; + public const string TimingDetailStepMembershipValidate = "membership_validate"; + public const string TimingDetailStepSnapshotGroupState = "snapshot_group_state"; + public const string TimingDetailStepActivePaths = "active_paths"; + public const string TimingDetailStepSiblingDetect = "sibling_detect"; + public const string TimingDetailStepWorkerInput = "worker_input"; + public const string TimingDetailStepPreparationOutcome = "preparation_outcome"; + public const string TimingDetailStepWorkerNotices = "worker_notices"; + public const string TimingDetailStepRevalidateBeforeRevert = "revalidate_before_revert"; + public const string TimingDetailStepApplyContext = "apply_context"; + public const string TimingDetailStepIsolationSplit = "isolation_split"; + public const string TimingDetailStepRecordSourceHashes = "record_source_hashes"; + public const string TimingDetailStepRemovedMembers = "removed_members"; + public const string TimingDetailStepCallerNotes = "caller_notes"; public const string VibeLogShimCompileStageFirstPass = "first_pass"; public const string VibeLogShimCompileStageRetry = "retry"; public const string VibeLogIsolationTriggerShimCompileFailure = "shim_compile_failure"; diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 7dd2488142..960dc5ad8e 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -31,6 +31,10 @@ command ran against: - Unity side: only when the `ULOOP_DEBUG` scripting define symbol is set in the project. Every log method is `[Conditional("ULOOP_DEBUG")]`, so without it nothing is written — not even errors. This repository's development project defines it for the Standalone build target. + Hot reload also writes one `hot_reload_timing_detail` per apply run, with the milliseconds of + each step outside the response's `Timing` phases (`steps`), the phases themselves, and + `unaccountedMs` — the part of `otherMs` no step covers. A negative `unaccountedMs` means a + step overlaps a phase. - CLI side: only when the `ULOOP_DEBUG` environment variable is set to a value other than empty, `0`, or `false` (`cli/common/vibelog/cli_vibe.go`). - A missing line is evidence only when the define was set and the code path logs at all. From ee6cc4bde6b8cecf76b55c8fcfe1b13dce4bbe33 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 01:11:45 +0900 Subject: [PATCH 05/29] fix: A request that Unity keeps answering busy no longer brings the Editor to the front (#3231) --- .../projectrunner/connection_retry.go | 19 +------- .../projectrunner/connection_retry_test.go | 43 ++++++------------- .../projectrunner/hot_reload_busy_wait.go | 3 +- ...d-a-macos-activity-while-a-command-runs.md | 1 + 4 files changed, 17 insertions(+), 49 deletions(-) diff --git a/cli/project-runner/internal/projectrunner/connection_retry.go b/cli/project-runner/internal/projectrunner/connection_retry.go index 4d5929e6e7..8d42c908d1 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry.go +++ b/cli/project-runner/internal/projectrunner/connection_retry.go @@ -18,7 +18,6 @@ const ( focusRestoreTimeout = 2 * time.Second serverConnectionRetryDefaultTimeout = 10 * time.Second serverConnectionRetryDefaultPoll = 1 * time.Second - defaultBusyFocusStallThreshold = 5 * time.Second ) type connectionRetryDeps struct { @@ -26,11 +25,9 @@ type connectionRetryDeps struct { focusUnityProcess func(context.Context, int) (unityprocess.RestoreFocusFunc, error) retryTimeout time.Duration retryPoll time.Duration - busyFocusStallThreshold time.Duration // returnBusyWithoutRetry hands the first busy answer back instead of resending every // retryPoll. Hot reload sets it because it waits for the busy slot to free on the Editor - // status: resending here would only delay that wait, and after busyFocusStallThreshold the - // busy-stall focus would bring the Editor to the front. + // status: resending here would only delay that wait. returnBusyWithoutRetry bool } @@ -51,7 +48,6 @@ const ( focusReasonMainThreadStall connectionRetryFocusReason = "main_thread_stall" focusReasonHeartbeatSilenceTimeout connectionRetryFocusReason = "heartbeat_silence_timeout" focusReasonFinalResponseTimeout connectionRetryFocusReason = "final_response_timeout" - focusReasonBusyStall connectionRetryFocusReason = "busy_stall" ) // Why: domain reload on large projects can keep the IPC endpoint down well past the @@ -66,13 +62,6 @@ func unityAliveRetryWindow(deps connectionRetryDeps) time.Duration { return deps.retryTimeout * serverConnectionRetryUnityAliveFactor } -func busyFocusStallThresholdFor(deps connectionRetryDeps) time.Duration { - if deps.busyFocusStallThreshold > 0 { - return deps.busyFocusStallThreshold - } - return defaultBusyFocusStallThreshold -} - type connectionRetryFocusController struct { connection unityipc.Connection method string @@ -247,7 +236,6 @@ func sendWithTransientConnectionRetryWithDeps( var lastOutcome unityipc.UnitySendOutcome var lastErr error - busySequenceStartedAt := time.Time{} focusController := newConnectionRetryFocusController(connection, method, deps) defer func() { restoreContext, cancel := context.WithTimeout(context.Background(), focusRestoreTimeout) @@ -273,11 +261,6 @@ func sendWithTransientConnectionRetryWithDeps( } // Busy means the request was never executed, so a bounded retry is safe and // usually absorbs back-to-back tool calls without bothering the caller. - if busySequenceStartedAt.IsZero() { - busySequenceStartedAt = time.Now() - } else if time.Since(busySequenceStartedAt) >= busyFocusStallThresholdFor(deps) { - focusController.tryFocus(ctx, focusReasonBusyStall, err) - } lastOutcome = outcome lastErr = err if finished, finalOutcome, finalErr := finishBusyRetry( diff --git a/cli/project-runner/internal/projectrunner/connection_retry_test.go b/cli/project-runner/internal/projectrunner/connection_retry_test.go index 91923a63ee..14d18a053b 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_test.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_test.go @@ -24,19 +24,6 @@ import ( "github.com/hatayama/unity-cli-loop/common/unityprocess" ) -// Verifies the default busy-stall focus threshold fires before the bounded busy retry window ends. -func TestDefaultBusyFocusStallThresholdFitsWithinBusyRetryWindow(t *testing.T) { - deps := defaultConnectionRetryDeps() - threshold := busyFocusStallThresholdFor(deps) - if threshold >= deps.retryTimeout { - t.Fatalf( - "busy focus stall threshold must stay below the busy retry window: threshold=%s window=%s", - threshold, - deps.retryTimeout, - ) - } -} - // Verifies connection-retry focus rescue bounds the focus external command with a deadline. func TestConnectionRetryFocusControllerBoundsFocusContext(t *testing.T) { var receivedContext context.Context @@ -50,7 +37,7 @@ func TestConnectionRetryFocusControllerBoundsFocusContext(t *testing.T) { "get-logs", deps, ) - controller.tryFocusProcess(context.Background(), 123, focusReasonBusyStall, errors.New("busy")) + controller.tryFocusProcess(context.Background(), 123, focusReasonPreAcceptTimeout, errors.New("busy")) if receivedContext == nil { t.Fatal("expected focus attempt context") @@ -228,7 +215,7 @@ func TestConnectionRetryFocusControllerLogsRestoreSuccessWithAttemptCorrelation( deps, ) - controller.tryFocusProcess(context.Background(), 123, focusReasonBusyStall, errors.New("busy")) + controller.tryFocusProcess(context.Background(), 123, focusReasonPreAcceptTimeout, errors.New("busy")) controller.restore(context.Background()) if restoreCallCount != 1 { @@ -253,7 +240,7 @@ func TestConnectionRetryFocusControllerLogsRestoreSuccessWithAttemptCorrelation( for _, expected := range []string{ `"command":"get-logs"`, `"pid":123`, - `"reason":"busy_stall"`, + `"reason":"pre_accept_timeout"`, } { if !strings.Contains(logContent, expected) { t.Fatalf("CLI Vibe log missing %q:\n%s", expected, logContent) @@ -360,7 +347,7 @@ func TestConnectionRetryFocusControllerLogsMissingRestorerAtFocusTime(t *testing deps, ) - controller.tryFocusProcess(context.Background(), 123, focusReasonBusyStall, errors.New("busy")) + controller.tryFocusProcess(context.Background(), 123, focusReasonPreAcceptTimeout, errors.New("busy")) controller.restore(context.Background()) logContent := readOnlyCliVibeLog(t, projectRoot) @@ -1080,8 +1067,8 @@ func TestSendWithTransientConnectionRetryRetriesBusyResponses(t *testing.T) { } } -// Verifies that returnBusyWithoutRetry hands the first busy answer back at once: no resend -// and no busy-stall focus, because hot reload waits for the Editor on its status instead. +// Verifies that returnBusyWithoutRetry hands the first busy answer back at once without a +// resend, because hot reload waits for the Editor on its status instead. func TestSendWithTransientConnectionRetryReturnsTheFirstBusyAnswerWhenAsked(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("TCP endpoint injection is only used by this non-Windows client test") @@ -1090,7 +1077,6 @@ func TestSendWithTransientConnectionRetryReturnsTheFirstBusyAnswerWhenAsked(t *t deps := defaultConnectionRetryDeps() deps.returnBusyWithoutRetry = true deps.retryPoll = 5 * time.Millisecond - deps.busyFocusStallThreshold = time.Nanosecond processLookups := 0 focusCalls := 0 deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { @@ -1319,10 +1305,10 @@ func TestSendWithTransientConnectionRetryReturnsBusyAfterRetryWindow(t *testing. } } -// TDD repro for B-7a: before busy_stall focus rescue, persistent server_busy never called -// focusUnityProcess (focusCallCount stayed 0). This assertion was Red on pre-fix -// connection_retry.go and turns Green after the busy stall threshold hook. -func TestSendWithTransientConnectionRetryFocusesOnceAfterPersistentBusy(t *testing.T) { +// Verifies persistent server_busy answers never bring the Editor to the front: the request +// never ran, and the running command holds the activity (ADR 0012), so there is nothing to +// rescue. +func TestSendWithTransientConnectionRetryNeverFocusesWhileBusy(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("TCP endpoint injection is only used by this non-Windows client test") } @@ -1330,7 +1316,6 @@ func TestSendWithTransientConnectionRetryFocusesOnceAfterPersistentBusy(t *testi deps := defaultConnectionRetryDeps() deps.retryTimeout = 500 * time.Millisecond deps.retryPoll = 5 * time.Millisecond - deps.busyFocusStallThreshold = 30 * time.Millisecond focusCallCount := 0 restoreCallCount := 0 deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { @@ -1390,11 +1375,11 @@ func TestSendWithTransientConnectionRetryFocusesOnceAfterPersistentBusy(t *testi if err == nil { t.Fatal("expected busy error after retry window") } - if focusCallCount != 1 { - t.Fatalf("expected one busy-stall focus attempt, got %d", focusCallCount) + if focusCallCount != 0 { + t.Fatalf("expected no focus attempt while Unity answered busy, got %d", focusCallCount) } - if restoreCallCount != 1 { - t.Fatalf("expected focus restore after busy retry exit, got %d", restoreCallCount) + if restoreCallCount != 0 { + t.Fatalf("expected no focus restore while Unity answered busy, got %d", restoreCallCount) } } diff --git a/cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go b/cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go index 154d59626a..5a41e201cf 100644 --- a/cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go +++ b/cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go @@ -28,8 +28,7 @@ const ( ) // hotReloadBusyWaitSendDeps sends without the bounded busy retry. Why: hot reload waits for the -// Editor on its status instead of resending every second, which would also bring the Editor to the -// front after the busy-stall threshold. +// Editor on its status instead of resending every second. func hotReloadBusyWaitSendDeps() connectionRetryDeps { deps := defaultConnectionRetryDeps() deps.returnBusyWithoutRetry = true diff --git a/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md b/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md index 1817d2a0bb..7b9d9744bd 100644 --- a/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md +++ b/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md @@ -69,6 +69,7 @@ retains it before its own autorelease pool drains and releases it after `endActi - A Begin and End pair costs about 4 µs inside the Editor (median of 100 pairs; the slowest took 0.11 ms). - `uloop compile` no longer brings the Editor to the front when compilation has not started within ten seconds. That rescue (#2341) existed for this suppression; with the activity held, a compile that has not started is a compile the Editor has not reached yet, and bringing it to the front only moves the user's windows. +- A request that Unity keeps answering `server_busy` no longer brings the Editor to the front after five seconds either. That rescue (B-7a) also existed for this suppression; with the activity held by the running command, a busy Editor is a working Editor. The bounded resend itself stays: busy means the request never ran. ## Reversal condition From d872b6d6ced4857fad3456d1452fbf9e0e14bbe3 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 08:09:20 +0900 Subject: [PATCH 06/29] fix: Hot reload's call-site cache is bounded by bytes instead of a count, so one run's assemblies stay cached (#3235) --- .../HotReloadCompiledCallSiteCacheTests.cs | 186 ++++++++++++++++-- .../HotReloadCompiledCallSiteCache.cs | 152 +++++++++++--- .../HotReload/Shared/HotReloadConstants.cs | 1 + docs/vibe-logs.md | 4 + 4 files changed, 298 insertions(+), 45 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledCallSiteCacheTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledCallSiteCacheTests.cs index 97a84f6ec7..ded3f916e5 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompiledCallSiteCacheTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledCallSiteCacheTests.cs @@ -15,7 +15,7 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// - /// Invalidation, capacity, run hold, and call-site index contract of the compiled call-site + /// Invalidation, byte budget, run hold, and call-site index contract of the compiled call-site /// cache. Each test works on copies of this test assembly's dll in a private temp directory so /// that mutating the file never touches ScriptAssemblies. /// @@ -46,7 +46,7 @@ public void SetUp() Path.GetTempPath(), "uloop-call-site-cache-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDirectory); - _cache = new HotReloadCompiledCallSiteCache(2); + _cache = new HotReloadCompiledCallSiteCache(BudgetForEntries(2)); } [TearDown] @@ -185,7 +185,7 @@ public void GetOrLoad_FileReplacedBetweenFingerprintAndRead_PublishesConsistentE File.SetLastWriteTimeUtc(dllPath, originalWriteTime); } }; - HotReloadCompiledCallSiteCache cache = new HotReloadCompiledCallSiteCache(2, probes); + HotReloadCompiledCallSiteCache cache = new HotReloadCompiledCallSiteCache(BudgetForEntries(2), probes); try { HotReloadCompiledCallSiteCache.Entry entry = cache.GetOrLoad(dllPath); @@ -227,7 +227,7 @@ public void GetOrLoad_FileChangesBeforeEveryRead_ThrowsWithoutPublishing() useOther = !useOther; } }; - HotReloadCompiledCallSiteCache cache = new HotReloadCompiledCallSiteCache(2, probes); + HotReloadCompiledCallSiteCache cache = new HotReloadCompiledCallSiteCache(BudgetForEntries(2), probes); try { Assert.Throws(() => cache.GetOrLoad(dllPath)); @@ -257,7 +257,7 @@ public void GetOrLoad_IndexBuildFails_DisposesAssemblyAndRethrows() throw injected; } }; - HotReloadCompiledCallSiteCache cache = new HotReloadCompiledCallSiteCache(2, probes); + HotReloadCompiledCallSiteCache cache = new HotReloadCompiledCallSiteCache(BudgetForEntries(2), probes); try { InvalidOperationException thrown = Assert.Throws(() => cache.GetOrLoad(dllPath)); @@ -295,11 +295,11 @@ public void GetOrLoad_DifferentPath_LoadsSeparately() } /// - /// What: loading beyond the capacity evicts the least recently used entry, so the count - /// never exceeds the cap and the evicted dll is read again on its next lookup. + /// What: loading beyond the budget evicts the least recently used entry, so the cached bytes + /// never exceed the budget and the evicted dll is read again on its next lookup. /// [Test] - public void GetOrLoad_OverCapacity_EvictsLeastRecentlyUsed() + public void GetOrLoad_OverBudget_EvictsLeastRecentlyUsed() { string pathA = CopyTestAssembly("a.dll"); string pathB = CopyTestAssembly("b.dll"); @@ -321,20 +321,20 @@ public void GetOrLoad_OverCapacity_EvictsLeastRecentlyUsed() } /// - /// What: a non-positive capacity is rejected, because the cache could never hold an entry. + /// What: a non-positive budget is rejected, because the cache could never hold an entry. /// [Test] - public void Constructor_NonPositiveCapacity_Throws() + public void Constructor_NonPositiveBudget_Throws() { Assert.Throws(() => new HotReloadCompiledCallSiteCache(0)); } /// - /// What: inside a hold each dll is read once even when a run touches more dlls than the - /// capacity, so a run never re-reads a dll it already read. + /// What: inside a hold each dll is read once even when a run touches more bytes than the + /// budget, so a run never re-reads a dll it already read. /// [Test] - public void GetOrLoad_InsideHold_ReadsEachDllOnce_EvenBeyondCapacity() + public void GetOrLoad_InsideHold_ReadsEachDllOnce_EvenBeyondBudget() { string pathA = CopyTestAssembly("a.dll"); string pathB = CopyTestAssembly("b.dll"); @@ -358,11 +358,11 @@ public void GetOrLoad_InsideHold_ReadsEachDllOnce_EvenBeyondCapacity() } /// - /// What: ending the hold evicts the least recently used entries down to the capacity, so + /// What: ending the hold evicts the least recently used entries down to the budget, so /// the dlls used last stay cached for the next run and the evicted one is read again. /// [Test] - public void HoldEntriesForRun_Dispose_EvictsDownToCapacity() + public void HoldEntriesForRun_Dispose_EvictsDownToBudget() { string pathA = CopyTestAssembly("a.dll"); string pathB = CopyTestAssembly("b.dll"); @@ -389,7 +389,7 @@ public void HoldEntriesForRun_Dispose_EvictsDownToCapacity() /// /// What: ending an inner hold keeps every entry while the outer hold is open; only the end - /// of the outer hold evicts down to the capacity. + /// of the outer hold evicts down to the budget. /// [Test] public void HoldEntriesForRun_Nested_KeepsEntriesUntilTheOuterHoldEnds() @@ -441,7 +441,7 @@ public void HoldEntriesForRun_DisposeTwice_IsIgnored() /// /// What: without a hold a miss still evicts the least recently used entry before it adds, - /// so lookups outside a run keep the cache within its capacity. + /// so lookups outside a run keep the cache within its budget. /// [Test] public void GetOrLoad_WithoutHold_StillEvictsBeforeAdding() @@ -452,6 +452,137 @@ public void GetOrLoad_WithoutHold_StillEvictsBeforeAdding() Assert.That(_cache.Count, Is.EqualTo(2)); Assert.That(_cache.LoadCount, Is.EqualTo(3)); + Assert.That(_cache.CachedBytes, Is.EqualTo(BudgetForEntries(2))); + } + + /// + /// What: when every dll a run touched fits the budget, ending the hold evicts nothing, so + /// the next run reads none of them again. A count cap smaller than the run evicted some. + /// + [Test] + public void HoldEntriesForRun_Dispose_KeepsEveryEntryWithinBudget() + { + HotReloadCompiledCallSiteCache cache = new HotReloadCompiledCallSiteCache(BudgetForEntries(3)); + try + { + string pathA = CopyTestAssembly("a.dll"); + string pathB = CopyTestAssembly("b.dll"); + string pathC = CopyTestAssembly("c.dll"); + using (cache.HoldEntriesForRun()) + { + cache.GetOrLoad(pathA); + cache.GetOrLoad(pathB); + cache.GetOrLoad(pathC); + } + + Assert.That(cache.Count, Is.EqualTo(3)); + Assert.That(cache.CachedBytes, Is.EqualTo(BudgetForEntries(3))); + Assert.That(cache.LastHoldReleaseEvictedCount, Is.EqualTo(0)); + + cache.GetOrLoad(pathA); + cache.GetOrLoad(pathB); + cache.GetOrLoad(pathC); + Assert.That(cache.LoadCount, Is.EqualTo(3), "The next run reads none of the dlls again."); + } + finally + { + cache.Clear(); + } + } + + /// + /// What: ending a hold over the budget records how many entries and bytes it evicted. + /// + [Test] + public void HoldEntriesForRun_Dispose_RecordsWhatItEvicted() + { + using (_cache.HoldEntriesForRun()) + { + _cache.GetOrLoad(CopyTestAssembly("a.dll")); + _cache.GetOrLoad(CopyTestAssembly("b.dll")); + _cache.GetOrLoad(CopyTestAssembly("c.dll")); + } + + Assert.That(_cache.Count, Is.EqualTo(2)); + Assert.That(_cache.CachedBytes, Is.EqualTo(BudgetForEntries(2))); + Assert.That(_cache.LastHoldReleaseEvictedCount, Is.EqualTo(1)); + Assert.That(_cache.LastHoldReleaseEvictedBytes, Is.EqualTo(TestAssemblyLength())); + } + + /// + /// What: ending a hold keeps the last entry even when it alone exceeds the budget, so the + /// end of a run never empties the cache. + /// + [Test] + public void HoldEntriesForRun_Dispose_KeepsTheLastEntryOverBudget() + { + HotReloadCompiledCallSiteCache cache = new HotReloadCompiledCallSiteCache(TestAssemblyLength() / 2); + try + { + using (cache.HoldEntriesForRun()) + { + cache.GetOrLoad(CopyTestAssembly("a.dll")); + cache.GetOrLoad(CopyTestAssembly("b.dll")); + } + + Assert.That(cache.Count, Is.EqualTo(1)); + Assert.That(cache.CachedBytes, Is.EqualTo(TestAssemblyLength())); + Assert.That(cache.LastHoldReleaseEvictedCount, Is.EqualTo(1)); + Assert.That(cache.LastHoldReleaseEvictedBytes, Is.EqualTo(TestAssemblyLength())); + } + finally + { + cache.Clear(); + } + } + + /// + /// What: a dll larger than the whole budget is still cached, alone, and the next dll + /// replaces it, so a large assembly never makes a lookup fail or loop. + /// + [Test] + public void GetOrLoad_EntryLargerThanBudget_IsStillLoaded() + { + HotReloadCompiledCallSiteCache cache = new HotReloadCompiledCallSiteCache(TestAssemblyLength() / 2); + try + { + cache.GetOrLoad(CopyTestAssembly("a.dll")); + Assert.That(cache.Count, Is.EqualTo(1)); + Assert.That(cache.CachedBytes, Is.EqualTo(TestAssemblyLength())); + + cache.GetOrLoad(CopyTestAssembly("b.dll")); + Assert.That(cache.Count, Is.EqualTo(1), "A was evicted to make room for B."); + Assert.That(cache.LoadCount, Is.EqualTo(2)); + } + finally + { + cache.Clear(); + } + } + + /// + /// What: replacing a stale entry counts the new file's length instead of adding it to the + /// old one, so the cached bytes match what is actually held. + /// + [Test] + public void GetOrLoad_ReplacedEntry_UpdatesCachedBytes() + { + string dllPath = CopyTestAssembly("a.dll"); + DateTime originalWriteTime = File.GetLastWriteTimeUtc(dllPath); + _cache.GetOrLoad(dllPath); + + // Why append: trailing bytes after the PE image keep the dll readable for Cecil while + // changing the length, as in GetOrLoad_SizeChanged_Reloads. + using (FileStream stream = new FileStream(dllPath, FileMode.Append)) + { + stream.WriteByte(0); + } + + File.SetLastWriteTimeUtc(dllPath, originalWriteTime); + _cache.GetOrLoad(dllPath); + + Assert.That(_cache.CachedBytes, Is.EqualTo(new FileInfo(dllPath).Length)); + Assert.That(_cache.Count, Is.EqualTo(1)); } /// @@ -503,7 +634,7 @@ public void GetOrLoad_ReleasesMethodBodiesAfterIndexing() { AfterAssemblyRead = assembly => captured = assembly }; - HotReloadCompiledCallSiteCache cache = new HotReloadCompiledCallSiteCache(2, probes); + HotReloadCompiledCallSiteCache cache = new HotReloadCompiledCallSiteCache(BudgetForEntries(2), probes); try { HotReloadCompiledCallSiteCache.Entry entry = cache.GetOrLoad(CopyTestAssembly("a.dll")); @@ -607,7 +738,7 @@ private static byte[] ReadOtherAssemblyPaddedTo(long length) return padded; } - private string CopyTestAssembly(string fileName) + private static string TestAssemblySourcePath() { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); string sourceDllPath = Path.Combine( @@ -616,6 +747,23 @@ private string CopyTestAssembly(string fileName) "ScriptAssemblies", TestAssemblyName + ".dll"); Assert.That(File.Exists(sourceDllPath), Is.True, "Test assembly dll missing: " + sourceDllPath); + return sourceDllPath; + } + + // Every fixture dll is a copy of the test assembly, so all of them have this length. + private static long TestAssemblyLength() + { + return new FileInfo(TestAssemblySourcePath()).Length; + } + + private static long BudgetForEntries(int count) + { + return count * TestAssemblyLength(); + } + + private string CopyTestAssembly(string fileName) + { + string sourceDllPath = TestAssemblySourcePath(); string destinationPath = Path.Combine(_tempDirectory, fileName); File.Copy(sourceDllPath, destinationPath); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteCache.cs index 2e1fb85a91..ac61191eeb 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteCache.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteCache.cs @@ -8,6 +8,8 @@ using UnityEngine; +using io.github.hatayama.UnityCliLoop.ToolContracts; + namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// @@ -20,12 +22,16 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal sealed class HotReloadCompiledCallSiteCache { - // Why 64: an entry keeps the dll's bytes (InMemory) and its metadata only, because the - // methods' instruction lists are released once the call sites are collected. In a large - // project one run scanned the target assembly plus the 20 assemblies that reference it, - // and a cap of 8 made every scan read all 21 again. Within a run nothing is evicted - // whatever the cap (HoldEntriesForRun); the cap bounds what stays cached between runs. - internal const int DefaultCapacity = 64; + // Why a byte budget: an entry costs about its dll's length, because it keeps the dll's + // bytes (InMemory) and its metadata only; the methods' instruction lists are released + // once the call sites are collected. Dlls range from kilobytes to megabytes, so a count + // bounds neither memory nor a run: with a cap of 64, a run that scanned 68 assemblies + // (42 MB) evicted 9 of them (29 MB, the edited assembly among them) at every hold release + // and read them again on the next run, about 2 s per run. 256 MB keeps every assembly + // such a run touches and still bounds the Editor's memory. Within a run nothing is + // evicted whatever the budget (HoldEntriesForRun); the budget bounds what stays cached + // between runs. The budget counts file lengths, not the exact in-memory size. + internal const long DefaultBudgetBytes = 256L * 1024 * 1024; /// /// One compiled instruction that may reference a target method. @@ -151,25 +157,29 @@ internal sealed class LoadProbes private const int MaxConsistentLoadAttempts = 2; public static HotReloadCompiledCallSiteCache Shared { get; } = - new HotReloadCompiledCallSiteCache(DefaultCapacity); + new HotReloadCompiledCallSiteCache(DefaultBudgetBytes); private readonly object _gate = new object(); - private readonly int _capacity; + private readonly long _budgetBytes; private readonly LoadProbes _probes; private readonly Dictionary _entries = new Dictionary(StringComparer.Ordinal); private long _accessSequence; private int _loadCount; + // Sum of the cached entries' dll lengths; kept in step with _entries under _gate. + private long _cachedBytes; + private int _lastHoldReleaseEvictedCount; + private long _lastHoldReleaseEvictedBytes; // Number of open holds; touched only under _gate. private int _holdDepth; - public HotReloadCompiledCallSiteCache(int capacity, LoadProbes probes = null) + public HotReloadCompiledCallSiteCache(long budgetBytes, LoadProbes probes = null) { - if (capacity <= 0) + if (budgetBytes <= 0) { - throw new ArgumentOutOfRangeException(nameof(capacity), "capacity must be positive."); + throw new ArgumentOutOfRangeException(nameof(budgetBytes), "budgetBytes must be positive."); } - _capacity = capacity; + _budgetBytes = budgetBytes; _probes = probes; } @@ -201,6 +211,49 @@ public int LoadCount } } + /// + /// Sum of the dll lengths of the entries currently held. It exceeds the budget only while + /// a hold is open, or when a single entry is larger than the budget. + /// + public long CachedBytes + { + get + { + lock (_gate) + { + return _cachedBytes; + } + } + } + + /// + /// Number of entries evicted when the outermost hold last ended; 0 before any hold ended. + /// + public int LastHoldReleaseEvictedCount + { + get + { + lock (_gate) + { + return _lastHoldReleaseEvictedCount; + } + } + } + + /// + /// Sum of the dll lengths of the entries evicted when the outermost hold last ended. + /// + public long LastHoldReleaseEvictedBytes + { + get + { + lock (_gate) + { + return _lastHoldReleaseEvictedBytes; + } + } + } + /// /// Returns the cached view of while the file's length, last /// write time, and module version id (MVID) all match the cached entry; otherwise reads the @@ -229,6 +282,7 @@ public Entry GetOrLoad(string dllPath) } _entries.Remove(fullPath); + _cachedBytes -= existing.Fingerprint.Length; existing.Dispose(); } @@ -238,18 +292,19 @@ public Entry GetOrLoad(string dllPath) // eviction inside the run turns one read per dll into one read per scan. if (_holdDepth == 0) { - EvictLeastRecentlyUsedWhileOverCapacity(); + EvictLeastRecentlyUsedUntilFits(loaded.Fingerprint.Length); } _entries[fullPath] = loaded; + _cachedBytes += loaded.Fingerprint.Length; return loaded; } } /// /// Keeps every entry until the returned hold is disposed, however many dlls are read in the - /// meantime; when the outermost hold ends, the least recently used entries are evicted down - /// to the capacity. Holds nest, and disposing one hold twice releases it once. + /// meantime; when the outermost hold ends, the least recently used entries are evicted until + /// the cached bytes fit the budget, keeping at least one entry. Holds nest, and disposing one hold twice releases it once. /// public IDisposable HoldEntriesForRun() { @@ -274,6 +329,7 @@ public void Clear() } _entries.Clear(); + _cachedBytes = 0; } } @@ -298,11 +354,43 @@ public void Dispose() return; } - owner.ReleaseHold(); + HoldRelease release = owner.ReleaseHold(); + if (release.EvictedCount == 0) + { + return; + } + + // Why outside the lock: the vibe log writes a file, which must not block lookups. + // Only numbers are logged; dll paths may name the user's project. + VibeLogger.LogInfo( + HotReloadConstants.VibeLogCallSiteCacheEvicted, + "Hot reload call-site cache evicted entries at the end of a run.", + new + { + evictedCount = release.EvictedCount, + evictedBytes = release.EvictedBytes, + cachedBytes = release.CachedBytes, + budgetBytes = owner._budgetBytes + }); + } + } + + // What ending a hold evicted, read under the lock and logged after it is released. + private readonly struct HoldRelease + { + public readonly int EvictedCount; + public readonly long EvictedBytes; + public readonly long CachedBytes; + + public HoldRelease(int evictedCount, long evictedBytes, long cachedBytes) + { + EvictedCount = evictedCount; + EvictedBytes = evictedBytes; + CachedBytes = cachedBytes; } } - private void ReleaseHold() + private HoldRelease ReleaseHold() { lock (_gate) { @@ -310,29 +398,40 @@ private void ReleaseHold() _holdDepth--; if (_holdDepth > 0) { - return; + return new HoldRelease(0, 0, _cachedBytes); } - EvictLeastRecentlyUsedDownToCapacity(); + EvictLeastRecentlyUsedDownToBudget(); + return new HoldRelease(_lastHoldReleaseEvictedCount, _lastHoldReleaseEvictedBytes, _cachedBytes); } } - private void EvictLeastRecentlyUsedWhileOverCapacity() + private void EvictLeastRecentlyUsedUntilFits(long incomingBytes) { - // The new entry is added after this call, so make room for it. - while (_entries.Count >= _capacity) + // The new entry is added after this call, so make room for it. Stops once the cache is + // empty: an entry larger than the budget is still added, alone. + while (_entries.Count > 0 && _cachedBytes + incomingBytes > _budgetBytes) { EvictEntry(FindLeastRecentlyUsedPath()); } } - private void EvictLeastRecentlyUsedDownToCapacity() + private void EvictLeastRecentlyUsedDownToBudget() { - // Nothing is added after this call, so keep entries up to the capacity itself. - while (_entries.Count > _capacity) + // Nothing is added after this call, so evict down to the budget itself, but keep the + // last entry even when it alone exceeds the budget, as a lookup without a hold would. + int evictedCount = 0; + long evictedBytes = 0; + while (_cachedBytes > _budgetBytes && _entries.Count > 1) { - EvictEntry(FindLeastRecentlyUsedPath()); + string path = FindLeastRecentlyUsedPath(); + evictedBytes += _entries[path].Fingerprint.Length; + EvictEntry(path); + evictedCount++; } + + _lastHoldReleaseEvictedCount = evictedCount; + _lastHoldReleaseEvictedBytes = evictedBytes; } private string FindLeastRecentlyUsedPath() @@ -355,6 +454,7 @@ private void EvictEntry(string path) { Entry evicted = _entries[path]; _entries.Remove(path); + _cachedBytes -= evicted.Fingerprint.Length; evicted.Dispose(); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index ed68369eb0..a6c24ce693 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -650,6 +650,7 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string VibeLogRevertFailed = "hot_reload_revert_failed"; public const string VibeLogApplySummary = "hot_reload_apply_summary"; public const string VibeLogTimingDetail = "hot_reload_timing_detail"; + public const string VibeLogCallSiteCacheEvicted = "hot_reload_call_site_cache_evicted"; // Steps of an apply run outside the response's Timing phases, as named in the // hot_reload_timing_detail vibe entry. Steps that run once per group add up. diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 960dc5ad8e..0d59cfb84d 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -35,6 +35,10 @@ command ran against: each step outside the response's `Timing` phases (`steps`), the phases themselves, and `unaccountedMs` — the part of `otherMs` no step covers. A negative `unaccountedMs` means a step overlaps a phase. + When the end of a run evicted entries from the call-site cache, it also writes one + `hot_reload_call_site_cache_evicted` with the evicted count and bytes, the bytes still cached, + and the budget — a sign that the assemblies one run scans do not fit the cache and are read + again on the next run. - CLI side: only when the `ULOOP_DEBUG` environment variable is set to a value other than empty, `0`, or `false` (`cli/common/vibelog/cli_vibe.go`). - A missing line is evidence only when the define was set and the code path logs at all. From ea9d692904911a97a7b3d3740f2c59f96eb96ac9 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 08:48:35 +0900 Subject: [PATCH 07/29] fix: The Debug-switch warning says the setting goes back to the startup preference, not that it reverts (#3237) --- .../PausePoint/Skill/references/troubleshooting.md | 2 +- .../FirstPartyTools/PausePoint/SourcePausePointConstants.cs | 5 +++-- .../internal/projectrunner/pause_point_release_recovery.go | 2 +- .../projectrunner/pause_point_release_recovery_test.go | 2 +- docs/soak-testing.md | 3 ++- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/troubleshooting.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/troubleshooting.md index dbe447522b..3e5bdd4825 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/troubleshooting.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/troubleshooting.md @@ -95,4 +95,4 @@ interrupt the flow or ask the user about it mid-run. At the next stopping point propose `uloop set-code-optimization debug --startup` (session-only without `--startup`), and only apply it if the user approves. -The automatic Debug switch changes only the current project's code optimization for this Editor session; it reverts on every Editor restart, and each re-switch costs a full script recompile. `uloop set-code-optimization debug --startup` makes Debug the startup default through a machine-wide Unity preference that applies to every project. Only the project's C# scripts run slower, mainly in Play Mode - the Editor itself is not slowed. +The automatic Debug switch changes the code optimization of this Editor session only; on every Editor restart it goes back to the 'Code Optimization On Startup' preference (a per-user Unity preference, Release unless changed), and each re-switch costs a full script recompile. `uloop set-code-optimization debug --startup` makes Debug the startup default through a machine-wide Unity preference that applies to every project. Only the project's C# scripts run slower, mainly in Play Mode - the Editor itself is not slowed. diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs index 02466dc359..c16fba1486 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs @@ -246,8 +246,9 @@ internal static class SourcePausePointConstants // not to correct the path or the line syntax as RESOLVE_FAILED asks. public const string ErrorCodePausePointLineNotCompiled = "PAUSE_POINT_LINE_NOT_COMPILED"; - // Why: Debug mode is lost on every Editor restart (including uloop launch -r), so the - // recovery steps must remind callers to re-switch after restart rather than only once. + // Why: Debug mode goes back to the 'Code Optimization On Startup' preference on every + // Editor restart (including uloop launch -r), so the recovery steps must remind callers + // to re-switch after restart rather than only once. public const string ReleaseCodeOptimizationRecommendedNextAction = "Automatic Debug switch and recompile did not succeed. Confirm Code Optimization is Debug " + "(the bug icon in the main toolbar), run uloop compile, then retry enable-pause-point. " diff --git a/cli/project-runner/internal/projectrunner/pause_point_release_recovery.go b/cli/project-runner/internal/projectrunner/pause_point_release_recovery.go index b272c5cc57..a1c55437f5 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_release_recovery.go +++ b/cli/project-runner/internal/projectrunner/pause_point_release_recovery.go @@ -16,7 +16,7 @@ import ( const pausePointReleaseCodeOptimizationErrorCode = "PAUSE_POINT_RELEASE_CODE_OPTIMIZATION" -const pausePointAutoDebugSwitchWarning = "Code Optimization was Release; switched to Debug and recompiled before arming the pause point. This setting reverts on every Editor restart, and each re-switch costs a full script recompile. Once the current task reaches a natural stopping point, suggest making Debug permanent: with the user's approval, run uloop set-code-optimization debug --startup (machine-wide: applies to every Unity project on this machine; only your project's C# script execution slows down, mainly during Play Mode - the Unity Editor itself is not slowed)." +const pausePointAutoDebugSwitchWarning = "Code Optimization was Release; switched to Debug and recompiled before arming the pause point. On every Editor restart the setting goes back to the 'Code Optimization On Startup' preference (Release unless that preference was changed), and each re-switch costs a full script recompile. Once the current task reaches a natural stopping point, suggest making Debug permanent: with the user's approval, run uloop set-code-optimization debug --startup (machine-wide: applies to every Unity project on this machine; only your project's C# script execution slows down, mainly during Play Mode - the Unity Editor itself is not slowed)." const pausePointRecoveryCompileBusyRetryInterval = 2 * time.Second diff --git a/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go b/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go index 1a47cfd35a..a454895711 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go @@ -15,7 +15,7 @@ import ( // Verifies the automatic Debug-switch warning gives the exact approved persistence guidance. func TestPausePointAutoDebugSwitchWarningRecommendsApprovedStartupCommand(t *testing.T) { - const expected = "Code Optimization was Release; switched to Debug and recompiled before arming the pause point. This setting reverts on every Editor restart, and each re-switch costs a full script recompile. Once the current task reaches a natural stopping point, suggest making Debug permanent: with the user's approval, run uloop set-code-optimization debug --startup (machine-wide: applies to every Unity project on this machine; only your project's C# script execution slows down, mainly during Play Mode - the Unity Editor itself is not slowed)." + const expected = "Code Optimization was Release; switched to Debug and recompiled before arming the pause point. On every Editor restart the setting goes back to the 'Code Optimization On Startup' preference (Release unless that preference was changed), and each re-switch costs a full script recompile. Once the current task reaches a natural stopping point, suggest making Debug permanent: with the user's approval, run uloop set-code-optimization debug --startup (machine-wide: applies to every Unity project on this machine; only your project's C# script execution slows down, mainly during Play Mode - the Unity Editor itself is not slowed)." if pausePointAutoDebugSwitchWarning != expected { t.Fatalf("warning = %q, want %q", pausePointAutoDebugSwitchWarning, expected) } diff --git a/docs/soak-testing.md b/docs/soak-testing.md index 013e6bd671..3acc06cdeb 100644 --- a/docs/soak-testing.md +++ b/docs/soak-testing.md @@ -169,7 +169,8 @@ mode at setup, switches it to Debug if needed, and restores the original mode when the run ends. The mode does **not** survive `uloop launch -r`: an editor restarted mid-soak -comes back on the project's own setting, measured going from Debug to Release +comes back on the per-user 'Code Optimization On Startup' preference (Unity's +EditorPrefs, Release unless changed), measured going from Debug to Release across one restart. Debug is therefore re-applied after every scheduled and recovery restart, the same way the soak scene is rebuilt there. Both switches trigger a full recompile — the setup compile absorbs the first one by retrying past `Compilation is already in progress`, From 00b4a8964ac46713408a86170f03d7f455172ba4 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 10:11:06 +0900 Subject: [PATCH 08/29] feat: Hot reload patches a Multiplayer Play Mode Virtual Player by reading the main project's compiled assemblies (#3238) --- .../references/multiplayer-play-mode.md | 23 +-- .../references/scope-and-limits.md | 2 +- .../references/multiplayer-play-mode.md | 23 +-- .../references/scope-and-limits.md | 2 +- .../HotReload/CompiledAssemblyLayoutTests.cs | 169 ++++++++++++++++++ .../CompiledAssemblyLayoutTests.cs.meta | 11 ++ ...HotReloadCompiledAssemblyPathCheckTests.cs | 117 ++++++++++++ ...loadCompiledAssemblyPathCheckTests.cs.meta | 11 ++ .../Editor/HotReload/HotReloadDomainTests.cs | 3 +- ...HotReloadResolverSearchDirectoriesTests.cs | 40 ++++- .../HotReloadShimReferenceBuilderTests.cs | 38 +++- ...tReloadSnapshotAssemblyEnumerationTests.cs | 40 +++++ .../HotReload/HotReloadTypeHomeTests.cs | 18 ++ .../HotReloadVirtualPlayerProjectTests.cs | 136 ++------------ .../Common/ScriptPath/AssemblyInfo.cs | 1 + .../ScriptPath/CompiledAssemblyLayout.cs | 87 +++++++++ .../ScriptPath/CompiledAssemblyLayout.cs.meta | 11 ++ .../Skill/references/multiplayer-play-mode.md | 23 +-- .../HotReload/HotReloadCallSiteScanner.cs | 6 +- .../HotReloadChangedFileAggregator.cs | 8 +- .../HotReloadCompiledAssemblyPathCheck.cs | 48 +++++ ...HotReloadCompiledAssemblyPathCheck.cs.meta | 11 ++ .../HotReloadGroupWorkerInputBuilder.cs | 1 + .../HotReloadIntroducedTypePreparation.cs | 2 +- .../HotReloadNewSourceMembershipValidator.cs | 5 +- .../HotReload/HotReloadPatchTargetSupport.cs | 22 ++- .../HotReloadResolverSearchDirectories.cs | 23 ++- .../Patching/HotReloadShimReferenceBuilder.cs | 28 ++- .../HotReload/Patching/ReferencePublicizer.cs | 23 +-- ...artyTools.HotReload.Patching.Editor.asmdef | 3 +- .../HotReload/Shared/HotReloadConstants.cs | 2 +- .../Shared/HotReloadSourceSnapshotter.cs | 10 +- .../HotReload/Shared/HotReloadTypeHome.cs | 5 +- .../Shared/HotReloadVirtualPlayerProject.cs | 65 ++----- .../Skill/references/scope-and-limits.md | 2 +- 35 files changed, 746 insertions(+), 273 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs create mode 100644 Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs.meta diff --git a/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md b/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md index b715e2aefe..29e02aa427 100644 --- a/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md +++ b/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md @@ -49,17 +49,18 @@ main Editor) do not block each other. ## Hot reload -- A hot-reload patch lives in the Editor process it was applied to. A patch applied to the - main Editor does not reach the Virtual Players, and a player's own - `uloop --project-path /Library/VP/mppm hot-reload --status` reports no - active patch. -- Hot reload cannot patch a Virtual Player yet: a player loads the main project's - `Library/ScriptAssemblies` and has none under its own root. `hot-reload --files ...` sent - to a player reports the file as `Failed`. Whether the CLI then compiles in that player - follows `--compile-on-skip`, as for any unapplied edit: when it compiles, the edit comes in - (`Outcome` is `ReplacedByCompile`); when the compile is held (`CompileFallback` is - `HeldForPlayMode`: `auto`, the default, while that player is in Play Mode), the edit has not - reached the player. +- A hot-reload patch lives in the Editor process it was applied to. To patch a Virtual + Player, send the command to that player: + `uloop --project-path /Library/VP/mppm hot-reload --files ...`. +- A player reads the main project's `Library/ScriptAssemblies` and keeps its own hot-reload + state (the `ActivePatchTotal` that `--status` reports, and the source snapshots) under its own + `Library/UloopHotReload/`. +- A patch applied to the main Editor does not reach the players, and a patch applied to a player + does not reach the main Editor or the other players. A compile of the main Editor's project + reaches every player, and clears the patches a player holds. +- When the main Editor's project has never compiled the edited assembly, a player reports the + file as `Failed` with `Compiled assembly not found ... Compile the main Editor's project + first.` ## Known limitations diff --git a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md index 25e9f5d6b0..236af49f53 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -430,7 +430,7 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | File does not belong to any compiled assembly | Per-file entry with `Method` = `(file)`; only `Assets/` and `Packages/` sources resolve | | Resolved assembly name is missing from CompilationPipeline | Per-file entry with `Method` = `(file)`; Unity may have mapped a not-yet-imported `.asmdef` onto a predefined assembly. Run `uloop compile` first | | Script is not in the last compiled assembly's source list and its assembly membership cannot be confirmed | Per-file entry with `Method` = `(file)`; a new file passed with `--files` is hot-reloadable when its membership in an existing, unchanged compiled assembly is confirmed (`.asmdef` / `.asmref` boundaries are checked when present; a predefined assembly with none also passes), but fails when the Editor is not ready or an `.asmdef` / `.asmref` on its path was added, deleted, or changed since the last import — run `uloop compile` first | -| The Editor is a Multiplayer Play Mode Virtual Player | Per-file entry with `Method` = `(file)`; a Virtual Player has no compiled assemblies under its own project root, so hot reload cannot patch it yet. The edit reaches that player only through a compile: the CLI's compile fallback brings it in when `--compile-on-skip` lets the compile run, and `auto` holds it while that player is in Play Mode (`CompileFallback` is `HeldForPlayMode`). A patch applied to the main Editor does not reach Virtual Players (each is a separate Editor process) | +| The Editor is a Multiplayer Play Mode Virtual Player and the main Editor's project has not compiled the assembly | Per-file entry with `Method` = `(file)`; a Virtual Player reads the main Editor's `Library/ScriptAssemblies`, and the reason says to compile the main Editor's project first. Until then the edit reaches that player only through a compile: the CLI's compile fallback brings it in when `--compile-on-skip` lets the compile run, and `auto` holds it while that player is in Play Mode (`CompileFallback` is `HeldForPlayMode`). A patch applied to the main Editor does not reach Virtual Players (each is a separate Editor process) | | The Editor is compiling or importing when the request arrives, or starts to before the reload is applied | Per-file entry with `Method` = `(file)`; nothing in the source needs a change. The response sets `RetryAfterEditorReady`, and the CLI waits for the Editor to settle (up to 10 minutes) and applies the same request again once; if Unity's own compile already took the edit in, the second apply reports `NothingToApply`. A second refusal falls through to `--compile-on-skip`. | | Another uloop command still holds the Editor when the request arrives (for example a `uloop compile` sent a moment earlier) | The request is not run; the CLI does not retry against the busy Editor and does not bring it to the front. It waits for that command to finish and the Editor to be ready (up to 10 minutes), then sends the same request once. `EditorReadyRetryNote` names the command it waited for and `Timing.EditorReadyWaitMs` is the wait. A busy answer to that one request is reported as `UNITY_SERVER_BUSY` without another wait. While a cancelled `execute-dynamic-code` request still holds the Editor, the wait sends the request again every 5 seconds, because the Editor takes such a request's slot back only when another request arrives. When the command that ran was a compile that already took the edit in, give `--files`: with no files the Editor selects changed files anew, finds none, and fails validation instead of reporting `NothingToApply` | | Loaded assembly differs from the one on disk (pending compile) | Run `uloop compile` first, then retry | diff --git a/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md b/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md index b715e2aefe..29e02aa427 100644 --- a/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md +++ b/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md @@ -49,17 +49,18 @@ main Editor) do not block each other. ## Hot reload -- A hot-reload patch lives in the Editor process it was applied to. A patch applied to the - main Editor does not reach the Virtual Players, and a player's own - `uloop --project-path /Library/VP/mppm hot-reload --status` reports no - active patch. -- Hot reload cannot patch a Virtual Player yet: a player loads the main project's - `Library/ScriptAssemblies` and has none under its own root. `hot-reload --files ...` sent - to a player reports the file as `Failed`. Whether the CLI then compiles in that player - follows `--compile-on-skip`, as for any unapplied edit: when it compiles, the edit comes in - (`Outcome` is `ReplacedByCompile`); when the compile is held (`CompileFallback` is - `HeldForPlayMode`: `auto`, the default, while that player is in Play Mode), the edit has not - reached the player. +- A hot-reload patch lives in the Editor process it was applied to. To patch a Virtual + Player, send the command to that player: + `uloop --project-path /Library/VP/mppm hot-reload --files ...`. +- A player reads the main project's `Library/ScriptAssemblies` and keeps its own hot-reload + state (the `ActivePatchTotal` that `--status` reports, and the source snapshots) under its own + `Library/UloopHotReload/`. +- A patch applied to the main Editor does not reach the players, and a patch applied to a player + does not reach the main Editor or the other players. A compile of the main Editor's project + reaches every player, and clears the patches a player holds. +- When the main Editor's project has never compiled the edited assembly, a player reports the + file as `Failed` with `Compiled assembly not found ... Compile the main Editor's project + first.` ## Known limitations diff --git a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md index 25e9f5d6b0..236af49f53 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -430,7 +430,7 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | File does not belong to any compiled assembly | Per-file entry with `Method` = `(file)`; only `Assets/` and `Packages/` sources resolve | | Resolved assembly name is missing from CompilationPipeline | Per-file entry with `Method` = `(file)`; Unity may have mapped a not-yet-imported `.asmdef` onto a predefined assembly. Run `uloop compile` first | | Script is not in the last compiled assembly's source list and its assembly membership cannot be confirmed | Per-file entry with `Method` = `(file)`; a new file passed with `--files` is hot-reloadable when its membership in an existing, unchanged compiled assembly is confirmed (`.asmdef` / `.asmref` boundaries are checked when present; a predefined assembly with none also passes), but fails when the Editor is not ready or an `.asmdef` / `.asmref` on its path was added, deleted, or changed since the last import — run `uloop compile` first | -| The Editor is a Multiplayer Play Mode Virtual Player | Per-file entry with `Method` = `(file)`; a Virtual Player has no compiled assemblies under its own project root, so hot reload cannot patch it yet. The edit reaches that player only through a compile: the CLI's compile fallback brings it in when `--compile-on-skip` lets the compile run, and `auto` holds it while that player is in Play Mode (`CompileFallback` is `HeldForPlayMode`). A patch applied to the main Editor does not reach Virtual Players (each is a separate Editor process) | +| The Editor is a Multiplayer Play Mode Virtual Player and the main Editor's project has not compiled the assembly | Per-file entry with `Method` = `(file)`; a Virtual Player reads the main Editor's `Library/ScriptAssemblies`, and the reason says to compile the main Editor's project first. Until then the edit reaches that player only through a compile: the CLI's compile fallback brings it in when `--compile-on-skip` lets the compile run, and `auto` holds it while that player is in Play Mode (`CompileFallback` is `HeldForPlayMode`). A patch applied to the main Editor does not reach Virtual Players (each is a separate Editor process) | | The Editor is compiling or importing when the request arrives, or starts to before the reload is applied | Per-file entry with `Method` = `(file)`; nothing in the source needs a change. The response sets `RetryAfterEditorReady`, and the CLI waits for the Editor to settle (up to 10 minutes) and applies the same request again once; if Unity's own compile already took the edit in, the second apply reports `NothingToApply`. A second refusal falls through to `--compile-on-skip`. | | Another uloop command still holds the Editor when the request arrives (for example a `uloop compile` sent a moment earlier) | The request is not run; the CLI does not retry against the busy Editor and does not bring it to the front. It waits for that command to finish and the Editor to be ready (up to 10 minutes), then sends the same request once. `EditorReadyRetryNote` names the command it waited for and `Timing.EditorReadyWaitMs` is the wait. A busy answer to that one request is reported as `UNITY_SERVER_BUSY` without another wait. While a cancelled `execute-dynamic-code` request still holds the Editor, the wait sends the request again every 5 seconds, because the Editor takes such a request's slot back only when another request arrives. When the command that ran was a compile that already took the edit in, give `--files`: with no files the Editor selects changed files anew, finds none, and fails validation instead of reporting `NothingToApply` | | Loaded assembly differs from the one on disk (pending compile) | Run `uloop compile` first, then retry | diff --git a/Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs b/Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs new file mode 100644 index 0000000000..7fafd9ee3e --- /dev/null +++ b/Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs @@ -0,0 +1,169 @@ +using System; +using System.IO; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers where the compiled assemblies of a project root live: under the root itself for an + /// ordinary project, and under the main project for a Multiplayer Play Mode Virtual Player. + /// + public sealed class CompiledAssemblyLayoutTests + { + // Why under the temp directory: Resolve makes the root absolute, so an absolute fixture keeps + // the expected values independent of the current directory. Nothing is created on disk. + private string _workspace; + + [SetUp] + public void SetUp() + { + _workspace = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N")); + } + + private static string Full(params string[] parts) + { + return Path.GetFullPath(Path.Combine(parts)); + } + + /// + /// What: an ordinary project reads its compiled assemblies from its own Library/ScriptAssemblies. + /// + [Test] + public void Resolve_OrdinaryRoot_ReadsItsOwnScriptAssemblies() + { + string projectRoot = Path.Combine(_workspace, "project"); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + + string expectedDirectory = Full(projectRoot, "Library", "ScriptAssemblies"); + Assert.That(layout.IsVirtualPlayer, Is.False); + Assert.That(layout.ProjectRoot, Is.EqualTo(Full(projectRoot))); + Assert.That(layout.MainProjectRoot, Is.EqualTo(layout.ProjectRoot)); + Assert.That(layout.CompiledAssembliesDirectory, Is.EqualTo(expectedDirectory)); + Assert.That(layout.DllPath("A"), Is.EqualTo(Path.Combine(expectedDirectory, "A.dll"))); + Assert.That(layout.PdbPath("A"), Is.EqualTo(Path.Combine(expectedDirectory, "A.pdb"))); + } + + /// + /// What: a Virtual Player root reads the main project's Library/ScriptAssemblies and keeps + /// its own root as ProjectRoot. + /// + [Test] + public void Resolve_VirtualPlayerRoot_ReadsTheMainProjectsScriptAssemblies() + { + string mainRoot = Path.Combine(_workspace, "project"); + string playerRoot = Path.Combine(mainRoot, "Library", "VP", "mppm1"); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(playerRoot); + + string expectedDirectory = Full(mainRoot, "Library", "ScriptAssemblies"); + Assert.That(layout.IsVirtualPlayer, Is.True); + Assert.That(layout.ProjectRoot, Is.EqualTo(Full(playerRoot))); + Assert.That(layout.MainProjectRoot, Is.EqualTo(Full(mainRoot))); + Assert.That(layout.CompiledAssembliesDirectory, Is.EqualTo(expectedDirectory)); + Assert.That(layout.DllPath("A"), Is.EqualTo(Path.Combine(expectedDirectory, "A.dll"))); + Assert.That(layout.PdbPath("A"), Is.EqualTo(Path.Combine(expectedDirectory, "A.pdb"))); + } + + /// + /// What: a trailing separator on a Virtual Player root gives the same layout, and ProjectRoot + /// does not keep the separator. + /// + [Test] + public void Resolve_VirtualPlayerRootWithTrailingSeparator_GivesTheSameLayout() + { + string mainRoot = Path.Combine(_workspace, "project"); + string playerRoot = Path.Combine(mainRoot, "Library", "VP", "mppm1"); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(playerRoot + Path.DirectorySeparatorChar); + + Assert.That(layout.IsVirtualPlayer, Is.True); + Assert.That(layout.ProjectRoot, Is.EqualTo(Full(playerRoot))); + Assert.That(layout.MainProjectRoot, Is.EqualTo(Full(mainRoot))); + Assert.That(layout.CompiledAssembliesDirectory, Is.EqualTo(Full(mainRoot, "Library", "ScriptAssemblies"))); + } + + /// + /// What: roots that only look like a Virtual Player root (no Library, a lowercase vp, the VP + /// directory itself, a parent other than VP, a grandparent other than Library) are ordinary + /// projects that read their own Library/ScriptAssemblies. + /// + [TestCase("VP", "mppm1")] + [TestCase("Library", "vp", "mppm1")] + [TestCase("Library", "VP")] + [TestCase("Library", "Other", "mppm1")] + [TestCase("Other", "VP", "mppm1")] + public void Resolve_LookalikeRoot_IsAnOrdinaryProject(params string[] relativeParts) + { + string[] parts = new string[relativeParts.Length + 1]; + parts[0] = Path.Combine(_workspace, "project"); + Array.Copy(relativeParts, 0, parts, 1, relativeParts.Length); + string projectRoot = Path.Combine(parts); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + + Assert.That(layout.IsVirtualPlayer, Is.False); + Assert.That(layout.MainProjectRoot, Is.EqualTo(Full(projectRoot))); + Assert.That(layout.CompiledAssembliesDirectory, Is.EqualTo(Full(projectRoot, "Library", "ScriptAssemblies"))); + } + + /// + /// What: a relative Virtual Player root is made absolute before it is recognized. + /// + [Test] + public void Resolve_RelativeVirtualPlayerRoot_IsRecognizedAndMadeAbsolute() + { + string playerRoot = Path.Combine("workspace", "project", "Library", "VP", "mppm1"); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(playerRoot); + + Assert.That(layout.IsVirtualPlayer, Is.True); + Assert.That(layout.ProjectRoot, Is.EqualTo(Path.GetFullPath(playerRoot))); + Assert.That(Path.IsPathRooted(layout.ProjectRoot), Is.True); + Assert.That( + layout.CompiledAssembliesDirectory, + Is.EqualTo(Full("workspace", "project", "Library", "ScriptAssemblies"))); + } + + /// + /// What: a root with no parent (a drive or "/" alone) is an ordinary project and stays as given, + /// instead of throwing. + /// + [Test] + public void Resolve_RootWithoutAParent_IsAnOrdinaryProject() + { + string fileSystemRoot = Path.GetPathRoot(Path.GetTempPath()); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(fileSystemRoot); + + Assert.That(layout.IsVirtualPlayer, Is.False); + Assert.That(layout.ProjectRoot, Is.EqualTo(fileSystemRoot)); + Assert.That(layout.MainProjectRoot, Is.EqualTo(fileSystemRoot)); + } + + /// + /// What: on Windows, a Virtual Player root is recognized with backslashes and with forward slashes. + /// + [Test] + public void Resolve_WindowsSeparators_RecognizeAVirtualPlayer() + { + if (Path.DirectorySeparatorChar != '\\') + { + Assert.Pass("Windows separator handling applies only on Windows."); + return; + } + + Assert.That( + CompiledAssemblyLayout.Resolve(@"C:\workspace\project\Library\VP\mppm1").IsVirtualPlayer, + Is.True, + "Backslash separators must be recognized."); + Assert.That( + CompiledAssemblyLayout.Resolve("C:/workspace/project/Library/VP/mppm1").IsVirtualPlayer, + Is.True, + "Forward-slash separators must be recognized."); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs.meta b/Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs.meta new file mode 100644 index 0000000000..d01d9b92ec --- /dev/null +++ b/Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8bfdafdffafc544ef8436062ef170430 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs new file mode 100644 index 0000000000..e01d6a833c --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs @@ -0,0 +1,117 @@ +using System; +using System.IO; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers the check that the output path the compilation pipeline reports for an assembly is + /// the DLL hot reload reads, for an ordinary project and for a Multiplayer Play Mode Virtual Player. + /// + public sealed class HotReloadCompiledAssemblyPathCheckTests + { + private string _mainRoot; + private string _playerRoot; + + [SetUp] + public void SetUp() + { + string workspace = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N")); + _mainRoot = Path.Combine(workspace, "project"); + _playerRoot = Path.Combine(_mainRoot, "Library", "VP", "mppm1"); + } + + /// + /// What: an ordinary project whose pipeline reports Library/ScriptAssemblies/A.dll passes. + /// + [Test] + public void DescribeOutputPathMismatch_OrdinaryRootReportingItsScriptAssemblies_ReturnsNull() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_mainRoot); + + Assert.That( + HotReloadCompiledAssemblyPathCheck.DescribeOutputPathMismatch(layout, "A", "Library/ScriptAssemblies/A.dll"), + Is.Null); + } + + /// + /// What: a Virtual Player whose pipeline reports ../../ScriptAssemblies/A.dll, the main + /// project's DLL, passes. + /// + [Test] + public void DescribeOutputPathMismatch_VirtualPlayerReportingTheMainProjectsDll_ReturnsNull() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_playerRoot); + + Assert.That( + HotReloadCompiledAssemblyPathCheck.DescribeOutputPathMismatch(layout, "A", "../../ScriptAssemblies/A.dll"), + Is.Null); + } + + /// + /// What: a Virtual Player whose pipeline reports a DLL under its own root fails as a Virtual + /// Player's missing assembly, naming both the reported path and the directory hot reload reads. + /// + [Test] + public void DescribeOutputPathMismatch_VirtualPlayerReportingItsOwnLibrary_FailsNamingBothPaths() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_playerRoot); + + HotReloadFailureDescription failure = + HotReloadCompiledAssemblyPathCheck.DescribeOutputPathMismatch(layout, "A", "Library/ScriptAssemblies/A.dll"); + + Assert.That(failure, Is.Not.Null); + Assert.That( + failure.Kinds, + Is.EqualTo(HotReloadFailureKinds.CompiledAssemblyMissing | HotReloadFailureKinds.VirtualPlayer)); + Assert.That( + failure.Message, + Does.Contain(Path.GetFullPath(Path.Combine(_playerRoot, "Library", "ScriptAssemblies", "A.dll")))); + Assert.That(failure.Message, Does.Contain(layout.CompiledAssembliesDirectory)); + } + + /// + /// What: an absolute output path that names the DLL hot reload reads passes. + /// + [Test] + public void DescribeOutputPathMismatch_AbsoluteMatchingPath_ReturnsNull() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_mainRoot); + + Assert.That( + HotReloadCompiledAssemblyPathCheck.DescribeOutputPathMismatch(layout, "A", layout.DllPath("A")), + Is.Null); + } + + /// + /// What: an ordinary project whose pipeline reports another assembly's DLL fails as a missing + /// assembly that is not a Virtual Player's. + /// + [Test] + public void DescribeOutputPathMismatch_OrdinaryRootReportingAnotherAssembly_Fails() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_mainRoot); + + HotReloadFailureDescription failure = + HotReloadCompiledAssemblyPathCheck.DescribeOutputPathMismatch(layout, "A", "Library/ScriptAssemblies/B.dll"); + + Assert.That(failure, Is.Not.Null); + Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.CompiledAssemblyMissing)); + } + + /// + /// What: when the pipeline reports no output path, nothing is compared. + /// + [TestCase("")] + [TestCase(null)] + public void DescribeOutputPathMismatch_NoOutputPath_ReturnsNull(string outputPath) + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_mainRoot); + + Assert.That(HotReloadCompiledAssemblyPathCheck.DescribeOutputPathMismatch(layout, "A", outputPath), Is.Null); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs.meta new file mode 100644 index 0000000000..a169ea0562 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a21ed7923f0e54fb2859cccf400141ee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs index d2767321dd..8a85bc8644 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs @@ -617,7 +617,8 @@ public void ResolveTypeHome_NoActiveArtifactForTheName_ReturnsTheScriptAssemblie home.DllPath, Is.EqualTo(Path.Combine( projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, + "Library", + "ScriptAssemblies", ProjectAssemblyName + HotReloadConstants.CompiledAssemblyExtension))); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs index f5bbaf592d..028329b38c 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs @@ -64,7 +64,7 @@ public void Collect_IncludesDirectoriesOfTransitivelyReferencedPrecompiledAssemb string pluginPath = CreateEmptyFile("plugins", "Plugin.dll"); ReferenceGraph graph = CreateReferenceGraph(pluginPath, Array.Empty()); - IReadOnlyCollection directories = HotReloadResolverSearchDirectories.Collect(graph.Tests); + IReadOnlyCollection directories = HotReloadResolverSearchDirectories.Collect(_tempRoot, graph.Tests); Assert.That(directories, Does.Contain(FullDirectoryOf(graph.Tests.outputPath))); Assert.That(directories, Does.Contain(FullDirectoryOf(pluginPath))); @@ -89,7 +89,7 @@ public void Collect_OrdersOwnReferencesBeforeTransitiveOnes() string ownPath = CreateEmptyFile("own", "Own.dll"); ReferenceGraph graph = CreateReferenceGraph(pluginPath, new[] { ownPath }); - List directories = new List(HotReloadResolverSearchDirectories.Collect(graph.Tests)); + List directories = new List(HotReloadResolverSearchDirectories.Collect(_tempRoot, graph.Tests)); int ownIndex = directories.IndexOf(FullDirectoryOf(ownPath)); int pluginIndex = directories.IndexOf(FullDirectoryOf(pluginPath)); @@ -112,7 +112,9 @@ public void Collect_ForHotReloadTestAssembly_ReachesTheCodeAnalysisPluginDirecto Does.Not.Contain(pluginDirectory), "Unity listed the plugin among the test assembly's own references, so the transitive walk is not what reaches it."); - Assert.That(HotReloadResolverSearchDirectories.Collect(testAssembly), Does.Contain(pluginDirectory)); + Assert.That(HotReloadResolverSearchDirectories.Collect( + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + testAssembly), Does.Contain(pluginDirectory)); } /// @@ -140,7 +142,7 @@ public void GetOrCreatePublicizedCopy_ResolvesAnAssemblyThatOnlyATransitiveRefer string publicized = ReferencePublicizer.GetOrCreatePublicizedCopy( image.Home, - HotReloadResolverSearchDirectories.Collect(graph.Tests)); + HotReloadResolverSearchDirectories.Collect(_tempRoot, graph.Tests)); Assert.That(File.Exists(publicized), Is.True); } @@ -174,6 +176,36 @@ public void TryBuildShimReferencePaths_PublicizesTheTargetWithTransitiveSearchDi Assert.That(File.Exists(result.References[0]), Is.True); } + /// + /// Verifies a reference Unity lists relative to a Virtual Player's root, as it does for the + /// main project's script assemblies, is resolved against that root and not the current + /// directory, so its directory becomes a search directory. + /// + [Test] + public void Collect_ResolvesARelativeReferenceAgainstTheProjectRoot() + { + const string relativeReference = "../../ScriptAssemblies/Fixture.Other.dll"; + string scriptAssembliesPath = CreateEmptyFile(Path.Combine("Library", "ScriptAssemblies"), "Fixture.Other.dll"); + string playerRoot = Path.Combine(_tempRoot, "Library", "VP", "mppm1"); + Directory.CreateDirectory(playerRoot); + Assert.That( + File.Exists(relativeReference), + Is.False, + "The reference must not exist relative to the current directory, or the test cannot tell the two apart."); + UnityCompilationAssembly root = new UnityCompilationAssembly( + "Tests", + ScriptPath("Tests.dll"), + Array.Empty(), + Array.Empty(), + Array.Empty(), + new[] { relativeReference }, + AssemblyFlags.EditorAssembly); + + IReadOnlyCollection directories = HotReloadResolverSearchDirectories.Collect(playerRoot, root); + + Assert.That(directories, Does.Contain(FullDirectoryOf(scriptAssembliesPath))); + } + // Tests -> Game -> Core -> plugin: two steps, so a walk that adds only the direct // reference's own references still misses the plugin. private ReferenceGraph CreateReferenceGraph(string pluginPath, string[] testsOwnReferences) diff --git a/Assets/Tests/Editor/HotReload/HotReloadShimReferenceBuilderTests.cs b/Assets/Tests/Editor/HotReload/HotReloadShimReferenceBuilderTests.cs index 1489a2716f..9865d5d291 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadShimReferenceBuilderTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadShimReferenceBuilderTests.cs @@ -57,11 +57,47 @@ public void BuildWorkerReferencePaths_WhenReferencesAlreadyContainTarget_ListsTa AssemblyFlags.None); HotReloadTypeHome targetHome = HotReloadTypeHome.ScriptAssemblies("Fixture.Target", targetPath); - string[] paths = HotReloadShimReferenceBuilder.BuildWorkerReferencePaths(compilationAssembly, targetHome); + string[] paths = HotReloadShimReferenceBuilder.BuildWorkerReferencePaths(_tempRoot, compilationAssembly, targetHome); Assert.That(paths, Is.EqualTo(new[] { Path.GetFullPath(otherPath), Path.GetFullPath(targetPath) })); } + /// + /// Verifies that a reference Unity lists relative to a Virtual Player's root, as it does for + /// the main project's script assemblies, is resolved against that root and not the current + /// directory. + /// + [Test] + public void BuildWorkerReferencePaths_RelativeReference_IsResolvedAgainstTheProjectRoot() + { + const string relativeReference = "../../ScriptAssemblies/Fixture.Other.dll"; + string scriptAssembliesDirectory = Path.Combine(_tempRoot, "Library", "ScriptAssemblies"); + string otherPath = Path.Combine(scriptAssembliesDirectory, "Fixture.Other.dll"); + string targetPath = Path.Combine(_tempRoot, "Fixture.Target.dll"); + string playerRoot = Path.Combine(_tempRoot, "Library", "VP", "mppm1"); + Directory.CreateDirectory(scriptAssembliesDirectory); + Directory.CreateDirectory(playerRoot); + File.WriteAllBytes(otherPath, new byte[] { 0 }); + File.WriteAllBytes(targetPath, new byte[] { 0 }); + Assert.That( + File.Exists(relativeReference), + Is.False, + "The reference must not exist relative to the current directory, or the test cannot tell the two apart."); + UnityCompilationAssembly compilationAssembly = new UnityCompilationAssembly( + "Fixture.Target", + targetPath, + Array.Empty(), + Array.Empty(), + Array.Empty(), + new[] { relativeReference, targetPath }, + AssemblyFlags.None); + HotReloadTypeHome targetHome = HotReloadTypeHome.ScriptAssemblies("Fixture.Target", targetPath); + + string[] paths = HotReloadShimReferenceBuilder.BuildWorkerReferencePaths(playerRoot, compilationAssembly, targetHome); + + Assert.That(paths, Does.Contain(Path.GetFullPath(otherPath))); + } + /// /// Verifies that a null home and a home whose DLL does not exist add no publicized reference. /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs index 3635427ef5..f2db96c95f 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs @@ -157,6 +157,46 @@ public void CollectSnapshotAssemblies_KeepsOnlyAssembliesWithSourcesAndCompiledO Assert.That(result[0].SourceFiles, Is.EqualTo(new[] { SourceRelativePath })); } + /// + /// Verifies a Virtual Player's capture reads the main project's compiled assembly and writes + /// the snapshot under the player's own root, leaving no hot reload state under the main project. + /// + [Test] + public void CaptureAssemblies_ForVirtualPlayerRoot_ReadsTheMainProjectsDllAndSnapshotsUnderThePlayer() + { + string mainRoot = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N")); + try + { + string scriptAssembliesDirectory = Path.Combine(mainRoot, "Library", "ScriptAssemblies"); + Directory.CreateDirectory(scriptAssembliesDirectory); + string sourceDllPath = typeof(HotReloadSnapshotAssemblyEnumerationTests).Assembly.Location; + string dllPath = Path.Combine(scriptAssembliesDirectory, "Fixture" + HotReloadConstants.CompiledAssemblyExtension); + File.Copy(sourceDllPath, dllPath); + File.Copy(Path.ChangeExtension(sourceDllPath, ".pdb"), Path.ChangeExtension(dllPath, ".pdb")); + string playerRoot = Path.Combine(mainRoot, "Library", "VP", "mppm1"); + Directory.CreateDirectory(Path.Combine(playerRoot, "Assets")); + File.WriteAllText(Path.Combine(playerRoot, "Assets", "Fixture.cs"), "class Fixture {}\n"); + string mvid = HotReloadSourceSnapshotter.ReadAssemblyMvid(dllPath); + + HotReloadSourceSnapshotter.CaptureAssemblies(playerRoot, new[] { CreateAssembly("Fixture") }); + + string playerSnapshotSource = Path.Combine( + playerRoot, + HotReloadConstants.SourceSnapshotRelativeDirectory, + "Fixture-" + mvid, + HotReloadSourceSnapshotter.HashProjectRelativePath(SourceRelativePath) + ".cs"); + Assert.That(File.Exists(playerSnapshotSource), Is.True); + Assert.That(Directory.Exists(Path.Combine(mainRoot, "Library", "UloopHotReload")), Is.False); + } + finally + { + if (Directory.Exists(mainRoot)) + { + Directory.Delete(mainRoot, true); + } + } + } + // Copies this test assembly's own DLL and PDB, so the fixture has a real image for Cecil to read. private string PlantCompiledAssembly(string assemblyName) { diff --git a/Assets/Tests/Editor/HotReload/HotReloadTypeHomeTests.cs b/Assets/Tests/Editor/HotReload/HotReloadTypeHomeTests.cs index 4312f77273..319b0019fd 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadTypeHomeTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadTypeHomeTests.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Reflection; @@ -34,6 +35,23 @@ private static string TestAssemblyDllPath } } + /// + /// What: a Virtual Player root resolves its script assemblies under the main project. + /// + [Test] + public void ScriptAssembliesUnderProject_VirtualPlayerRoot_PointsAtTheMainProjectsCompiledAssembly() + { + string mainRoot = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N")); + string playerRoot = Path.Combine(mainRoot, "Library", "VP", "mppm1"); + + HotReloadTypeHome home = HotReloadTypeHome.ScriptAssembliesUnderProject(playerRoot, "A"); + + Assert.That(home.Kind, Is.EqualTo(HotReloadTypeHomeKind.ScriptAssemblies)); + Assert.That( + home.DllPath, + Is.EqualTo(Path.GetFullPath(Path.Combine(mainRoot, "Library", "ScriptAssemblies", "A.dll")))); + } + /// /// What: a ScriptAssemblies home whose compiled Mvid matches the live assembly resolves to that assembly. /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs index 268d3b5946..52f1a7c94b 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs @@ -7,8 +7,8 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// - /// EditMode coverage for recognizing a Multiplayer Play Mode Virtual Player by its project root, - /// and for the missing-assembly reason worded for it. + /// EditMode coverage for the missing-assembly reason worded for an ordinary project and for a + /// Multiplayer Play Mode Virtual Player. /// public class HotReloadVirtualPlayerProjectTests { @@ -18,130 +18,18 @@ public class HotReloadVirtualPlayerProjectTests private static readonly string OrdinaryProjectRoot = Path.Combine("workspace", "project"); /// - /// What: a player directory directly under Library/VP is recognized as a Virtual Player root. + /// What: for a Virtual Player, the reason says the main Editor's project has not compiled the + /// assembly and asks for that project to be compiled, instead of this one, and the failure is + /// marked as a Virtual Player's so the next step names the main Editor's project. /// [Test] - public void IsVirtualPlayerProjectRoot_PlayerDirectoryUnderLibraryVP_ReturnsTrue() + public void DescribeMissingCompiledAssembly_VirtualPlayerRoot_SaysTheMainProjectHasNotCompiledTheAssembly() { - Assert.That(HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(VirtualPlayerRoot), Is.True); - } - - /// - /// What: a trailing separator does not shift the parent lookup up by one directory. - /// - [Test] - public void IsVirtualPlayerProjectRoot_TrailingSeparator_ReturnsTrue() - { - string rootWithTrailingSeparator = VirtualPlayerRoot + Path.DirectorySeparatorChar; - - Assert.That( - HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(rootWithTrailingSeparator), - Is.True); - } - - /// - /// What: an ordinary project root is not taken for a Virtual Player. - /// - [Test] - public void IsVirtualPlayerProjectRoot_OrdinaryProjectRoot_ReturnsFalse() - { - Assert.That(HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(OrdinaryProjectRoot), Is.False); - } - - /// - /// What: the Library/VP directory itself is not a Virtual Player root. - /// - [Test] - public void IsVirtualPlayerProjectRoot_TheVPDirectoryItself_ReturnsFalse() - { - string virtualPlayersDirectory = Path.Combine("workspace", "project", "Library", "VP"); - - Assert.That( - HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(virtualPlayersDirectory), - Is.False); - } - - /// - /// What: a directory under Library whose parent is not named VP is not a Virtual Player root. - /// - [Test] - public void IsVirtualPlayerProjectRoot_ParentIsNotVP_ReturnsFalse() - { - string projectRoot = Path.Combine("workspace", "project", "Library", "Other", "mppm0a1b2c3d"); - - Assert.That(HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(projectRoot), Is.False); - } - - /// - /// What: a directory under a VP directory that is not inside Library is not a Virtual Player root. - /// - [Test] - public void IsVirtualPlayerProjectRoot_GrandparentIsNotLibrary_ReturnsFalse() - { - string projectRoot = Path.Combine("workspace", "project", "Other", "VP", "mppm0a1b2c3d"); - - Assert.That(HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(projectRoot), Is.False); - } - - /// - /// What: a path without a parent or without a grandparent, including one made only of a - /// separator, is answered false instead of throwing. - /// - [Test] - public void IsVirtualPlayerProjectRoot_PathTooShortToHaveAGrandparent_ReturnsFalse() - { - string playerDirectoryOnly = "mppm0a1b2c3d"; - string virtualPlayersAndPlayerDirectory = Path.Combine("VP", "mppm0a1b2c3d"); - string separatorOnly = Path.DirectorySeparatorChar.ToString(); - - Assert.That( - HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(playerDirectoryOnly), - Is.False, - "A path without a parent must not be taken for a Virtual Player root."); - Assert.That( - HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(virtualPlayersAndPlayerDirectory), - Is.False, - "A path without a grandparent must not be taken for a Virtual Player root."); - Assert.That( - HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(separatorOnly), - Is.False, - "A path made only of a separator must not be taken for a Virtual Player root."); - } - - /// - /// What: on Windows, a Virtual Player root is recognized with backslashes and with forward slashes. - /// - [Test] - public void IsVirtualPlayerProjectRoot_WindowsSeparators_ReturnsTrue() - { - if (Path.DirectorySeparatorChar != '\\') - { - Assert.Pass("Windows separator handling applies only on Windows."); - return; - } - - Assert.That( - HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(@"C:\workspace\project\Library\VP\mppm0a1b2c3d"), - Is.True, - "Backslash separators must be recognized."); - Assert.That( - HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot("C:/workspace/project/Library/VP/mppm0a1b2c3d"), - Is.True, - "Forward-slash separators must be recognized."); - } - - /// - /// What: for a Virtual Player, the reason says hot reload cannot patch it and that a compile - /// brings the edit in, instead of asking for a compile first, and the failure is marked as a - /// Virtual Player's so the next step names the main Editor's project. - /// - [Test] - public void DescribeMissingCompiledAssembly_VirtualPlayerRoot_SaysHotReloadCannotPatchAVirtualPlayer() - { - string dllPath = Path.Combine(VirtualPlayerRoot, "Library", "ScriptAssemblies", "Sample.dll"); + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(VirtualPlayerRoot); + string dllPath = layout.DllPath("Sample"); HotReloadFailureDescription failure = - HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(VirtualPlayerRoot, dllPath); + HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(layout, dllPath); string reason = failure.Message; Assert.That( @@ -152,6 +40,8 @@ public void DescribeMissingCompiledAssembly_VirtualPlayerRoot_SaysHotReloadCanno Assert.That(reason, Does.Contain("main Editor")); Assert.That(reason, Does.Contain("compile")); Assert.That(reason, Does.Not.Contain("Compile the project first")); + Assert.That(reason, Does.Contain("Compile the main Editor's project first")); + Assert.That(reason, Does.Contain("has not compiled")); } /// @@ -164,7 +54,9 @@ public void DescribeMissingCompiledAssembly_OrdinaryRoot_KeepsTheCompileFirstTex string dllPath = Path.Combine(OrdinaryProjectRoot, "Library", "ScriptAssemblies", "Sample.dll"); HotReloadFailureDescription failure = - HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(OrdinaryProjectRoot, dllPath); + HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly( + CompiledAssemblyLayout.Resolve(OrdinaryProjectRoot), + dllPath); Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.CompiledAssemblyMissing)); Assert.That( diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/AssemblyInfo.cs index 8f57186ca2..ee1a792cc6 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/AssemblyInfo.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/AssemblyInfo.cs @@ -2,6 +2,7 @@ [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.HotReload.Shared.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.HotReload.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.HotReload.Patching.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.PausePoint.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Infrastructure")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor.HotReload")] diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs new file mode 100644 index 0000000000..5074a162f4 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Decides where the compiled assemblies of a project root live. An ordinary project reads its + /// own Library/ScriptAssemblies; a Multiplayer Play Mode Virtual Player, whose root is + /// <main project>/Library/VP/<player>, reads the main project's, because it has none + /// of its own. + /// + internal sealed class CompiledAssemblyLayout + { + // Why these are spelled here: this assembly cannot reference hot reload's constants, since + // the dependency runs from hot reload to this assembly only. + private const string LibraryDirectoryName = "Library"; + private const string VirtualPlayersDirectoryName = "VP"; + private const string ScriptAssembliesDirectoryName = "ScriptAssemblies"; + + private CompiledAssemblyLayout( + string projectRoot, + bool isVirtualPlayer, + string mainProjectRoot, + string compiledAssembliesDirectory) + { + ProjectRoot = projectRoot; + IsVirtualPlayer = isVirtualPlayer; + MainProjectRoot = mainProjectRoot; + CompiledAssembliesDirectory = compiledAssembliesDirectory; + } + + /// The project root made absolute, without a trailing separator. + internal string ProjectRoot { get; } + + /// True when ProjectRoot is a Multiplayer Play Mode Virtual Player's root. + internal bool IsVirtualPlayer { get; } + + /// The main project's root for a Virtual Player; ProjectRoot otherwise. + internal string MainProjectRoot { get; } + + /// The absolute directory the project's compiled assemblies are read from. + internal string CompiledAssembliesDirectory { get; } + + internal static CompiledAssemblyLayout Resolve(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + + string fullRoot = Path.GetFullPath(projectRoot); + // Why trim first: with a trailing separator, Path.GetDirectoryName returns the same + // directory, so every parent lookup below would land one level too low. A path root ("/" + // or "C:\") stays as it is, because trimming it would leave "" or the drive-relative "C:". + bool isPathRoot = string.Equals(Path.GetPathRoot(fullRoot), fullRoot, StringComparison.Ordinal); + string root = isPathRoot + ? fullRoot + : fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string virtualPlayersDirectory = Path.GetDirectoryName(root); + string libraryDirectory = string.IsNullOrEmpty(virtualPlayersDirectory) + ? null + : Path.GetDirectoryName(virtualPlayersDirectory); + string mainRoot = string.IsNullOrEmpty(libraryDirectory) ? null : Path.GetDirectoryName(libraryDirectory); + // Why a main root is required: /Library/VP/x has no project above Library to read from. + bool isVirtualPlayer = !string.IsNullOrEmpty(mainRoot) + && string.Equals(Path.GetFileName(virtualPlayersDirectory), VirtualPlayersDirectoryName, StringComparison.Ordinal) + && string.Equals(Path.GetFileName(libraryDirectory), LibraryDirectoryName, StringComparison.Ordinal); + string compiledAssembliesOwner = isVirtualPlayer ? mainRoot : root; + return new CompiledAssemblyLayout( + root, + isVirtualPlayer, + compiledAssembliesOwner, + Path.Combine(compiledAssembliesOwner, LibraryDirectoryName, ScriptAssembliesDirectoryName)); + } + + internal string DllPath(string assemblyName) + { + Debug.Assert(!string.IsNullOrEmpty(assemblyName), "assemblyName must not be null or empty."); + return Path.Combine(CompiledAssembliesDirectory, assemblyName + ".dll"); + } + + internal string PdbPath(string assemblyName) + { + Debug.Assert(!string.IsNullOrEmpty(assemblyName), "assemblyName must not be null or empty."); + return Path.Combine(CompiledAssembliesDirectory, assemblyName + ".pdb"); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs.meta new file mode 100644 index 0000000000..5b7e60449f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6e5ee652390f74fdaa571cb77458b0d9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md index b715e2aefe..29e02aa427 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md @@ -49,17 +49,18 @@ main Editor) do not block each other. ## Hot reload -- A hot-reload patch lives in the Editor process it was applied to. A patch applied to the - main Editor does not reach the Virtual Players, and a player's own - `uloop --project-path /Library/VP/mppm hot-reload --status` reports no - active patch. -- Hot reload cannot patch a Virtual Player yet: a player loads the main project's - `Library/ScriptAssemblies` and has none under its own root. `hot-reload --files ...` sent - to a player reports the file as `Failed`. Whether the CLI then compiles in that player - follows `--compile-on-skip`, as for any unapplied edit: when it compiles, the edit comes in - (`Outcome` is `ReplacedByCompile`); when the compile is held (`CompileFallback` is - `HeldForPlayMode`: `auto`, the default, while that player is in Play Mode), the edit has not - reached the player. +- A hot-reload patch lives in the Editor process it was applied to. To patch a Virtual + Player, send the command to that player: + `uloop --project-path /Library/VP/mppm hot-reload --files ...`. +- A player reads the main project's `Library/ScriptAssemblies` and keeps its own hot-reload + state (the `ActivePatchTotal` that `--status` reports, and the source snapshots) under its own + `Library/UloopHotReload/`. +- A patch applied to the main Editor does not reach the players, and a patch applied to a player + does not reach the main Editor or the other players. A compile of the main Editor's project + reaches every player, and clears the patches a player holds. +- When the main Editor's project has never compiled the edited assembly, a player reports the + file as `Failed` with `Compiled assembly not found ... Compile the main Editor's project + first.` ## Known limitations diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs index 4d66c3d967..030713d350 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs @@ -107,12 +107,10 @@ public static HotReloadCallSiteScanResult FindCallSites( HashSet scanAssemblyNames = CollectScanAssemblyNames(targets); int examinedCallSiteCount = 0; + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); foreach (string assemblyName in scanAssemblyNames) { - string dllPath = Path.Combine( - projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, - assemblyName + HotReloadConstants.CompiledAssemblyExtension); + string dllPath = layout.DllPath(assemblyName); // Why skip (not assert): an assembly that has not been written to ScriptAssemblies // cannot contain call sites, so it cannot be a caller. Missing here is "not compiled diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedFileAggregator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedFileAggregator.cs index a7da610dba..e3bb7d24af 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedFileAggregator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedFileAggregator.cs @@ -121,6 +121,7 @@ internal static List CollectSnapshotAssemblies( Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); Debug.Assert(assemblies != null, "assemblies must not be null."); + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); List snapshotAssemblies = new List(); foreach (UnityCompilationAssembly assembly in assemblies) { @@ -135,11 +136,8 @@ internal static List CollectSnapshotAssemblies( continue; } - string dllPath = Path.Combine( - projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, - assembly.name + HotReloadConstants.CompiledAssemblyExtension); - string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + string dllPath = layout.DllPath(assembly.name); + string pdbPath = layout.PdbPath(assembly.name); if (!File.Exists(dllPath) || !File.Exists(pdbPath)) { continue; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs new file mode 100644 index 0000000000..6c11e9ba61 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Checks that the compilation pipeline puts an assembly where hot reload reads it from. + /// + internal static class HotReloadCompiledAssemblyPathCheck + { + // Why only a check: hot reload decides where an assembly lives from the root's shape, since + // most places that need the path have only the assembly's name. Comparing here keeps that + // decision from silently reading another DLL if Unity ever writes assemblies elsewhere. + internal static HotReloadFailureDescription DescribeOutputPathMismatch( + CompiledAssemblyLayout layout, + string assemblyName, + string outputPath) + { + Debug.Assert(layout != null, "layout must not be null."); + Debug.Assert(!string.IsNullOrEmpty(assemblyName), "assemblyName must not be null or empty."); + + if (string.IsNullOrEmpty(outputPath)) + { + return null; + } + + // Path.Combine returns an absolute outputPath unchanged, so one expression covers both forms. + string reported = Path.GetFullPath(Path.Combine(layout.ProjectRoot, outputPath)); + string expected = Path.GetFullPath(layout.DllPath(assemblyName)); + // Windows paths are case-insensitive, as ReferencePublicizer compares them. + StringComparison comparison = Application.platform == RuntimePlatform.WindowsEditor + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (string.Equals(reported, expected, comparison)) + { + return null; + } + + return HotReloadFailureDescription.CompiledAssemblyMissing( + "The compilation pipeline reports '" + assemblyName + "' at '" + reported + + "', but hot reload reads compiled assemblies from '" + layout.CompiledAssembliesDirectory + + "'. Compile the project and retry; if this persists, the project layout is one hot reload does not know.", + isVirtualPlayer: layout.IsVirtualPlayer); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs.meta new file mode 100644 index 0000000000..0cf24334d2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 733e44a6979c447c3b9f75fcbb42e056 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs index 0b47916256..87b4dc37b9 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs @@ -33,6 +33,7 @@ internal static TransformWorkerInputDto BuildWorkerInput( sources = sources, defines = firstFile.CompilationAssembly.defines ?? Array.Empty(), referencePaths = HotReloadShimReferenceBuilder.BuildWorkerReferencePaths( + firstFile.ProjectRoot, firstFile.CompilationAssembly, firstFile.Home), targetTypesAssemblyPath = Path.GetFullPath(firstFile.Home.DllPath), diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs index dbb9ef7a9c..44bc875e55 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs @@ -140,7 +140,7 @@ private static async Task CompileArtifact firstFile.Home, collaborators.Domain, firstFile.ProjectRoot, - HotReloadResolverSearchDirectories.Collect(firstFile.CompilationAssembly), + HotReloadResolverSearchDirectories.Collect(firstFile.ProjectRoot, firstFile.CompilationAssembly), collaborators.InternalAccessGrant.IsAvailable); if (!references.Success) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs index 14a3bb9987..72fe13b7e8 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs @@ -112,10 +112,7 @@ private static HotReloadFailureDescription DescribeMembershipChange( "The resolved assembly is no longer present in the compilation pipeline. Compile the project and retry hot reload."); } - string targetDllPath = Path.Combine( - projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, - evidence.AssemblyName + HotReloadConstants.CompiledAssemblyExtension); + string targetDllPath = CompiledAssemblyLayout.Resolve(projectRoot).DllPath(evidence.AssemblyName); HotReloadFailureDescription compiledAssemblyChange = DescribeCompiledAssemblyChange( evidence, targetDllPath); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs index ddb491c099..1ab7783375 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs @@ -97,20 +97,32 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( } } - string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - HotReloadTypeHome home = domain.ResolveTypeHome(projectRoot, assemblyName); + CompiledAssemblyLayout layout = + CompiledAssemblyLayout.Resolve(Path.GetFullPath(Path.Combine(Application.dataPath, ".."))); + HotReloadTypeHome home = domain.ResolveTypeHome(layout.ProjectRoot, assemblyName); if (!File.Exists(home.DllPath)) { outcomes.Add( HotReloadMethodOutcome.FailedBecause( "(file)", - HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(projectRoot, home.DllPath), + HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(layout, home.DllPath), assemblyResolvePath)); return HotReloadPatchTargetResolution.EarlyExit( new HotReloadFileProcessResult(outcomes, warnings, 0)); } + HotReloadFailureDescription outputPathFailure = HotReloadCompiledAssemblyPathCheck.DescribeOutputPathMismatch( + layout, + assemblyName, + compilationAssembly.outputPath); + if (outputPathFailure != null) + { + outcomes.Add(HotReloadMethodOutcome.FailedBecause("(file)", outputPathFailure, assemblyResolvePath)); + return HotReloadPatchTargetResolution.EarlyExit( + new HotReloadFileProcessResult(outcomes, warnings, 0)); + } + HotReloadFailureDescription mvidGuardFailure = CheckMvidGuard(home); if (mvidGuardFailure != null) { @@ -124,7 +136,7 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( { HotReloadFailureDescription membershipFailure = HotReloadNewSourceMembershipValidator.TryCapture( editorStateSnapshotCapture, - projectRoot, + layout.ProjectRoot, projectRelativePath, assemblyName, compilationAssembly, @@ -174,7 +186,7 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( assemblyName, compilationAssembly, home, - projectRoot, + layout.ProjectRoot, unchangedDecision, newSourceMembershipEvidence); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs index ae33b28ba1..22560e40a4 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using UnityEngine; @@ -16,8 +17,9 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class HotReloadResolverSearchDirectories { - internal static IReadOnlyCollection Collect(UnityCompilationAssembly rootAssembly) + internal static IReadOnlyCollection Collect(string projectRoot, UnityCompilationAssembly rootAssembly) { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); Debug.Assert(rootAssembly != null, "rootAssembly must not be null."); List orderedDirectories = new List(); @@ -35,7 +37,7 @@ internal static IReadOnlyCollection Collect(UnityCompilationAssembly roo UnityCompilationAssembly current = pending.Dequeue(); // allReferences already dereferences assemblyReferences, so a null array would have // thrown here; no null guard after this point. - List unseenReferences = TakeUnseenReferences(current.allReferences, seenReferences); + List unseenReferences = TakeUnseenReferences(projectRoot, current.allReferences, seenReferences); foreach (string directory in ReferencePublicizer.CollectResolverSearchDirectories(unseenReferences)) { if (seenDirectories.Add(directory)) @@ -59,14 +61,25 @@ internal static IReadOnlyCollection Collect(UnityCompilationAssembly roo // Why skip a path seen earlier: the engine references, about 230 per assembly, repeat in // every assembly of the closure, and each one would cost a File.Exists. A path seen earlier // already added its directory at the same or a shallower level, or did not exist then either. - private static List TakeUnseenReferences(string[] references, HashSet seenReferences) + // Why against the root: a Virtual Player's script assemblies are listed relative to its root + // (../../ScriptAssemblies), and its process need not run there. + private static List TakeUnseenReferences( + string projectRoot, + string[] references, + HashSet seenReferences) { List unseenReferences = new List(); foreach (string reference in references) { - if (seenReferences.Add(reference)) + if (string.IsNullOrEmpty(reference)) { - unseenReferences.Add(reference); + continue; + } + + string fullReference = Path.GetFullPath(Path.Combine(projectRoot, reference)); + if (seenReferences.Add(fullReference)) + { + unseenReferences.Add(fullReference); } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs index 2fa9235e00..2d9548abc4 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs @@ -23,9 +23,11 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal static class HotReloadShimReferenceBuilder { internal static string[] BuildWorkerReferencePaths( + string projectRoot, UnityCompilationAssembly compilationAssembly, HotReloadTypeHome targetHome) { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); Debug.Assert(targetHome != null, "targetHome must not be null."); List paths = new List(); @@ -33,9 +35,17 @@ internal static string[] BuildWorkerReferencePaths( { foreach (string reference in compilationAssembly.allReferences) { - if (!string.IsNullOrEmpty(reference) && File.Exists(reference)) + if (string.IsNullOrEmpty(reference)) + { + continue; + } + + // Why against the root: a Virtual Player's script assemblies are listed relative + // to its root (../../ScriptAssemblies), and its process need not run there. + string fullReference = Path.GetFullPath(Path.Combine(projectRoot, reference)); + if (File.Exists(fullReference)) { - paths.Add(Path.GetFullPath(reference)); + paths.Add(fullReference); } } } @@ -416,14 +426,13 @@ private static List BuildShimReferencePaths( IReadOnlyList introducedTypeArtifactHomes) { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - string scriptAssembliesDirectory = Path.GetFullPath( - Path.Combine(projectRoot, HotReloadConstants.ScriptAssembliesRelativeDirectory)); + string scriptAssembliesDirectory = CompiledAssemblyLayout.Resolve(projectRoot).CompiledAssembliesDirectory; // Derive Cecil search dirs from Unity's actual compile references so publicize // resolves netstandard/engine modules without hardcoding Editor Contents layouts. // Referenced assemblies count transitively: Cecil may need a DLL only they list. IReadOnlyCollection resolverSearchDirectories = - HotReloadResolverSearchDirectories.Collect(compilationAssembly); + HotReloadResolverSearchDirectories.Collect(projectRoot, compilationAssembly); List references = new List(); string publicizedTarget = ReferencePublicizer.GetOrCreatePublicizedCopy( @@ -452,12 +461,17 @@ private static List BuildShimReferencePaths( string shimTargetAssemblyName = Path.GetFileNameWithoutExtension(fullTarget); foreach (string reference in compilationAssembly.allReferences) { - if (string.IsNullOrEmpty(reference) || !File.Exists(reference)) + if (string.IsNullOrEmpty(reference)) { continue; } - string fullReference = Path.GetFullPath(reference); + // Resolved against the root for the same reason as in BuildWorkerReferencePaths. + string fullReference = Path.GetFullPath(Path.Combine(projectRoot, reference)); + if (!File.Exists(fullReference)) + { + continue; + } if (string.Equals(fullReference, fullTarget, StringComparison.OrdinalIgnoreCase)) { // Replaced by the publicized copy above. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs index 5a373a3d17..dd3c2fd5d1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs @@ -276,31 +276,27 @@ private static void AssertIsPublicizableSourcePath(string fullSourceDllPath) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; string normalizedSource = NormalizePathForComparison(fullSourceDllPath); - bool underAcceptedDirectory = IsUnderProjectDirectory( + bool underAcceptedDirectory = IsUnderDirectory( normalizedSource, - projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, + CompiledAssemblyLayout.Resolve(projectRoot).CompiledAssembliesDirectory, comparison) - || IsUnderProjectDirectory( + || IsUnderDirectory( normalizedSource, - projectRoot, - HotReloadConstants.IntroducedTypeArtifactsRelativeDirectory, + Path.Combine(projectRoot, HotReloadConstants.IntroducedTypeArtifactsRelativeDirectory), comparison); Debug.Assert( underAcceptedDirectory, - "ReferencePublicizer only accepts DLLs under Library/ScriptAssemblies/ or " + "ReferencePublicizer only accepts DLLs under the compiled assemblies directory or " + "Library/UloopHotReload/IntroducedTypes/."); } - private static bool IsUnderProjectDirectory( + private static bool IsUnderDirectory( string normalizedSourcePath, - string projectRoot, - string relativeDirectory, + string absoluteDirectory, StringComparison comparison) { - string normalizedDirectory = NormalizePathForComparison( - Path.GetFullPath(Path.Combine(projectRoot, relativeDirectory))); + string normalizedDirectory = NormalizePathForComparison(Path.GetFullPath(absoluteDirectory)); return normalizedSourcePath.StartsWith(normalizedDirectory + "/", comparison); } @@ -320,8 +316,7 @@ private static DefaultAssemblyResolver CreateAssemblyResolver( resolver.AddSearchDirectory(Path.GetDirectoryName(sourceDllPath)); string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - resolver.AddSearchDirectory( - Path.Combine(projectRoot, HotReloadConstants.ScriptAssembliesRelativeDirectory)); + resolver.AddSearchDirectory(CompiledAssemblyLayout.Resolve(projectRoot).CompiledAssembliesDirectory); foreach (string searchDirectory in resolverSearchDirectories) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/UnityCLILoop.FirstPartyTools.HotReload.Patching.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/UnityCLILoop.FirstPartyTools.HotReload.Patching.Editor.asmdef index 1991b71e7c..f2f1bb9923 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/UnityCLILoop.FirstPartyTools.HotReload.Patching.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/UnityCLILoop.FirstPartyTools.HotReload.Patching.Editor.asmdef @@ -5,7 +5,8 @@ "GUID:384dc39e13234d8aae3b1b1c44c9507e", "GUID:1e3293e9639242278dcb27724b34beb4", "GUID:fc3fd32eddbee40e39c2d76dc184957b", - "GUID:afe86dd49995e46baa33e099a4d2ee1d" + "GUID:afe86dd49995e46baa33e099a4d2ee1d", + "GUID:24d448abf7c10486d9578018b3a3c54b" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index a6c24ce693..3fa77955dc 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -740,7 +740,7 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE // Why the main Editor's project: a Virtual Player loads the script assemblies of the main // project, so only a compile there reaches it. public const string VirtualPlayerRecommendedNextAction = - "This Editor is a Multiplayer Play Mode Virtual Player, which hot reload cannot patch. Run 'uloop compile' against the main Editor's project; the compiled result reaches this player. A patch applied in the main Editor does not."; + "This Editor is a Multiplayer Play Mode Virtual Player and reads the compiled assemblies of the main Editor's project. Run 'uloop compile' against the main Editor's project; the compiled result reaches this player. Then rerun hot reload with this player's project path. A patch applied to the main Editor does not reach this player."; public const string CompiledAssemblyMissingRecommendedNextAction = "The compiled assembly for the file is missing, so there is nothing to fix in the source: run 'uloop compile'."; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index 782b7e4f36..6f0e49fa03 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -89,11 +89,11 @@ internal static void CaptureAssemblyIfNeeded( return; } - string dllPath = Path.Combine( - projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, - assembly.name + HotReloadConstants.CompiledAssemblyExtension); - string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + // Why projectRoot stays the snapshot owner: only the compiled assemblies move to the main + // project for a Virtual Player; its snapshots stay under its own root. + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + string dllPath = layout.DllPath(assembly.name); + string pdbPath = layout.PdbPath(assembly.name); if (!File.Exists(dllPath) || !File.Exists(pdbPath)) { return; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadTypeHome.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadTypeHome.cs index 006595bb32..a65e43a04c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadTypeHome.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadTypeHome.cs @@ -87,10 +87,7 @@ public static HotReloadTypeHome ScriptAssembliesUnderProject(string projectRoot, Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(assemblyName), "assemblyName must not be null or empty."); - string dllPath = Path.Combine( - projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, - assemblyName + HotReloadConstants.CompiledAssemblyExtension); + string dllPath = CompiledAssemblyLayout.Resolve(projectRoot).DllPath(assemblyName); return ScriptAssemblies(assemblyName, dllPath); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs index 53fd63a77e..44c1520dc2 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs @@ -1,78 +1,37 @@ -using System; -using System.IO; - using UnityEngine; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Recognizes a Multiplayer Play Mode Virtual Player by its project root, and words the - /// missing-assembly reason for it. + /// Words the missing-assembly reason for an ordinary project and for a Multiplayer Play Mode + /// Virtual Player. /// internal static class HotReloadVirtualPlayerProject { - private const string LibraryDirectoryName = "Library"; - private const string VirtualPlayersDirectoryName = "VP"; - - // A Virtual Player's project root is
/Library/VP/. - internal static bool IsVirtualPlayerProjectRoot(string projectRoot) - { - Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); - - // Why trim first: with a trailing separator, Path.GetDirectoryName returns the same - // directory, so every parent lookup below would land one level too low. - string trimmedRoot = projectRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - // A root made only of separators has no parent, and Path.GetDirectoryName rejects the empty string. - if (trimmedRoot.Length == 0) - { - return false; - } - - string virtualPlayersDirectory = Path.GetDirectoryName(trimmedRoot); - if (string.IsNullOrEmpty(virtualPlayersDirectory)) - { - return false; - } - - if (!string.Equals( - Path.GetFileName(virtualPlayersDirectory), - VirtualPlayersDirectoryName, - StringComparison.Ordinal)) - { - return false; - } - - string libraryDirectory = Path.GetDirectoryName(virtualPlayersDirectory); - if (string.IsNullOrEmpty(libraryDirectory)) - { - return false; - } - - return string.Equals(Path.GetFileName(libraryDirectory), LibraryDirectoryName, StringComparison.Ordinal); - } - // Why the text and the kinds come from one decision: a reason that names a Virtual Player // has to arrive with VirtualPlayer set, or the next step would tell the player to compile // its own project, which has no assemblies to compile. - internal static HotReloadFailureDescription DescribeMissingCompiledAssembly(string projectRoot, string dllPath) + internal static HotReloadFailureDescription DescribeMissingCompiledAssembly( + CompiledAssemblyLayout layout, + string dllPath) { - Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert(layout != null, "layout must not be null."); Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); - if (!IsVirtualPlayerProjectRoot(projectRoot)) + if (!layout.IsVirtualPlayer) { return HotReloadFailureDescription.CompiledAssemblyMissing( "Compiled assembly not found at '" + dllPath + "'. Compile the project first.", isVirtualPlayer: false); } - // Why a different reason: a Virtual Player has no compiled assemblies under its own - // root, so "compile first" gives the same answer however many times it is followed. + // Why a different reason: a Virtual Player cannot compile its own assemblies, so only a + // compile of the main Editor's project puts the missing assembly where it reads from. return HotReloadFailureDescription.CompiledAssemblyMissing( "Compiled assembly not found at '" + dllPath + "'. This Editor is a Multiplayer Play Mode " - + "Virtual Player: it loads the script assemblies of the main Editor's project, so hot reload " - + "cannot patch it yet. The edit reaches this player through a compile; a patch applied to the " - + "main Editor does not reach it.", + + "Virtual Player: it loads the script assemblies of the main Editor's project at '" + + layout.MainProjectRoot + "', and that project has not compiled this assembly yet. Compile the " + + "main Editor's project first; a patch applied to the main Editor does not reach this player.", isVirtualPlayer: true); } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md index 25e9f5d6b0..236af49f53 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md @@ -430,7 +430,7 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | File does not belong to any compiled assembly | Per-file entry with `Method` = `(file)`; only `Assets/` and `Packages/` sources resolve | | Resolved assembly name is missing from CompilationPipeline | Per-file entry with `Method` = `(file)`; Unity may have mapped a not-yet-imported `.asmdef` onto a predefined assembly. Run `uloop compile` first | | Script is not in the last compiled assembly's source list and its assembly membership cannot be confirmed | Per-file entry with `Method` = `(file)`; a new file passed with `--files` is hot-reloadable when its membership in an existing, unchanged compiled assembly is confirmed (`.asmdef` / `.asmref` boundaries are checked when present; a predefined assembly with none also passes), but fails when the Editor is not ready or an `.asmdef` / `.asmref` on its path was added, deleted, or changed since the last import — run `uloop compile` first | -| The Editor is a Multiplayer Play Mode Virtual Player | Per-file entry with `Method` = `(file)`; a Virtual Player has no compiled assemblies under its own project root, so hot reload cannot patch it yet. The edit reaches that player only through a compile: the CLI's compile fallback brings it in when `--compile-on-skip` lets the compile run, and `auto` holds it while that player is in Play Mode (`CompileFallback` is `HeldForPlayMode`). A patch applied to the main Editor does not reach Virtual Players (each is a separate Editor process) | +| The Editor is a Multiplayer Play Mode Virtual Player and the main Editor's project has not compiled the assembly | Per-file entry with `Method` = `(file)`; a Virtual Player reads the main Editor's `Library/ScriptAssemblies`, and the reason says to compile the main Editor's project first. Until then the edit reaches that player only through a compile: the CLI's compile fallback brings it in when `--compile-on-skip` lets the compile run, and `auto` holds it while that player is in Play Mode (`CompileFallback` is `HeldForPlayMode`). A patch applied to the main Editor does not reach Virtual Players (each is a separate Editor process) | | The Editor is compiling or importing when the request arrives, or starts to before the reload is applied | Per-file entry with `Method` = `(file)`; nothing in the source needs a change. The response sets `RetryAfterEditorReady`, and the CLI waits for the Editor to settle (up to 10 minutes) and applies the same request again once; if Unity's own compile already took the edit in, the second apply reports `NothingToApply`. A second refusal falls through to `--compile-on-skip`. | | Another uloop command still holds the Editor when the request arrives (for example a `uloop compile` sent a moment earlier) | The request is not run; the CLI does not retry against the busy Editor and does not bring it to the front. It waits for that command to finish and the Editor to be ready (up to 10 minutes), then sends the same request once. `EditorReadyRetryNote` names the command it waited for and `Timing.EditorReadyWaitMs` is the wait. A busy answer to that one request is reported as `UNITY_SERVER_BUSY` without another wait. While a cancelled `execute-dynamic-code` request still holds the Editor, the wait sends the request again every 5 seconds, because the Editor takes such a request's slot back only when another request arrives. When the command that ran was a compile that already took the edit in, give `--files`: with no files the Editor selects changed files anew, finds none, and fails validation instead of reporting `NothingToApply` | | Loaded assembly differs from the one on disk (pending compile) | Run `uloop compile` first, then retry | From 14ecf1a1d36395ab031fea78662a8f9571fa488a Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 10:17:30 +0900 Subject: [PATCH 09/29] chore: Regenerate the pause-point skill copies that fell behind their source (#3239) --- .agents/skills/uloop-pause-point/references/troubleshooting.md | 2 +- .claude/skills/uloop-pause-point/references/troubleshooting.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/uloop-pause-point/references/troubleshooting.md b/.agents/skills/uloop-pause-point/references/troubleshooting.md index dbe447522b..3e5bdd4825 100644 --- a/.agents/skills/uloop-pause-point/references/troubleshooting.md +++ b/.agents/skills/uloop-pause-point/references/troubleshooting.md @@ -95,4 +95,4 @@ interrupt the flow or ask the user about it mid-run. At the next stopping point propose `uloop set-code-optimization debug --startup` (session-only without `--startup`), and only apply it if the user approves. -The automatic Debug switch changes only the current project's code optimization for this Editor session; it reverts on every Editor restart, and each re-switch costs a full script recompile. `uloop set-code-optimization debug --startup` makes Debug the startup default through a machine-wide Unity preference that applies to every project. Only the project's C# scripts run slower, mainly in Play Mode - the Editor itself is not slowed. +The automatic Debug switch changes the code optimization of this Editor session only; on every Editor restart it goes back to the 'Code Optimization On Startup' preference (a per-user Unity preference, Release unless changed), and each re-switch costs a full script recompile. `uloop set-code-optimization debug --startup` makes Debug the startup default through a machine-wide Unity preference that applies to every project. Only the project's C# scripts run slower, mainly in Play Mode - the Editor itself is not slowed. diff --git a/.claude/skills/uloop-pause-point/references/troubleshooting.md b/.claude/skills/uloop-pause-point/references/troubleshooting.md index dbe447522b..3e5bdd4825 100644 --- a/.claude/skills/uloop-pause-point/references/troubleshooting.md +++ b/.claude/skills/uloop-pause-point/references/troubleshooting.md @@ -95,4 +95,4 @@ interrupt the flow or ask the user about it mid-run. At the next stopping point propose `uloop set-code-optimization debug --startup` (session-only without `--startup`), and only apply it if the user approves. -The automatic Debug switch changes only the current project's code optimization for this Editor session; it reverts on every Editor restart, and each re-switch costs a full script recompile. `uloop set-code-optimization debug --startup` makes Debug the startup default through a machine-wide Unity preference that applies to every project. Only the project's C# scripts run slower, mainly in Play Mode - the Editor itself is not slowed. +The automatic Debug switch changes the code optimization of this Editor session only; on every Editor restart it goes back to the 'Code Optimization On Startup' preference (a per-user Unity preference, Release unless changed), and each re-switch costs a full script recompile. `uloop set-code-optimization debug --startup` makes Debug the startup default through a machine-wide Unity preference that applies to every project. Only the project's C# scripts run slower, mainly in Play Mode - the Editor itself is not slowed. From 731c2f700b94a97ec93d9afe3c483b44eadbcfc8 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 10:45:53 +0900 Subject: [PATCH 10/29] perf: Hot reload asks the compilation pipeline for the assembly list once per domain (#3240) --- .../HotReloadCompilationAssembliesTests.cs | 160 ++++++++++++++++ ...otReloadCompilationAssembliesTests.cs.meta | 11 ++ .../HotReloadCompilationAssemblyCacheTests.cs | 180 ++++++++++++++++++ ...eloadCompilationAssemblyCacheTests.cs.meta | 11 ++ .../HotReload/HotReloadCallSiteScanner.cs | 2 +- .../HotReloadChangedFileAggregator.cs | 4 +- .../HotReloadNewSourceMembershipValidator.cs | 16 +- .../HotReload/HotReloadPatchTargetSupport.cs | 15 +- .../Shared/HotReloadCompilationAssemblies.cs | 57 ++++++ .../HotReloadCompilationAssemblies.cs.meta | 11 ++ .../HotReloadCompilationAssemblyCache.cs | 74 +++++++ .../HotReloadCompilationAssemblyCache.cs.meta | 11 ++ .../Shared/HotReloadSourceSnapshotter.cs | 3 +- 13 files changed, 520 insertions(+), 35 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs new file mode 100644 index 0000000000..753ae17ec4 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +using NUnit.Framework; + +using UnityEditor; +using UnityEditor.Compilation; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// EditMode coverage for the per-domain compilation assembly list: one shared memo, dropped by the + /// Unity events that can change the list. + /// + public sealed class HotReloadCompilationAssembliesTests + { + // Why string names: the handlers are private to the production class; a rename fails these tests + // instead of silently passing them. + private const string CompilationStartedHandlerName = "OnCompilationStarted"; + private const string ProjectChangedHandlerName = "OnProjectChanged"; + + /// + /// Verifies that the static constructor subscribed the memo's invalidation to compile start and + /// project change. + /// + [Test] + public void StaticConstructor_SubscribesInvalidationToCompilationStartedAndProjectChanged() + { + HotReloadCompilationAssemblies.Current(); + + Assert.That( + StaticEventHasHandler(typeof(CompilationPipeline), CompilationStartedHandlerName), + Is.True, + "OnCompilationStarted must be subscribed on CompilationPipeline."); + Assert.That( + StaticEventHasHandler(typeof(EditorApplication), ProjectChangedHandlerName), + Is.True, + "OnProjectChanged must be subscribed on EditorApplication."); + } + + /// + /// Verifies that two calls share one kept list instead of asking Unity each time. + /// + [Test] + public void Current_CalledTwice_ReturnsSameInstance() + { + IReadOnlyList first = HotReloadCompilationAssemblies.Current(); + IReadOnlyList second = HotReloadCompilationAssemblies.Current(); + + Assert.That(first, Is.Not.Empty); + Assert.That(second, Is.SameAs(first)); + } + + // Why: CompilationPipeline stores handlers on Delegate fields, but EditorApplication.projectChanged + // lives on EventWithPerformanceTracker. A Delegate-only scan cannot see it. + private static bool StaticEventHasHandler(Type eventOwner, string handlerName) + { + FieldInfo[] fields = eventOwner.GetFields( + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); + for (int index = 0; index < fields.Length; index++) + { + object value = fields[index].GetValue(null); + if (ContainsProductionHandler(value, handlerName)) + { + return true; + } + } + + return false; + } + + private static bool ContainsProductionHandler(object source, string handlerName) + { + if (source == null) + { + return false; + } + + Delegate current = source as Delegate; + if (current != null) + { + return InvocationListContains(current, handlerName); + } + + return EnumeratorContainsHandler(source, handlerName); + } + + private static bool InvocationListContains(Delegate current, string handlerName) + { + Delegate[] listeners = current.GetInvocationList(); + for (int listenerIndex = 0; listenerIndex < listeners.Length; listenerIndex++) + { + if (IsProductionHandler(listeners[listenerIndex], handlerName)) + { + return true; + } + } + + return false; + } + + private static bool EnumeratorContainsHandler(object source, string handlerName) + { + string typeName = source.GetType().Name; + if (typeName.IndexOf("EventWithPerformanceTracker", StringComparison.Ordinal) < 0) + { + return false; + } + + MethodInfo getEnumerator = source.GetType().GetMethod( + "GetEnumerator", + BindingFlags.Instance | BindingFlags.Public); + if (getEnumerator == null || getEnumerator.GetParameters().Length != 0) + { + return false; + } + + object enumerator = getEnumerator.Invoke(source, null); + if (enumerator == null) + { + return false; + } + + MethodInfo moveNext = enumerator.GetType().GetMethod("MoveNext"); + PropertyInfo currentProperty = enumerator.GetType().GetProperty("Current"); + if (moveNext == null || currentProperty == null) + { + return false; + } + + while ((bool)moveNext.Invoke(enumerator, null)) + { + Delegate listener = currentProperty.GetValue(enumerator) as Delegate; + if (IsProductionHandler(listener, handlerName)) + { + return true; + } + } + + return false; + } + + private static bool IsProductionHandler(Delegate listener, string handlerName) + { + if (listener == null) + { + return false; + } + + MethodInfo listenerMethod = listener.Method; + return listenerMethod.DeclaringType == typeof(HotReloadCompilationAssemblies) + && listenerMethod.Name == handlerName; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs.meta new file mode 100644 index 0000000000..0abc5271e0 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 585d8186a697a4e21b54c6109d15ba69 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs new file mode 100644 index 0000000000..b26b95ea23 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; + +using NUnit.Framework; + +using UnityEditor.Compilation; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// EditMode coverage for the per-domain memo of the compilation assembly list: when it asks Unity, + /// when it reuses the last answer, and why an empty answer is never kept. + /// + public sealed class HotReloadCompilationAssemblyCacheTests + { + private int _fetchCount; + private Queue _answers; + + [SetUp] + public void SetUp() + { + _fetchCount = 0; + _answers = new Queue(); + } + + /// + /// Verifies that a second Current() reuses the first answer without asking Unity again. + /// + [Test] + public void Current_CalledTwice_FetchesOnceAndReturnsSameInstance() + { + _answers.Enqueue(new[] { CreateAssembly("First") }); + HotReloadCompilationAssemblyCache cache = CreateCache(); + + IReadOnlyList first = cache.Current(); + IReadOnlyList second = cache.Current(); + + Assert.That(_fetchCount, Is.EqualTo(1)); + Assert.That(second, Is.SameAs(first)); + Assert.That(cache.IsFilled, Is.True); + } + + /// + /// Verifies that Invalidate() drops the memo so the next Current() asks Unity for a new list. + /// + [Test] + public void Current_AfterInvalidate_FetchesAgainAndReturnsNewAnswer() + { + UnityCompilationAssembly[] firstAnswer = { CreateAssembly("First") }; + UnityCompilationAssembly[] secondAnswer = { CreateAssembly("Second") }; + _answers.Enqueue(firstAnswer); + _answers.Enqueue(secondAnswer); + HotReloadCompilationAssemblyCache cache = CreateCache(); + cache.Current(); + + cache.Invalidate(); + IReadOnlyList afterInvalidate = cache.Current(); + + Assert.That(_fetchCount, Is.EqualTo(2)); + Assert.That(afterInvalidate, Is.SameAs(secondAnswer)); + } + + /// + /// Verifies that an empty answer, which Unity gives while a compile is in flight, is not kept. + /// + [Test] + public void Current_EmptyAnswer_IsNotMemoized() + { + _answers.Enqueue(Array.Empty()); + _answers.Enqueue(Array.Empty()); + HotReloadCompilationAssemblyCache cache = CreateCache(); + + IReadOnlyList first = cache.Current(); + bool filledAfterEmpty = cache.IsFilled; + cache.Current(); + + Assert.That(first, Is.Empty); + Assert.That(filledAfterEmpty, Is.False); + Assert.That(_fetchCount, Is.EqualTo(2)); + } + + /// + /// Verifies that the first non-empty answer after an empty one is kept. + /// + [Test] + public void Current_NonEmptyAfterEmpty_IsMemoized() + { + UnityCompilationAssembly[] nonEmpty = { CreateAssembly("First") }; + _answers.Enqueue(Array.Empty()); + _answers.Enqueue(nonEmpty); + HotReloadCompilationAssemblyCache cache = CreateCache(); + + cache.Current(); + cache.Current(); + IReadOnlyList third = cache.Current(); + + Assert.That(_fetchCount, Is.EqualTo(2)); + Assert.That(third, Is.SameAs(nonEmpty)); + } + + /// + /// Verifies that FindByName returns the assembly with that name and asks Unity only once across lookups. + /// + [Test] + public void FindByName_KnownName_ReturnsAssemblyAndFetchesOnce() + { + UnityCompilationAssembly target = CreateAssembly("Second"); + _answers.Enqueue(new[] { CreateAssembly("First"), target }); + HotReloadCompilationAssemblyCache cache = CreateCache(); + + UnityCompilationAssembly first = cache.FindByName("Second"); + UnityCompilationAssembly second = cache.FindByName("Second"); + + Assert.That(first, Is.SameAs(target)); + Assert.That(second, Is.SameAs(target)); + Assert.That(_fetchCount, Is.EqualTo(1)); + } + + /// + /// Verifies that FindByName matches the whole name with case, not a prefix or a case-folded name, + /// and that it looks at the first assembly too. + /// + [Test] + public void FindByName_SimilarNames_ReturnsExactMatchOnly() + { + UnityCompilationAssembly lowerCase = CreateAssembly("foo"); + UnityCompilationAssembly prefixed = CreateAssembly("Foo.Editor"); + UnityCompilationAssembly exact = CreateAssembly("Foo"); + _answers.Enqueue(new[] { lowerCase, prefixed, exact }); + HotReloadCompilationAssemblyCache cache = CreateCache(); + + UnityCompilationAssembly foundUpper = cache.FindByName("Foo"); + UnityCompilationAssembly foundLower = cache.FindByName("foo"); + + Assert.That(foundUpper, Is.SameAs(exact)); + Assert.That(foundLower, Is.SameAs(lowerCase)); + } + + /// + /// Verifies that FindByName returns null for a name no compilation assembly has. + /// + [Test] + public void FindByName_UnknownName_ReturnsNull() + { + _answers.Enqueue(new[] { CreateAssembly("First") }); + HotReloadCompilationAssemblyCache cache = CreateCache(); + + UnityCompilationAssembly found = cache.FindByName("Missing"); + + Assert.That(found, Is.Null); + } + + private HotReloadCompilationAssemblyCache CreateCache() + { + return new HotReloadCompilationAssemblyCache(Fetch); + } + + private UnityCompilationAssembly[] Fetch() + { + _fetchCount++; + return _answers.Dequeue(); + } + + private static UnityCompilationAssembly CreateAssembly(string assemblyName) + { + return new UnityCompilationAssembly( + assemblyName, + HotReloadConstants.ScriptAssembliesRelativeDirectory + "/" + assemblyName + HotReloadConstants.CompiledAssemblyExtension, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + AssemblyFlags.EditorAssembly); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs.meta new file mode 100644 index 0000000000..b20a4b48a4 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 23e6c40d20f324b7891035e1cd3ec444 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs index 030713d350..24d6e91006 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs @@ -174,7 +174,7 @@ private static Dictionary GetReferencedDllFileNamesByAssembly( } Dictionary graph = new Dictionary(StringComparer.Ordinal); - foreach (UnityCompilationAssembly assembly in CompilationPipeline.GetAssemblies()) + foreach (UnityCompilationAssembly assembly in HotReloadCompilationAssemblies.Current()) { graph[assembly.name] = CollectReferenceFileNames(assembly.allReferences); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedFileAggregator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedFileAggregator.cs index e3bb7d24af..d6c368e2d4 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedFileAggregator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedFileAggregator.cs @@ -2,8 +2,6 @@ using System.Collections.Generic; using System.IO; -using UnityEditor.Compilation; - using UnityEngine; using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; @@ -64,7 +62,7 @@ internal static HotReloadChangedFileAggregationResult Detect() string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); List snapshotAssemblies = CollectSnapshotAssemblies( projectRoot, - CompilationPipeline.GetAssemblies()); + HotReloadCompilationAssemblies.Current()); return DetectFromSnapshotDirectories(projectRoot, snapshotAssemblies); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs index 72fe13b7e8..c99bca4b6a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs @@ -105,7 +105,7 @@ private static HotReloadFailureDescription DescribeMembershipChange( HotReloadNewSourceMembershipEvidence evidence) { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - UnityCompilationAssembly compilationAssembly = FindCompilationAssembly(evidence.AssemblyName); + UnityCompilationAssembly compilationAssembly = HotReloadCompilationAssemblies.FindByName(evidence.AssemblyName); if (compilationAssembly == null) { return HotReloadFailureDescription.Declaration( @@ -422,20 +422,6 @@ internal static bool EvidenceMatches( return true; } - private static UnityCompilationAssembly FindCompilationAssembly(string assemblyName) - { - UnityCompilationAssembly[] assemblies = CompilationPipeline.GetAssemblies(); - for (int index = 0; index < assemblies.Length; index++) - { - if (assemblies[index].name == assemblyName) - { - return assemblies[index]; - } - } - - return null; - } - private static string NormalizeProjectRelativePath(string path) { return string.IsNullOrEmpty(path) ? null : path.Replace('\\', '/'); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs index 1ab7783375..f688f1b75d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs @@ -57,7 +57,7 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( } string assemblyName = Path.GetFileNameWithoutExtension(rawAssemblyName); - UnityCompilationAssembly compilationAssembly = FindCompilationAssembly(assemblyName); + UnityCompilationAssembly compilationAssembly = HotReloadCompilationAssemblies.FindByName(assemblyName); // Why gate on compilationAssembly == null: the unimported-asmdef flag is only // consumed on that branch, and walking ancestor directories on every successful // resolve (including loose Assembly-CSharp scripts) is wasted disk I/O. @@ -208,19 +208,6 @@ private static HotReloadFailureDescription DescribeBusyRefusal( return editorStateSnapshotCapture.CaptureCurrent().GetBusyFailure(); } - private static UnityCompilationAssembly FindCompilationAssembly(string assemblyName) - { - foreach (UnityCompilationAssembly assembly in CompilationPipeline.GetAssemblies()) - { - if (assembly.name == assemblyName) - { - return assembly; - } - } - - return null; - } - // Why Path.Combine then GetFullPath: Unity Assembly.sourceFiles are project-relative // (slash-separated). The worker cwd is Library/UloopHotReload/Worker//, so it // can only open absolute paths. Normalization matches HotReloadSourceSnapshotter. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs new file mode 100644 index 0000000000..842fe71c12 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; + +using UnityEditor; +using UnityEditor.Compilation; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The compilation assembly list for hot reload, asked of Unity once per domain. + /// Why: on a project with several hundred assemblies one CompilationPipeline.GetAssemblies() call + /// takes 340-620 ms, while the list only changes through an import or a compile. The startup + /// snapshot capture is the first caller after a domain reload, so runs find the list already kept. + /// + internal static class HotReloadCompilationAssemblies + { + private static readonly HotReloadCompilationAssemblyCache Cache = + new HotReloadCompilationAssemblyCache(CompilationPipeline.GetAssemblies); + + static HotReloadCompilationAssemblies() + { + // Why: a compile that fails keeps the domain alive, so without this the list would keep + // the asmdefs and source files from before that compile until the next reload. + CompilationPipeline.compilationStarted += OnCompilationStarted; + // Why: an import does not always compile (Play Mode with Recompile After Finished Playing + // postpones it), yet it can change the source files of an assembly. + EditorApplication.projectChanged += OnProjectChanged; + } + + /// + /// Returns the compilation assemblies. The list is shared; callers only read it. + /// + internal static IReadOnlyList Current() + { + return Cache.Current(); + } + + /// + /// Returns the first compilation assembly with this name, or null when none has it. + /// + internal static UnityCompilationAssembly FindByName(string assemblyName) + { + return Cache.FindByName(assemblyName); + } + + private static void OnCompilationStarted(object context) + { + Cache.Invalidate(); + } + + private static void OnProjectChanged() + { + Cache.Invalidate(); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs.meta new file mode 100644 index 0000000000..34d698d2da --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 74af2888c9ea646518605a0fe11f27cf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs new file mode 100644 index 0000000000..5eac8cd972 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Keeps the last non-empty compilation assembly list so repeated lookups ask Unity only once + /// until the owner invalidates it. Main thread only, like the Unity call it wraps. + /// + internal sealed class HotReloadCompilationAssemblyCache + { + private readonly Func _fetch; + private UnityCompilationAssembly[] _assemblies; + + internal HotReloadCompilationAssemblyCache(Func fetch) + { + Debug.Assert(fetch != null, "fetch must not be null."); + _fetch = fetch; + } + + internal bool IsFilled => _assemblies != null; + + /// + /// Returns the memoized list, asking Unity when nothing is kept. The list is shared; callers only read it. + /// + internal IReadOnlyList Current() + { + if (_assemblies != null) + { + return _assemblies; + } + + UnityCompilationAssembly[] fetched = _fetch(); + Debug.Assert(fetched != null, "fetch must not return null."); + + // Why not memoize an empty result: GetAssemblies() answers nothing while a compile is in + // flight, and keeping that would hide every assembly for the rest of the domain's life. + if (fetched.Length > 0) + { + _assemblies = fetched; + } + + return fetched; + } + + /// + /// Returns the first compilation assembly with this name, or null when none has it. + /// + internal UnityCompilationAssembly FindByName(string assemblyName) + { + Debug.Assert(!string.IsNullOrEmpty(assemblyName), "assemblyName must not be null or empty."); + + IReadOnlyList assemblies = Current(); + for (int index = 0; index < assemblies.Count; index++) + { + if (assemblies[index].name == assemblyName) + { + return assemblies[index]; + } + } + + return null; + } + + internal void Invalidate() + { + _assemblies = null; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs.meta new file mode 100644 index 0000000000..7f7ac07f11 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d7365883bccb142a7b7e503e8080d096 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index 6f0e49fa03..6064b1ff9a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -6,7 +6,6 @@ using Mono.Cecil; -using UnityEditor.Compilation; using UnityEditor.PackageManager; using UnityEngine; @@ -33,7 +32,7 @@ internal static class HotReloadSourceSnapshotter internal static void CaptureAfterDomainReload() { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - CaptureAssemblies(projectRoot, CompilationPipeline.GetAssemblies()); + CaptureAssemblies(projectRoot, HotReloadCompilationAssemblies.Current()); } // Why separate from CaptureAfterDomainReload: the project root and the compilation assemblies come From 084e1d13ab10ab36b054fefc019989d178d7ec0d Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 12:52:03 +0900 Subject: [PATCH 11/29] perf: Hot reload compares a sibling with its snapshot again only when the file's length or write time changed (#3241) --- .../references/scope-and-limits.md | 6 + .../references/scope-and-limits.md | 6 + ...ReloadChangedSiblingSourceDetectorTests.cs | 173 ++++++++++++++++++ .../HotReloadChangedSourceDetectorTests.cs | 81 ++++++++ .../HotReloadSiblingVerdictCacheTests.cs | 137 ++++++++++++++ .../HotReloadSiblingVerdictCacheTests.cs.meta | 11 ++ .../HotReloadChangedSiblingSourceDetector.cs | 52 +++++- .../HotReload/HotReloadSiblingVerdictCache.cs | 111 +++++++++++ .../HotReloadSiblingVerdictCache.cs.meta | 11 ++ .../Skill/references/scope-and-limits.md | 6 + 10 files changed, 588 insertions(+), 6 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs.meta diff --git a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md index 236af49f53..715d2611ac 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -348,6 +348,12 @@ line each. A file with a declaration that hot reload refused to introduce (its `Warnings` line says the type requires a compile) gets no such line, because that compile also establishes its baseline. This also holds for an existing file that gains such a declaration, for example a nested type or a delegate. +Within one Editor session, a source compared with its baseline once is compared again +only when its length or last write time changes; a rewrite that keeps both (for example +a copy that preserves timestamps) is noticed only after the next domain reload, such as +`uloop compile`. This applies wherever a source is compared with its baseline: finding +drifted siblings, deciding which live patches to re-apply after a skip, and selecting +files when `--files` is omitted. Property getters with a body (including expression-bodied properties) are patched like ordinary methods. Editing a compiled property's setter, init, or indexer accessor diff --git a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md index 236af49f53..715d2611ac 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -348,6 +348,12 @@ line each. A file with a declaration that hot reload refused to introduce (its `Warnings` line says the type requires a compile) gets no such line, because that compile also establishes its baseline. This also holds for an existing file that gains such a declaration, for example a nested type or a delegate. +Within one Editor session, a source compared with its baseline once is compared again +only when its length or last write time changes; a rewrite that keeps both (for example +a copy that preserves timestamps) is noticed only after the next domain reload, such as +`uloop compile`. This applies wherever a source is compared with its baseline: finding +drifted siblings, deciding which live patches to re-apply after a skip, and selecting +files when `--files` is omitted. Property getters with a body (including expression-bodied properties) are patched like ordinary methods. Editing a compiled property's setter, init, or indexer accessor diff --git a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs index b475459c71..d268383291 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs @@ -17,6 +17,11 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload ///
public class HotReloadChangedSiblingSourceDetectorTests { + // Why fixed write times: the detector reuses a verdict while a file's length and write time + // stay the same, so each test sets the stamp it means instead of relying on the clock. + private static readonly DateTime FirstWriteTimeUtc = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime SecondWriteTimeUtc = FirstWriteTimeUtc.AddDays(1); + /// /// What: a sibling whose on-disk bytes differ from its snapshot is returned, and the /// edited file itself is excluded even when it also differs. @@ -504,6 +509,142 @@ public void SourceMatchesSnapshot_NoPdb_IsFalse() } } + /// + /// What: a sibling that matched its snapshot is reported once it is rewritten with another + /// length while its write time stays the same, so the length alone triggers a new comparison. + /// + [Test] + public void DetectFromSnapshotDirectory_SecondScanAfterRewriteWithNewLength_ReturnsSibling() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty); + + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA-longer", FirstWriteTimeUtc); + + Assert.That( + ScanSibling(projectRoot, "Asm-mvid", siblingRelative), + Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) })); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a sibling that matched its snapshot is reported once it is rewritten with the same + /// length and a later write time, so the write time alone triggers a new comparison. + /// + [Test] + public void DetectFromSnapshotDirectory_SecondScanAfterRewriteWithNewWriteTime_ReturnsSibling() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty); + + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-BBBB", SecondWriteTimeUtc); + + Assert.That( + ScanSibling(projectRoot, "Asm-mvid", siblingRelative), + Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) })); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a sibling rewritten with the same length while its write time is put back keeps + /// the earlier "matches" verdict. This is a known limit: a change that keeps both length and + /// write time (a copy that preserves timestamps) is not noticed until the next compile. + /// + [Test] + public void DetectFromSnapshotDirectory_SecondScanWithSameStamp_KeepsTheMatchVerdict() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty); + + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-BBBB", FirstWriteTimeUtc); + + Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a verdict against one assembly generation's snapshot is not reused against + /// another generation's snapshot of the same file. + /// + [Test] + public void DetectFromSnapshotDirectory_SameSiblingAgainstAnotherSnapshotDirectory_ComparesAgain() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid1", siblingRelative, "sibling-AAAA"); + WriteSnapshot(projectRoot, "Asm-mvid2", siblingRelative, "sibling-BBBB"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + Assert.That(ScanSibling(projectRoot, "Asm-mvid1", siblingRelative), Is.Empty); + + Assert.That( + ScanSibling(projectRoot, "Asm-mvid2", siblingRelative), + Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) })); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a sibling put back to its snapshot bytes while its length and write time are + /// kept keeps the earlier "differs" verdict. This is the same known limit in the other + /// direction: it stays reported until the next compile. + /// + [Test] + public void DetectFromSnapshotDirectory_SecondScanWithSameStamp_KeepsTheDiffersVerdict() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-BBBB", FirstWriteTimeUtc); + Assert.That( + ScanSibling(projectRoot, "Asm-mvid", siblingRelative), + Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) })); + + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + + Assert.That( + ScanSibling(projectRoot, "Asm-mvid", siblingRelative), + Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) })); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + /// /// What: sibling-derived warnings are ordinal-deduped among themselves and skipped /// when the own-file list already contains the exact string, without collapsing @@ -524,6 +665,38 @@ public void AppendSiblingDerivedWarnings_DedupesAmongSiblingsAndSkipsOwnFileMatc Is.EqualTo(new[] { "own-a", "own-a", "shared", "sibling-b" })); } + // Why an edited file: a run always has one, and the detector requires it. + private const string EditedRelative = "Assets/Edited.cs"; + + private static string[] ScanSibling( + string projectRoot, + string assemblySnapshotDirectoryName, + string siblingRelative) + { + return HotReloadChangedSiblingSourceDetector.DetectFromSnapshotDirectory( + projectRoot, + assemblySnapshotDirectoryName, + new[] { EditedRelative, siblingRelative }, + new[] { EditedRelative }).ChangedSiblingAbsolutePaths; + } + + // Why a write time the test chooses: SetLastWriteTimeUtc stores microseconds while + // LastWriteTimeUtc reports 100 ns ticks, so only a chosen value can be put back exactly. + private static void WriteProjectFileAt( + string projectRoot, + string projectRelativePath, + string contents, + DateTime lastWriteTimeUtc) + { + WriteProjectFile(projectRoot, projectRelativePath, contents); + string absolutePath = AbsoluteProjectPath(projectRoot, projectRelativePath); + File.SetLastWriteTimeUtc(absolutePath, lastWriteTimeUtc); + Assert.That( + new FileInfo(absolutePath).LastWriteTimeUtc, + Is.EqualTo(lastWriteTimeUtc), + "The write time must be settable exactly, or the stamp the detector reads is not the one the test chose."); + } + private static string CreateTempProjectRoot() { string projectRoot = Path.Combine( diff --git a/Assets/Tests/Editor/HotReload/HotReloadChangedSourceDetectorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadChangedSourceDetectorTests.cs index 5fdab2ea2c..f80afa95f5 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadChangedSourceDetectorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadChangedSourceDetectorTests.cs @@ -14,6 +14,11 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload /// public class HotReloadChangedSourceDetectorTests { + // Why fixed write times: the detector reuses a verdict while a file's length and write time + // stay the same, so each test sets the stamp it means instead of relying on the clock. + private static readonly DateTime FirstWriteTimeUtc = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime SecondWriteTimeUtc = FirstWriteTimeUtc.AddDays(1); + /// /// What: a missing snapshot directory reports no baseline instead of conflating it with no changes. /// @@ -178,6 +183,82 @@ public void DetectAllChangedFromSnapshotDirectory_WhenMoreThanLimitChanged_Trunc } } + /// + /// What: the default file selection does not pick a source rewritten while it keeps its + /// length and write time, because the earlier "matches" verdict is reused. This is a known + /// limit until the next compile. + /// + [Test] + public void DetectAllChangedFromSnapshotDirectory_SecondScanWithSameStamp_DoesNotSelectTheRewrite() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string sourcePath = "Assets/Source.cs"; + WriteSnapshot(projectRoot, "Assembly-mvid", sourcePath, "source-AAAA"); + WriteProjectFileAt(projectRoot, sourcePath, "source-AAAA", FirstWriteTimeUtc); + Assert.That(ScanAll(projectRoot, sourcePath), Is.Empty); + + WriteProjectFileAt(projectRoot, sourcePath, "source-BBBB", FirstWriteTimeUtc); + + Assert.That(ScanAll(projectRoot, sourcePath), Is.Empty); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: the default file selection picks a source rewritten with a later write time after + /// an earlier scan found it unchanged. + /// + [Test] + public void DetectAllChangedFromSnapshotDirectory_SecondScanAfterRewriteWithNewWriteTime_SelectsTheRewrite() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string sourcePath = "Assets/Source.cs"; + WriteSnapshot(projectRoot, "Assembly-mvid", sourcePath, "source-AAAA"); + WriteProjectFileAt(projectRoot, sourcePath, "source-AAAA", FirstWriteTimeUtc); + Assert.That(ScanAll(projectRoot, sourcePath), Is.Empty); + + WriteProjectFileAt(projectRoot, sourcePath, "source-BBBB", SecondWriteTimeUtc); + + Assert.That(ScanAll(projectRoot, sourcePath), Is.EqualTo(new[] { sourcePath })); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + private static System.Collections.Generic.List ScanAll(string projectRoot, string sourcePath) + { + return HotReloadChangedSiblingSourceDetector.DetectAllChangedFromSnapshotDirectory( + projectRoot, + "Assembly-mvid", + new[] { sourcePath }).ChangedProjectRelativePaths; + } + + // Why a write time the test chooses: SetLastWriteTimeUtc stores microseconds while + // LastWriteTimeUtc reports 100 ns ticks, so only a chosen value can be put back exactly. + private static void WriteProjectFileAt( + string projectRoot, + string projectRelativePath, + string contents, + DateTime lastWriteTimeUtc) + { + WriteProjectFile(projectRoot, projectRelativePath, contents); + string absolutePath = AbsoluteProjectPath(projectRoot, projectRelativePath); + File.SetLastWriteTimeUtc(absolutePath, lastWriteTimeUtc); + Assert.That( + new FileInfo(absolutePath).LastWriteTimeUtc, + Is.EqualTo(lastWriteTimeUtc), + "The write time must be settable exactly, or the stamp the detector reads is not the one the test chose."); + } + private static string CreateTempProjectRoot() { string projectRoot = Path.Combine( diff --git a/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs new file mode 100644 index 0000000000..512195bef0 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs @@ -0,0 +1,137 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Verifies when the sibling verdict cache answers from a recorded verdict and when it does not. + /// + public sealed class HotReloadSiblingVerdictCacheTests + { + private const string SnapshotPath = "/proj/Library/UloopHotReload/SourceSnapshot/Asm-mvid1/a.cs"; + private const string OtherSnapshotPath = "/proj/Library/UloopHotReload/SourceSnapshot/Asm-mvid2/a.cs"; + private const string SourcePath = "/proj/Assets/A.cs"; + private const string OtherSourcePath = "/proj/Temp/Copy/A.cs"; + + /// + /// What: an empty cache has no verdict. + /// + [Test] + public void TryGetVerdict_WhenNothingRecorded_ReturnsFalse() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + + bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 10, 100, out bool _); + + Assert.That(found, Is.False); + } + + /// + /// What: a recorded verdict is returned for the same pair and the same stamp. + /// + [Test] + public void TryGetVerdict_SamePairAndStamp_ReturnsRecordedVerdict() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + cache.Record(SnapshotPath, SourcePath, 10, 100, true); + + bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 10, 100, out bool matches); + + Assert.That(found, Is.True); + Assert.That(matches, Is.True); + } + + /// + /// What: a different length makes the recorded verdict unusable. + /// + [Test] + public void TryGetVerdict_DifferentLength_ReturnsFalse() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + cache.Record(SnapshotPath, SourcePath, 10, 100, true); + + bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 11, 100, out bool _); + + Assert.That(found, Is.False); + } + + /// + /// What: a different last write time makes the recorded verdict unusable. + /// + [Test] + public void TryGetVerdict_DifferentWriteTime_ReturnsFalse() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + cache.Record(SnapshotPath, SourcePath, 10, 100, true); + + bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 10, 101, out bool _); + + Assert.That(found, Is.False); + } + + /// + /// What: the same source compared with another snapshot has no verdict, because the + /// snapshot path is part of the key. + /// + [Test] + public void TryGetVerdict_OtherSnapshotPath_ReturnsFalse() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + cache.Record(SnapshotPath, SourcePath, 10, 100, true); + + bool found = cache.TryGetVerdict(OtherSnapshotPath, SourcePath, 10, 100, out bool _); + + Assert.That(found, Is.False); + } + + /// + /// What: another source compared with the same snapshot has no verdict, because the + /// source path is part of the key. + /// + [Test] + public void TryGetVerdict_OtherSourcePath_ReturnsFalse() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + cache.Record(SnapshotPath, SourcePath, 10, 100, true); + + bool found = cache.TryGetVerdict(SnapshotPath, OtherSourcePath, 10, 100, out bool _); + + Assert.That(found, Is.False); + } + + /// + /// What: recording the pair again replaces the earlier verdict. + /// + [Test] + public void Record_SamePairAgain_ReplacesTheVerdict() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + cache.Record(SnapshotPath, SourcePath, 10, 100, true); + cache.Record(SnapshotPath, SourcePath, 10, 100, false); + + bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 10, 100, out bool matches); + + Assert.That(found, Is.True); + Assert.That(matches, Is.False); + Assert.That(cache.Count, Is.EqualTo(1)); + } + + /// + /// What: Clear drops every recorded verdict. + /// + [Test] + public void Clear_DropsEveryVerdict() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + cache.Record(SnapshotPath, SourcePath, 10, 100, true); + cache.Record(OtherSnapshotPath, SourcePath, 10, 100, false); + + cache.Clear(); + + bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 10, 100, out bool _); + Assert.That(found, Is.False); + Assert.That(cache.Count, Is.EqualTo(0)); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs.meta new file mode 100644 index 0000000000..6b2fb1811c --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e765a53c312f6487fa098ff2e8991402 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs index ae717ee7f4..6e008adee5 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs @@ -3,6 +3,8 @@ using System.Globalization; using System.IO; +using UnityEditor.Compilation; + using UnityEngine; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -13,6 +15,17 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class HotReloadChangedSiblingSourceDetector { + // Why per domain: a verdict compares a file with a snapshot that never changes for its MVID, so it can only + // go stale when the file itself changes, and a save changes the file's length or write time. + private static readonly HotReloadSiblingVerdictCache _verdicts = new HotReloadSiblingVerdictCache(); + + static HotReloadChangedSiblingSourceDetector() + { + // Why: a compile that fails keeps the domain alive, so without this the memo would keep + // verdicts for an assembly generation that is no longer current. + CompilationPipeline.compilationStarted += _ => _verdicts.Clear(); + } + /// /// Returns changed sibling absolute paths for , excluding /// . Missing snapshots or DLLs yield an empty @@ -159,7 +172,13 @@ internal static bool SourceMatchesSnapshotDirectory( "assemblySnapshotDirectoryName must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(projectRelativePath), "projectRelativePath must not be null or empty."); - if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)) + if (string.IsNullOrEmpty(sourcePath)) + { + return false; + } + + FileInfo source = new FileInfo(sourcePath); + if (!source.Exists) { return false; } @@ -174,7 +193,7 @@ internal static bool SourceMatchesSnapshotDirectory( return false; } - return BytesEqual(File.ReadAllBytes(sourcePath), File.ReadAllBytes(snapshotPath)); + return MatchesSnapshot(source, snapshotPath); } private static HotReloadChangedSourceScanResult DetectChangedFromSnapshotDirectory( @@ -232,7 +251,8 @@ private static string TryResolveChangedProjectRelativePath( } string absoluteSourcePath = ToAbsoluteProjectPath(projectRoot, normalizedRelativePath); - if (!File.Exists(absoluteSourcePath)) + FileInfo source = new FileInfo(absoluteSourcePath); + if (!source.Exists) { return null; } @@ -245,9 +265,7 @@ private static string TryResolveChangedProjectRelativePath( return null; } - byte[] diskBytes = File.ReadAllBytes(absoluteSourcePath); - byte[] snapshotBytes = File.ReadAllBytes(snapshotPath); - if (BytesEqual(diskBytes, snapshotBytes)) + if (MatchesSnapshot(source, snapshotPath)) { return null; } @@ -256,6 +274,28 @@ private static string TryResolveChangedProjectRelativePath( return normalizedRelativePath; } + // Compares an existing source with an existing snapshot file, reusing the last verdict while + // the source keeps its length and write time. + private static bool MatchesSnapshot(FileInfo source, string snapshotPath) + { + long lastWriteTimeUtcTicks = source.LastWriteTimeUtc.Ticks; + if (_verdicts.TryGetVerdict( + snapshotPath, + source.FullName, + source.Length, + lastWriteTimeUtcTicks, + out bool matches)) + { + return matches; + } + + bool equal = BytesEqual(File.ReadAllBytes(source.FullName), File.ReadAllBytes(snapshotPath)); + // Why the stamp taken before reading: a write during the read moves the stamp on, so the + // next scan reads the file again instead of trusting this verdict. + _verdicts.Record(snapshotPath, source.FullName, source.Length, lastWriteTimeUtcTicks, equal); + return equal; + } + private static HotReloadChangedSourceScanResult LimitChangedSources( List changedProjectRelativePaths) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs new file mode 100644 index 0000000000..cd1cb8164f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Remembers whether a source file matched its snapshot, together with the file's length and + /// last write time, so an unchanged file need not be read again. + /// + internal sealed class HotReloadSiblingVerdictCache + { + // Why both paths in the key: the same file compared with another assembly generation's + // snapshot is a different verdict, and the same snapshot can be compared with another file. + private readonly Dictionary<(string snapshotPath, string sourcePath), Entry> _entries = + new Dictionary<(string snapshotPath, string sourcePath), Entry>(); + + // Why a lock: the rebind planner compares on a thread-pool continuation while + // compilationStarted clears the memo on the main thread. + private readonly object _gate = new object(); + + /// + /// Returns true with the remembered verdict when the pair was recorded with the same stamp. + /// + /// + /// Why a changed stamp reads as no verdict: the caller then has a single path that reads, + /// compares, and records again, so a stale verdict is never returned. + /// + internal bool TryGetVerdict( + string snapshotPath, + string sourcePath, + long length, + long lastWriteTimeUtcTicks, + out bool matchesSnapshot) + { + Debug.Assert(!string.IsNullOrEmpty(snapshotPath), "snapshotPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(sourcePath), "sourcePath must not be null or empty."); + + Entry entry; + lock (_gate) + { + if (!_entries.TryGetValue((snapshotPath, sourcePath), out entry)) + { + matchesSnapshot = false; + return false; + } + } + + if (entry.Length != length || entry.LastWriteTimeUtcTicks != lastWriteTimeUtcTicks) + { + matchesSnapshot = false; + return false; + } + + matchesSnapshot = entry.MatchesSnapshot; + return true; + } + + /// + /// Records the verdict for the pair, replacing any earlier one. + /// + internal void Record( + string snapshotPath, + string sourcePath, + long length, + long lastWriteTimeUtcTicks, + bool matchesSnapshot) + { + Debug.Assert(!string.IsNullOrEmpty(snapshotPath), "snapshotPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(sourcePath), "sourcePath must not be null or empty."); + + lock (_gate) + { + _entries[(snapshotPath, sourcePath)] = new Entry(length, lastWriteTimeUtcTicks, matchesSnapshot); + } + } + + internal void Clear() + { + lock (_gate) + { + _entries.Clear(); + } + } + + internal int Count + { + get + { + lock (_gate) + { + return _entries.Count; + } + } + } + + private readonly struct Entry + { + internal readonly long Length; + internal readonly long LastWriteTimeUtcTicks; + internal readonly bool MatchesSnapshot; + + internal Entry(long length, long lastWriteTimeUtcTicks, bool matchesSnapshot) + { + Length = length; + LastWriteTimeUtcTicks = lastWriteTimeUtcTicks; + MatchesSnapshot = matchesSnapshot; + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs.meta new file mode 100644 index 0000000000..5e1455b13c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9fe33db03b44e461f9e50087fcf28ff1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md index 236af49f53..715d2611ac 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md @@ -348,6 +348,12 @@ line each. A file with a declaration that hot reload refused to introduce (its `Warnings` line says the type requires a compile) gets no such line, because that compile also establishes its baseline. This also holds for an existing file that gains such a declaration, for example a nested type or a delegate. +Within one Editor session, a source compared with its baseline once is compared again +only when its length or last write time changes; a rewrite that keeps both (for example +a copy that preserves timestamps) is noticed only after the next domain reload, such as +`uloop compile`. This applies wherever a source is compared with its baseline: finding +drifted siblings, deciding which live patches to re-apply after a skip, and selecting +files when `--files` is omitted. Property getters with a body (including expression-bodied properties) are patched like ordinary methods. Editing a compiled property's setter, init, or indexer accessor From 5614e38df4d18c756a4f980644c03c8eaa66ab81 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 13:20:14 +0900 Subject: [PATCH 12/29] perf: Hot reload keeps the PDB document list of an assembly across domain reloads (#3242) --- .../HotReloadPdbDocumentIndexTests.cs | 285 +++++++++++++++++- .../HotReload/HotReloadSourceSnapshotTests.cs | 112 ++++--- .../HotReload/Shared/HotReloadConstants.cs | 6 + .../Shared/HotReloadPdbDocumentIndex.cs | 232 +++++++++++++- docs/hot-reload.md | 7 +- 5 files changed, 595 insertions(+), 47 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs index 52e6701615..9f739e6ade 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.IO; using System.Linq; using System.Security.Cryptography; @@ -36,12 +37,25 @@ public class HotReloadPdbDocumentIndexTests private const string PackageSourceAssetPath = "Packages/io.github.hatayama.uloopmcp/Runtime/PausePoints/UloopPausePoint.cs"; + private string _persistenceDirectory; private HotReloadPdbDocumentIndex _index; [SetUp] public void SetUp() { - _index = new HotReloadPdbDocumentIndex(); + // Why a directory per test: a list persisted by an earlier test or domain would let a + // new index answer without reading the PDB and break every LoadCount expectation. + _persistenceDirectory = Path.Combine(Path.GetTempPath(), "uloop-pdb-documents-" + Guid.NewGuid().ToString("N")); + _index = new HotReloadPdbDocumentIndex(_persistenceDirectory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_persistenceDirectory)) + { + Directory.Delete(_persistenceDirectory, recursive: true); + } } /// @@ -242,13 +256,21 @@ public void TryFindDocument_PdbUnreadableAfterAListWasKept_ThrowsAndKeepsNoStale bool foundBefore = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); // Why a shorter file: the length alone makes the files differ from the ones the list // was read from, even if the new write time lands on the same tick as the old one. + string persistedBefore = File.ReadAllText(PersistedListPath(dllPath)); File.WriteAllBytes(pdbPath, new byte[] { 0x6E, 0x6F, 0x74, 0x20, 0x61, 0x20, 0x70, 0x64, 0x62 }); TestDelegate findAgain = () => Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + TestDelegate findWithNewIndex = () => Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); Assert.That(foundBefore, Is.True); Assert.That(findAgain, Throws.Exception, "The list read before the PDB changed must not answer."); Assert.That(findAgain, Throws.Exception, "No list may be stored for a PDB that could not be read."); Assert.That(_index.LoadCount, Is.EqualTo(1)); + Assert.That( + File.ReadAllText(PersistedListPath(dllPath)), + Is.EqualTo(persistedBefore), + "A walk that threw must not rewrite the persisted list."); + Assert.That(findWithNewIndex, Throws.Exception, "The persisted list of the earlier files must not answer."); } finally { @@ -256,6 +278,267 @@ public void TryFindDocument_PdbUnreadableAfterAListWasKept_ThrowsAndKeepsNoStale } } + /// + /// What: a new index on the same directory answers from the list the first index persisted, + /// without reading the dll and the PDB, and the document it returns is the same. + /// + [Test] + public void TryFindDocument_NewIndexOnTheSameDirectory_AnswersFromThePersistedListWithoutReadingThePdb() + { + string dllPath = DllPath(TestAssemblyName); + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument firstDocument); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument secondDocument); + + Assert.That(foundFirst, Is.True); + Assert.That(foundSecond, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(0)); + Assert.That(second.PersistedLoadCount, Is.EqualTo(1)); + Assert.That(secondDocument.Url, Is.EqualTo(firstDocument.Url)); + Assert.That(secondDocument.HashAlgorithm, Is.EqualTo(firstDocument.HashAlgorithm)); + Assert.That(secondDocument.Hash.SequenceEqual(firstDocument.Hash), Is.True); + } + + /// + /// What: a dll written after its list was persisted makes a new index read the PDB again and + /// persist the new list, which the next new index then reads. + /// + [Test] + public void TryFindDocument_DllWriteTimeChangedAfterTheListWasPersisted_ReadsThePdbAgainAndPersistsTheNewList() + { + string dllPath = CopyAssemblyToTemp(TestAssemblyName); + try + { + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + File.SetLastWriteTimeUtc(dllPath, File.GetLastWriteTimeUtc(dllPath).AddSeconds(2)); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + HotReloadPdbDocumentIndex third = new HotReloadPdbDocumentIndex(_persistenceDirectory); + bool foundThird = Find(third, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundFirst, Is.True); + Assert.That(foundSecond, Is.True); + Assert.That(foundThird, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(1)); + Assert.That(second.PersistedLoadCount, Is.EqualTo(0)); + Assert.That(third.LoadCount, Is.EqualTo(0)); + Assert.That(third.PersistedLoadCount, Is.EqualTo(1)); + } + finally + { + Directory.Delete(Path.GetDirectoryName(dllPath), recursive: true); + } + } + + /// + /// What: an empty persisted file is treated as missing: the PDB is read and the list is + /// written again, so the next new index reads it from the file. + /// + [Test] + public void TryFindDocument_PersistedFileEmpty_ReadsThePdbAndWritesTheListAgain() + { + string dllPath = DllPath(TestAssemblyName); + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + File.WriteAllBytes(PersistedListPath(dllPath), Array.Empty()); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + long rewrittenLength = new FileInfo(PersistedListPath(dllPath)).Length; + HotReloadPdbDocumentIndex third = new HotReloadPdbDocumentIndex(_persistenceDirectory); + bool foundThird = Find(third, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundFirst, Is.True); + Assert.That(foundSecond, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(1)); + Assert.That(rewrittenLength, Is.GreaterThan(0)); + Assert.That(foundThird, Is.True); + Assert.That(third.LoadCount, Is.EqualTo(0)); + } + + /// + /// What: a persisted file that lost its last document line, while still claiming the + /// original count, is treated as missing rather than answered from the remaining lines. + /// + [Test] + public void TryFindDocument_PersistedFileMissingItsLastDocumentLine_ReadsThePdb() + { + string dllPath = DllPath(TestAssemblyName); + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + string persisted = File.ReadAllText(PersistedListPath(dllPath)); + string withoutTrailingNewline = persisted.Substring(0, persisted.Length - 1); + File.WriteAllText( + PersistedListPath(dllPath), + withoutTrailingNewline.Substring(0, withoutTrailingNewline.LastIndexOf('\n') + 1)); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundFirst, Is.True); + Assert.That(persisted, Does.EndWith("\n")); + Assert.That(foundSecond, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(1)); + } + + /// + /// What: a persisted file whose header, stamp, count and line count are right but whose one + /// document line has a hash that is not hex is treated as missing: the PDB is read, the + /// same document is found, and the list is written again so the next new index reads it. + /// + [Test] + public void TryFindDocument_PersistedFileWithMalformedDocumentLine_ReadsThePdb() + { + string dllPath = DllPath(TestAssemblyName); + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument firstDocument); + string[] lines = File.ReadAllText(PersistedListPath(dllPath)).Split('\n'); + string[] documentFields = lines[2].Split(new[] { '\t' }, 3); + documentFields[1] = "zz"; + lines[2] = string.Join("\t", documentFields); + File.WriteAllText(PersistedListPath(dllPath), string.Join("\n", lines)); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument secondDocument); + HotReloadPdbDocumentIndex third = new HotReloadPdbDocumentIndex(_persistenceDirectory); + bool foundThird = Find(third, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundFirst, Is.True); + Assert.That(foundSecond, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(1)); + Assert.That(second.PersistedLoadCount, Is.EqualTo(0)); + Assert.That(secondDocument.Url, Is.EqualTo(firstDocument.Url)); + Assert.That(secondDocument.Hash.SequenceEqual(firstDocument.Hash), Is.True); + Assert.That(foundThird, Is.True); + Assert.That(third.LoadCount, Is.EqualTo(0)); + Assert.That(third.PersistedLoadCount, Is.EqualTo(1)); + } + + /// + /// What: a persisted file whose stamp names another dll length is not used, because it was + /// written for other files. + /// + [Test] + public void TryFindDocument_PersistedFileWithAnotherStamp_ReadsThePdb() + { + string dllPath = DllPath(TestAssemblyName); + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + string[] lines = File.ReadAllText(PersistedListPath(dllPath)).Split('\n'); + string[] stampFields = lines[1].Split('\t'); + stampFields[0] = (long.Parse(stampFields[0], CultureInfo.InvariantCulture) + 1).ToString(CultureInfo.InvariantCulture); + lines[1] = string.Join("\t", stampFields); + File.WriteAllText(PersistedListPath(dllPath), string.Join("\n", lines)); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundFirst, Is.True); + Assert.That(foundSecond, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(1)); + } + + /// + /// What: a persisted file with one document line more than its count, still ending with a + /// newline, is treated as missing rather than answered from its first count lines. + /// + [Test] + public void TryFindDocument_PersistedFileWithAnExtraDocumentLine_ReadsThePdb() + { + AssertRewrittenListIsNotUsed(persisted => + { + string withoutTrailingNewline = persisted.Substring(0, persisted.Length - 1); + string lastLine = withoutTrailingNewline.Substring(withoutTrailingNewline.LastIndexOf('\n') + 1); + return persisted + lastLine + "\n"; + }); + } + + /// + /// What: a persisted file with text after its last newline is treated as missing, although + /// splitting it still yields the expected number of parts. + /// + [Test] + public void TryFindDocument_PersistedFileWithTextAfterTheLastNewline_ReadsThePdb() + { + AssertRewrittenListIsNotUsed(persisted => persisted + "garbage"); + } + + /// + /// What: a document line whose hash algorithm is not a defined value makes the file missing. + /// + [Test] + public void TryFindDocument_PersistedDocumentLineWithAnUndefinedHashAlgorithm_ReadsThePdb() + { + AssertRewrittenListIsNotUsed(persisted => RewriteFirstDocumentField(persisted, 0, field => "9")); + } + + /// + /// What: a document line whose hash has an odd number of hex digits makes the file missing + /// instead of answering with a hash one byte short. + /// + [Test] + public void TryFindDocument_PersistedDocumentLineWithAnOddLengthHash_ReadsThePdb() + { + AssertRewrittenListIsNotUsed(persisted => RewriteFirstDocumentField(persisted, 1, field => + { + Assert.That(field.Length, Is.GreaterThan(0), "Precondition: the first document must have a hash."); + return field.Substring(0, field.Length - 1); + })); + } + + /// + /// What: when the persisted list cannot be moved into place, the lookup throws after the walk, + /// no temp file is left behind, and a later index writes the list once the path is free. + /// + [Test] + public void TryFindDocument_PersistedListPathBlocked_ThrowsAndLeavesNoTempFile() + { + string dllPath = DllPath(TestAssemblyName); + string persistedPath = PersistedListPath(dllPath); + // Why a directory: File.Exists is false for it, so the write skips the delete and the move throws. + Directory.CreateDirectory(persistedPath); + TestDelegate find = () => Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(find, Throws.Exception); + Assert.That(Directory.GetFiles(_persistenceDirectory, "*.tmp-*"), Is.Empty); + Assert.That(_index.LoadCount, Is.EqualTo(1)); + + Directory.Delete(persistedPath); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundSecond, Is.True); + Assert.That(File.Exists(persistedPath), Is.True); + } + + // Persists the list with _index, rewrites the file, and asserts that a new index walks the + // PDB instead of answering from the rewritten file. + private void AssertRewrittenListIsNotUsed(Func rewrite) + { + string dllPath = DllPath(TestAssemblyName); + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + string persisted = File.ReadAllText(PersistedListPath(dllPath)); + Assert.That(persisted, Does.EndWith("\n")); + File.WriteAllText(PersistedListPath(dllPath), rewrite(persisted)); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundFirst, Is.True); + Assert.That(foundSecond, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(1)); + Assert.That(second.PersistedLoadCount, Is.EqualTo(0)); + } + + private static string RewriteFirstDocumentField(string persisted, int fieldIndex, Func rewrite) + { + string[] lines = persisted.Split('\n'); + string[] fields = lines[2].Split(new[] { '\t' }, 3); + fields[fieldIndex] = rewrite(fields[fieldIndex]); + lines[2] = string.Join("\t", fields); + return string.Join("\n", lines); + } + + private string PersistedListPath(string dllPath) + { + return Path.Combine(_persistenceDirectory, Path.GetFileNameWithoutExtension(dllPath) + ".txt"); + } + private static string ProjectRoot() { return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs index 74914225fd..243b6f3431 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs @@ -82,17 +82,27 @@ public void PortablePdb_DocumentChecksum_MatchesSourceFileBytes() public void TryFindDocument_ReturnsTheSameDocumentAsTheWalk() { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(); - - AssertIndexFindsTheWalkedDocument(index, TestAssemblyDllPath(projectRoot), FixtureProjectRelativePath); - AssertIndexFindsTheWalkedDocument(index, TestAssemblyDllPath(projectRoot), CoreFixtureProjectRelativePath); - AssertIndexFindsTheWalkedDocument( - index, - Path.Combine( - projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, - PredefinedEditorAssemblyName + HotReloadConstants.CompiledAssemblyExtension), - PredefinedEditorFixtureProjectRelativePath); + string persistenceDirectory = Path.Combine(Path.GetTempPath(), "uloop-pdb-documents-" + Guid.NewGuid().ToString("N")); + HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(persistenceDirectory); + try + { + AssertIndexFindsTheWalkedDocument(index, TestAssemblyDllPath(projectRoot), FixtureProjectRelativePath); + AssertIndexFindsTheWalkedDocument(index, TestAssemblyDllPath(projectRoot), CoreFixtureProjectRelativePath); + AssertIndexFindsTheWalkedDocument( + index, + Path.Combine( + projectRoot, + HotReloadConstants.ScriptAssembliesRelativeDirectory, + PredefinedEditorAssemblyName + HotReloadConstants.CompiledAssemblyExtension), + PredefinedEditorFixtureProjectRelativePath); + } + finally + { + if (Directory.Exists(persistenceDirectory)) + { + Directory.Delete(persistenceDirectory, recursive: true); + } + } } private static void AssertIndexFindsTheWalkedDocument( @@ -331,22 +341,32 @@ public void DescribeSnapshotMiss_ForAVerifiedSnapshot_ReportsNone() public void LoadVerifiedSnapshotSourceAt_TwoFilesOfOneAssembly_ReadsThePdbOnce() { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(); - - string first = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( - projectRoot, - FixtureProjectRelativePath, - TestAssemblyDllPath(projectRoot), - index); - string second = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( - projectRoot, - CoreFixtureProjectRelativePath, - TestAssemblyDllPath(projectRoot), - index); + string persistenceDirectory = Path.Combine(Path.GetTempPath(), "uloop-pdb-documents-" + Guid.NewGuid().ToString("N")); + HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(persistenceDirectory); + try + { + string first = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( + projectRoot, + FixtureProjectRelativePath, + TestAssemblyDllPath(projectRoot), + index); + string second = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( + projectRoot, + CoreFixtureProjectRelativePath, + TestAssemblyDllPath(projectRoot), + index); - Assert.That(first, Is.Not.Null); - Assert.That(second, Is.Not.Null); - Assert.That(index.LoadCount, Is.EqualTo(1)); + Assert.That(first, Is.Not.Null); + Assert.That(second, Is.Not.Null); + Assert.That(index.LoadCount, Is.EqualTo(1)); + } + finally + { + if (Directory.Exists(persistenceDirectory)) + { + Directory.Delete(persistenceDirectory, recursive: true); + } + } } /// @@ -356,22 +376,32 @@ public void LoadVerifiedSnapshotSourceAt_TwoFilesOfOneAssembly_ReadsThePdbOnce() public void DescribeSnapshotMissAt_AfterALoadThatFoundNoDocument_DoesNotReadThePdbAgain() { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(); - - string loaded = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( - projectRoot, - BodylessFixtureProjectRelativePath, - TestAssemblyDllPath(projectRoot), - index); - HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMissAt( - projectRoot, - BodylessFixtureProjectRelativePath, - TestAssemblyDllPath(projectRoot), - index); + string persistenceDirectory = Path.Combine(Path.GetTempPath(), "uloop-pdb-documents-" + Guid.NewGuid().ToString("N")); + HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(persistenceDirectory); + try + { + string loaded = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( + projectRoot, + BodylessFixtureProjectRelativePath, + TestAssemblyDllPath(projectRoot), + index); + HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMissAt( + projectRoot, + BodylessFixtureProjectRelativePath, + TestAssemblyDllPath(projectRoot), + index); - Assert.That(loaded, Is.Null); - Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoDocumentInPdb)); - Assert.That(index.LoadCount, Is.EqualTo(1)); + Assert.That(loaded, Is.Null); + Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoDocumentInPdb)); + Assert.That(index.LoadCount, Is.EqualTo(1)); + } + finally + { + if (Directory.Exists(persistenceDirectory)) + { + Directory.Delete(persistenceDirectory, recursive: true); + } + } } private static string TestAssemblyDllPath(string projectRoot) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 3fa77955dc..9400ebc9b0 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -49,6 +49,12 @@ internal static class HotReloadConstants // affect the on-disk layout. Adoption is decided at use time by PDB document checksum. public const string SourceSnapshotRelativeDirectory = "Library/UloopHotReload/SourceSnapshot"; + // Per-assembly lists of the documents a PDB's sequence points refer to, keyed by the dll's + // file name and stamped with the dll's and the PDB's length, write time and MVID. Lets the + // first hot reload run after a domain reload skip the walk over every sequence point. + // "fmt1" = generation of the file format, bumped the same way as PublicizedRefs. + public const string PdbDocumentsRelativeDirectory = "Library/UloopHotReload/PdbDocuments/fmt1"; + // Package-relative directory of the out-of-process transform worker sources (tilde dir = Unity-ignored). public const string WorkerSourcePackageRelativePath = "Editor/FirstPartyTools/HotReload/TransformWorker~"; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs index ae4296a6f7..54acfe57a1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; +using System.Text; using Mono.Cecil; using Mono.Cecil.Cil; @@ -34,6 +36,8 @@ public HotReloadPdbDocument(string url, DocumentHashAlgorithm hashAlgorithm, byt /// again when the dll's length, write time or MVID, or the PDB's length or write time, /// differs from the files it was read from. There is no capacity limit: an entry is a short /// list of urls and checksums, and there is at most one entry per assembly of the project. + /// Each list is also written to a file under the persistence directory with the same five + /// values, so a new index in the next domain reads it from there instead of walking the PDB. /// internal sealed class HotReloadPdbDocumentIndex { @@ -82,6 +86,10 @@ public override int GetHashCode() } } + private const string PersistedFormatHeader = "uloop-pdb-documents 1"; + private const int PersistedStampFieldCount = 6; + private const int PersistedDocumentFieldCount = 3; + private sealed class Entry { public readonly FileStamp Stamp; @@ -96,11 +104,22 @@ public Entry(FileStamp stamp, List documents) // Why a shared instance: the snapshot loader is static and has static callers in // several assemblies, like the compiled call-site cache this mirrors. - public static HotReloadPdbDocumentIndex Shared { get; } = new HotReloadPdbDocumentIndex(); + public static HotReloadPdbDocumentIndex Shared { get; } = new HotReloadPdbDocumentIndex( + Path.Combine( + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + HotReloadConstants.PdbDocumentsRelativeDirectory)); private readonly object _gate = new object(); private readonly Dictionary _entries = new Dictionary(StringComparer.Ordinal); + private readonly string _persistenceDirectory; private int _loadCount; + private int _persistedLoadCount; + + internal HotReloadPdbDocumentIndex(string persistenceDirectory) + { + Debug.Assert(!string.IsNullOrEmpty(persistenceDirectory), "persistenceDirectory must not be null or empty."); + _persistenceDirectory = persistenceDirectory; + } /// /// Number of times a dll and its PDB were read and walked. @@ -116,6 +135,20 @@ internal int LoadCount } } + /// + /// Number of times a list was read from its persisted file instead of walking the PDB. + /// + internal int PersistedLoadCount + { + get + { + lock (_gate) + { + return _persistedLoadCount; + } + } + } + /// /// Finds the document whose url names among the /// documents a sequence point refers to. False when no sequence point refers to such a @@ -141,11 +174,26 @@ internal bool TryFindDocument( { if (!_entries.TryGetValue(fullDllPath, out Entry entry) || !entry.Stamp.Equals(stamp)) { + string persistedPath = PersistedListPath(fullDllPath); + List documents = TryReadPersistedList(persistedPath, stamp); + if (documents != null) + { + _persistedLoadCount++; + } + else + { + // Why the walk before any write: a walk that throws leaves no entry and no + // file, so the next lookup reads the files again instead of answering from + // a list of other files. + documents = ReadDocuments(fullDllPath, pdbPath); + _loadCount++; + WritePersistedList(persistedPath, stamp, documents); + } + // Why stored under the stamp read before the walk: if a file is replaced while // it is being read, the next lookup sees another stamp and reads again, so a // list is never served for files it was not read from. - entry = new Entry(stamp, ReadDocuments(fullDllPath, pdbPath)); - _loadCount++; + entry = new Entry(stamp, documents); _entries[fullDllPath] = entry; } @@ -153,6 +201,184 @@ internal bool TryFindDocument( } } + private string PersistedListPath(string fullDllPath) + { + return Path.Combine(_persistenceDirectory, Path.GetFileNameWithoutExtension(fullDllPath) + ".txt"); + } + + // File format: UTF-8 without a BOM, every line ends with "\n", fields are TAB-separated and + // numbers are invariant. + // line 1: the format header + // line 2: dll length, dll write time ticks, MVID, PDB length, PDB write time ticks, count + // then count lines: (int)HashAlgorithm, the hash in hex ("" for none), url + // Null for a missing file, another stamp, or any malformed part: a file cut short or edited + // by hand must not answer with part of a list. Why the url last: it is the only field that + // can hold anything, so splitting into three keeps a TAB in a url intact. + private static List TryReadPersistedList(string path, FileStamp stamp) + { + if (!File.Exists(path)) + { + return null; + } + + string[] lines = File.ReadAllText(path, Encoding.UTF8).Split('\n'); + for (int index = 0; index < lines.Length; index++) + { + lines[index] = lines[index].TrimEnd('\r'); + } + + if (lines.Length < 3 || !string.Equals(lines[0], PersistedFormatHeader, StringComparison.Ordinal)) + { + return null; + } + + if (!TryParseStampLine(lines[1], out FileStamp persistedStamp, out int count) + || !persistedStamp.Equals(stamp)) + { + return null; + } + + // The header, the stamp, count documents and the empty string after the last "\n". + if (lines.Length != 3 + count || lines[lines.Length - 1].Length != 0) + { + return null; + } + + List documents = new List(count); + for (int index = 0; index < count; index++) + { + if (!TryParseDocumentLine(lines[2 + index], out HotReloadPdbDocument document)) + { + return null; + } + + documents.Add(document); + } + + return documents; + } + + private static bool TryParseStampLine(string line, out FileStamp stamp, out int count) + { + stamp = default(FileStamp); + count = 0; + string[] fields = line.Split('\t'); + if (fields.Length != PersistedStampFieldCount + || !TryParseLong(fields[0], out long dllLength) + || !TryParseLong(fields[1], out long dllTicks) + || fields[2].Length == 0 + || !TryParseLong(fields[3], out long pdbLength) + || !TryParseLong(fields[4], out long pdbTicks) + || !int.TryParse(fields[5], NumberStyles.None, CultureInfo.InvariantCulture, out count)) + { + return false; + } + + stamp = new FileStamp(dllLength, dllTicks, fields[2], pdbLength, pdbTicks); + return true; + } + + private static bool TryParseDocumentLine(string line, out HotReloadPdbDocument document) + { + document = default(HotReloadPdbDocument); + string[] fields = line.Split(new[] { '\t' }, PersistedDocumentFieldCount); + if (fields.Length != PersistedDocumentFieldCount || fields[2].Length == 0) + { + return false; + } + + if (!int.TryParse(fields[0], NumberStyles.None, CultureInfo.InvariantCulture, out int algorithmValue) + || !Enum.IsDefined(typeof(DocumentHashAlgorithm), algorithmValue)) + { + return false; + } + + if (!TryParseHex(fields[1], out byte[] hash)) + { + return false; + } + + document = new HotReloadPdbDocument(fields[2], (DocumentHashAlgorithm)algorithmValue, hash); + return true; + } + + private static bool TryParseLong(string text, out long value) + { + return long.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value); + } + + // "" is the empty hash; an odd length or a character that is not a hex digit is malformed. + private static bool TryParseHex(string text, out byte[] bytes) + { + bytes = null; + if (text.Length % 2 != 0) + { + return false; + } + + if (text.Length == 0) + { + bytes = Array.Empty(); + return true; + } + + byte[] parsed = new byte[text.Length / 2]; + for (int index = 0; index < parsed.Length; index++) + { + if (!byte.TryParse( + text.Substring(index * 2, 2), + NumberStyles.AllowHexSpecifier, + CultureInfo.InvariantCulture, + out parsed[index])) + { + return false; + } + } + + bytes = parsed; + return true; + } + + private void WritePersistedList(string path, FileStamp stamp, List documents) + { + Directory.CreateDirectory(_persistenceDirectory); + StringBuilder text = new StringBuilder(); + text.Append(PersistedFormatHeader).Append('\n'); + text.Append(stamp.DllLength.ToString(CultureInfo.InvariantCulture)).Append('\t') + .Append(stamp.DllLastWriteTimeUtcTicks.ToString(CultureInfo.InvariantCulture)).Append('\t') + .Append(stamp.ModuleVersionId).Append('\t') + .Append(stamp.PdbLength.ToString(CultureInfo.InvariantCulture)).Append('\t') + .Append(stamp.PdbLastWriteTimeUtcTicks.ToString(CultureInfo.InvariantCulture)).Append('\t') + .Append(documents.Count.ToString(CultureInfo.InvariantCulture)).Append('\n'); + foreach (HotReloadPdbDocument document in documents) + { + text.Append(((int)document.HashAlgorithm).ToString(CultureInfo.InvariantCulture)).Append('\t') + .Append(document.Hash == null ? string.Empty : BitConverter.ToString(document.Hash).Replace("-", string.Empty)) + .Append('\t') + .Append(document.Url).Append('\n'); + } + + // Why a temp file and a move: a reader in another domain never sees a half-written list. + string tempPath = path + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + File.WriteAllText(tempPath, text.ToString(), new UTF8Encoding(false)); + if (File.Exists(path)) + { + File.Delete(path); + } + + File.Move(tempPath, path); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + private static FileStamp ReadStamp(string fullDllPath, string pdbPath, string moduleVersionId) { FileInfo dll = new FileInfo(fullDllPath); diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 4bf0a678a1..6feca66a6b 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -72,8 +72,11 @@ added on one side alone fails a test rather than silently serializing to nothing Harmony ID: `io.github.hatayama.uloop.hot-reload` (distinct from the pause point's ID). Caches: `Library/UloopHotReload/PublicizedRefs/fmt2/-.dll`, `Library/UloopHotReload/PublicizedExternalRefs/fmt1/-.dll`, -`Library/UloopHotReload/Worker//`, and -`Library/UloopHotReload/SourceSnapshot/-/`. +`Library/UloopHotReload/Worker//`, +`Library/UloopHotReload/SourceSnapshot/-/`, and +`Library/UloopHotReload/PdbDocuments/fmt1/.txt` (the documents the PDB's sequence +points refer to, stamped with the dll's and the PDB's length and write time and the MVID, so the +first run after a domain reload does not walk the PDB again while they still match). The shim compile references a fully publicized copy of the edited assembly, and of every other project assembly that grants the edited one its internals through `InternalsVisibleTo`. Any other project assembly is referenced through a `PublicizedExternalRefs` copy that keeps its top-level From 0dc16328c2dbddafef33f570f7627d4cbd477ce0 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 13:27:58 +0900 Subject: [PATCH 13/29] docs: Explain why a Multiplayer Play Mode player can quit with a Fatal Error before uloop loads (#3243) --- .../references/multiplayer-play-mode.md | 16 ++++++++++++++++ .../references/multiplayer-play-mode.md | 16 ++++++++++++++++ .../Skill/references/multiplayer-play-mode.md | 16 ++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md b/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md index 29e02aa427..05e55195c8 100644 --- a/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md +++ b/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md @@ -62,6 +62,22 @@ main Editor) do not block each other. file as `Failed` with `Compiled assembly not found ... Compile the main Editor's project first.` +## When a player quits right after activation + +A player whose Editor shows +`Fatal Error! Compilation Pipeline: Could not read file Packages//.asmdef` +a few seconds after it was activated, and then quits, never loaded its packages. Multiplayer +Play Mode gives a player symlinks to `Assets` and `ProjectSettings` and an empty `Packages` +folder (that is normal), and starts its Editor with `-noUpm -upmRestorePackages`: the player +takes the package list from the main project's `Library/PackageManager/ProjectCache` instead +of resolving packages itself. When the main Editor showed `Project has invalid dependencies` at +startup (for example a `file:` dependency whose folder is missing), that file is absent and the +player finds no package. Fix the main project's dependencies, restart the main Editor until it +starts without that dialog and `/Library/PackageManager/ProjectCache` exists, +then activate the player again. Until then +`uloop --project-path /Library/VP/mppm ...` fails at project resolution: the +player never loaded uloop's project runner. + ## Known limitations - `Stop` sent while Virtual Players are still starting (the main Editor is not yet in Play Mode) diff --git a/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md b/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md index 29e02aa427..05e55195c8 100644 --- a/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md +++ b/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md @@ -62,6 +62,22 @@ main Editor) do not block each other. file as `Failed` with `Compiled assembly not found ... Compile the main Editor's project first.` +## When a player quits right after activation + +A player whose Editor shows +`Fatal Error! Compilation Pipeline: Could not read file Packages//.asmdef` +a few seconds after it was activated, and then quits, never loaded its packages. Multiplayer +Play Mode gives a player symlinks to `Assets` and `ProjectSettings` and an empty `Packages` +folder (that is normal), and starts its Editor with `-noUpm -upmRestorePackages`: the player +takes the package list from the main project's `Library/PackageManager/ProjectCache` instead +of resolving packages itself. When the main Editor showed `Project has invalid dependencies` at +startup (for example a `file:` dependency whose folder is missing), that file is absent and the +player finds no package. Fix the main project's dependencies, restart the main Editor until it +starts without that dialog and `/Library/PackageManager/ProjectCache` exists, +then activate the player again. Until then +`uloop --project-path /Library/VP/mppm ...` fails at project resolution: the +player never loaded uloop's project runner. + ## Known limitations - `Stop` sent while Virtual Players are still starting (the main Editor is not yet in Play Mode) diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md index 29e02aa427..05e55195c8 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md @@ -62,6 +62,22 @@ main Editor) do not block each other. file as `Failed` with `Compiled assembly not found ... Compile the main Editor's project first.` +## When a player quits right after activation + +A player whose Editor shows +`Fatal Error! Compilation Pipeline: Could not read file Packages//.asmdef` +a few seconds after it was activated, and then quits, never loaded its packages. Multiplayer +Play Mode gives a player symlinks to `Assets` and `ProjectSettings` and an empty `Packages` +folder (that is normal), and starts its Editor with `-noUpm -upmRestorePackages`: the player +takes the package list from the main project's `Library/PackageManager/ProjectCache` instead +of resolving packages itself. When the main Editor showed `Project has invalid dependencies` at +startup (for example a `file:` dependency whose folder is missing), that file is absent and the +player finds no package. Fix the main project's dependencies, restart the main Editor until it +starts without that dialog and `/Library/PackageManager/ProjectCache` exists, +then activate the player again. Until then +`uloop --project-path /Library/VP/mppm ...` fails at project resolution: the +player never loaded uloop's project runner. + ## Known limitations - `Stop` sent while Virtual Players are still starting (the main Editor is not yet in Play Mode) From c328223f09aaf11db99acf34839e1bbcad7fe82d Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 14:15:26 +0900 Subject: [PATCH 14/29] fix: Hot reload in a Virtual Player patches only the edited method of a package source (#3244) --- .../HotReload/HotReloadSourceSnapshotTests.cs | 68 +++++++++++++++++++ .../Shared/HotReloadSourceBaseline.cs | 10 ++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs index 243b6f3431..7765ef4ef6 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs @@ -191,6 +191,74 @@ public void LoadVerifiedSnapshotSource_PackageSourceGivenByItsAssetPath_ReturnsT Assert.That(loaded, Is.EqualTo(File.ReadAllText(Path.Combine(projectRoot, PackageSourcePhysicalPath)))); } + /// + /// What: in a Multiplayer Play Mode Virtual Player, whose root lies under the main project's + /// Library, a package source's PDB document is found by the path the main project's compile + /// recorded, so the verified snapshot loads instead of reporting NoDocumentInPdb. + /// + [Test] + public void LoadVerifiedSnapshotSourceAt_VirtualPlayerRootAndPackageSource_FindsTheDocumentTheMainProjectCompiled() + { + string mainRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string dllPath = Path.Combine( + mainRoot, + HotReloadConstants.ScriptAssembliesRelativeDirectory, + PausePointsRuntimeAssemblyName + HotReloadConstants.CompiledAssemblyExtension); + string physicalSourcePath = Path.Combine(mainRoot, PackageSourcePhysicalPath); + Assert.That(File.Exists(dllPath), Is.True, "Precondition: the package assembly must be compiled."); + Assert.That(File.Exists(physicalSourcePath), Is.True, "Precondition: the package source must exist."); + + string virtualPlayersDirectory = Path.Combine(mainRoot, "Library", "VP"); + bool createdVirtualPlayersDirectory = !Directory.Exists(virtualPlayersDirectory); + string playerRoot = Path.Combine(virtualPlayersDirectory, "uloop-test-" + Guid.NewGuid().ToString("N")); + try + { + // The player's snapshot is planted by hand: a root under Library is not a project the + // Editor resolves package asset paths for, so the capture would skip the source. + string mvid = HotReloadSourceSnapshotter.ReadAssemblyMvid(dllPath); + string snapshotDirectory = Path.Combine( + playerRoot, + HotReloadConstants.SourceSnapshotRelativeDirectory, + PausePointsRuntimeAssemblyName + "-" + mvid); + Directory.CreateDirectory(snapshotDirectory); + File.Copy( + physicalSourcePath, + Path.Combine( + snapshotDirectory, + HotReloadSourceSnapshotter.HashProjectRelativePath(PackageSourceAssetPath) + ".cs")); + HotReloadPdbDocumentIndex documentIndex = new HotReloadPdbDocumentIndex( + Path.Combine(playerRoot, HotReloadConstants.PdbDocumentsRelativeDirectory)); + + HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMissAt( + playerRoot, + PackageSourceAssetPath, + dllPath, + documentIndex); + string loaded = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( + playerRoot, + PackageSourceAssetPath, + dllPath, + documentIndex); + + Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.None)); + Assert.That(loaded, Is.EqualTo(File.ReadAllText(physicalSourcePath))); + } + finally + { + if (Directory.Exists(playerRoot)) + { + Directory.Delete(playerRoot, recursive: true); + } + + if (createdVirtualPlayersDirectory + && Directory.Exists(virtualPlayersDirectory) + && Directory.GetFileSystemEntries(virtualPlayersDirectory).Length == 0) + { + Directory.Delete(virtualPlayersDirectory); + } + } + } + /// /// What: a one-byte tamper of the snapshot bytes fails PDB checksum validation and yields null. /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs index bcdafe33a7..55022836d7 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs @@ -128,7 +128,15 @@ private static HotReloadSnapshotMissReason TryLoadVerifiedSnapshotSource( // every caller of the loader already runs on the Unity main thread the Package Manager // requires: a run's group step, and the pause-point port, which the pause-point tools and a // run's patch step call. - string pdbLookupPath = ScriptPackageRoots.ToPhysicalPath(projectRoot, slashNormalizedRelativePath); + // Why the root that compiled the assembly rather than the snapshot owner's: the PDB spells a + // source inside the compiling project as a path relative to that project's root (behind a + // leading "./"), which the lookup matches as the end of the url. A Multiplayer Play Mode + // Virtual Player reads the main project's assemblies from a root under the main project's + // Library, so a package folder of the main project lies outside the player's root, and + // relativizing against the player's root would hand the lookup an absolute path that no + // document ends with. For an ordinary project the two roots are the same. + string compiledProjectRoot = CompiledAssemblyLayout.Resolve(projectRoot).MainProjectRoot; + string pdbLookupPath = ScriptPackageRoots.ToPhysicalPath(compiledProjectRoot, slashNormalizedRelativePath); if (!documentIndex.TryFindDocument( targetDllPath, pdbPath, From 63881ae36e4a68c304ee19cf49629027cd744dd7 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 16:07:33 +0900 Subject: [PATCH 15/29] perf: Hot reload records each source's length and write time with its snapshot, so the first run after a domain reload does not read unchanged siblings (#3245) --- .../references/scope-and-limits.md | 10 +- .../references/scope-and-limits.md | 10 +- ...ReloadChangedSiblingSourceDetectorTests.cs | 215 ++++++++++++++++++ .../HotReloadSourceSnapshotterTests.cs | 54 ++++- .../HotReloadSourceStampManifestTests.cs | 204 +++++++++++++++++ .../HotReloadSourceStampManifestTests.cs.meta | 11 + .../HotReloadChangedSiblingSourceDetector.cs | 53 ++++- .../HotReload/Shared/HotReloadConstants.cs | 5 + .../Shared/HotReloadSourceSnapshotter.cs | 23 +- .../Shared/HotReloadSourceStampManifest.cs | 172 ++++++++++++++ .../HotReloadSourceStampManifest.cs.meta | 11 + .../Skill/references/scope-and-limits.md | 10 +- docs/hot-reload.md | 4 +- 13 files changed, 754 insertions(+), 28 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSourceStampManifestTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSourceStampManifestTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceStampManifest.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceStampManifest.cs.meta diff --git a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md index 715d2611ac..e42aaee8db 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -348,10 +348,12 @@ line each. A file with a declaration that hot reload refused to introduce (its `Warnings` line says the type requires a compile) gets no such line, because that compile also establishes its baseline. This also holds for an existing file that gains such a declaration, for example a nested type or a delegate. -Within one Editor session, a source compared with its baseline once is compared again -only when its length or last write time changes; a rewrite that keeps both (for example -a copy that preserves timestamps) is noticed only after the next domain reload, such as -`uloop compile`. This applies wherever a source is compared with its baseline: finding +A source whose length and last write time still equal those recorded when its baseline +was captured is taken to match it without being read, in later Editor sessions as well; a +rewrite that keeps both (for example a copy that preserves timestamps) is noticed only once +its assembly compiles again, which captures the baseline anew. A baseline captured by an +earlier package version carries no such record and is compared by bytes once per Editor +session. This applies wherever a source is compared with its baseline: finding drifted siblings, deciding which live patches to re-apply after a skip, and selecting files when `--files` is omitted. diff --git a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md index 715d2611ac..e42aaee8db 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -348,10 +348,12 @@ line each. A file with a declaration that hot reload refused to introduce (its `Warnings` line says the type requires a compile) gets no such line, because that compile also establishes its baseline. This also holds for an existing file that gains such a declaration, for example a nested type or a delegate. -Within one Editor session, a source compared with its baseline once is compared again -only when its length or last write time changes; a rewrite that keeps both (for example -a copy that preserves timestamps) is noticed only after the next domain reload, such as -`uloop compile`. This applies wherever a source is compared with its baseline: finding +A source whose length and last write time still equal those recorded when its baseline +was captured is taken to match it without being read, in later Editor sessions as well; a +rewrite that keeps both (for example a copy that preserves timestamps) is noticed only once +its assembly compiles again, which captures the baseline anew. A baseline captured by an +earlier package version carries no such record and is compared by bytes once per Editor +session. This applies wherever a source is compared with its baseline: finding drifted siblings, deciding which live patches to re-apply after a skip, and selecting files when `--files` is omitted. diff --git a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs index d268383291..c80c6ccb79 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs @@ -645,6 +645,189 @@ public void DetectFromSnapshotDirectory_SecondScanWithSameStamp_KeepsTheDiffersV } } + /// + /// What: a sibling whose length and write time equal the stamp the capture recorded is + /// taken to match its snapshot without comparing bytes, even though the bytes differ. + /// + [Test] + public void DetectFromSnapshotDirectory_WhenTheManifestStampEqualsTheSource_TrustsItWithoutComparingBytes() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-BBBB"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + WriteStampManifest(projectRoot, "Asm-mvid", StampLine(siblingRelative, 12, FirstWriteTimeUtc)); + + Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a recorded stamp with another length is not trusted, so the bytes are compared + /// and a differing sibling is reported. + /// + [Test] + public void DetectFromSnapshotDirectory_WhenTheManifestLengthDiffers_ComparesBytes() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-BBBB"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + WriteStampManifest(projectRoot, "Asm-mvid", StampLine(siblingRelative, 13, FirstWriteTimeUtc)); + + Assert.That( + ScanSibling(projectRoot, "Asm-mvid", siblingRelative), + Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) })); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a recorded stamp with another write time is not trusted, so the bytes are compared + /// and a differing sibling is reported. + /// + [Test] + public void DetectFromSnapshotDirectory_WhenTheManifestWriteTimeDiffers_ComparesBytes() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-BBBB"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + WriteStampManifest(projectRoot, "Asm-mvid", StampLine(siblingRelative, 12, SecondWriteTimeUtc)); + + Assert.That( + ScanSibling(projectRoot, "Asm-mvid", siblingRelative), + Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) })); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a sibling whose stamp differs from the recorded one but whose bytes still equal the + /// snapshot is left out, because the byte comparison decides. + /// + [Test] + public void DetectFromSnapshotDirectory_WhenTheManifestStampDiffersButTheBytesAreEqual_OmitsTheSibling() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + WriteStampManifest(projectRoot, "Asm-mvid", StampLine(siblingRelative, 12, SecondWriteTimeUtc)); + + Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a manifest with one line that cannot be parsed is ignored as a whole, so even a + /// matching stamp on another line is not trusted and the differing sibling is reported. + /// + [Test] + public void DetectFromSnapshotDirectory_WhenAManifestLineIsMalformed_IgnoresTheWholeManifest() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-BBBB"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + WriteStampManifest( + projectRoot, + "Asm-mvid", + StampLine(siblingRelative, 12, FirstWriteTimeUtc), + "broken\t12"); + + Assert.That( + ScanSibling(projectRoot, "Asm-mvid", siblingRelative), + Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) })); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: the re-apply check after a skip also takes a source whose stamp equals the recorded + /// one to match its snapshot without comparing bytes. + /// + [Test] + public void SourceMatchesSnapshotDirectory_WhenTheManifestStampEqualsTheSource_IsTrueWithoutComparingBytes() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-BBBB"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + WriteStampManifest(projectRoot, "Asm-mvid", StampLine(siblingRelative, 12, FirstWriteTimeUtc)); + + bool matches = HotReloadChangedSiblingSourceDetector.SourceMatchesSnapshotDirectory( + projectRoot, + "Asm-mvid", + siblingRelative, + AbsoluteProjectPath(projectRoot, siblingRelative)); + + Assert.That(matches, Is.True); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: the default file selection leaves out a source whose stamp equals the recorded one, + /// without comparing bytes. + /// + [Test] + public void DetectAllChangedFromSnapshotDirectory_WhenTheManifestStampEqualsTheSource_LeavesTheSourceOut() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-BBBB"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + WriteStampManifest(projectRoot, "Asm-mvid", StampLine(siblingRelative, 12, FirstWriteTimeUtc)); + + HotReloadChangedSourceScanResult result = + HotReloadChangedSiblingSourceDetector.DetectAllChangedFromSnapshotDirectory( + projectRoot, + "Asm-mvid", + new[] { siblingRelative }); + + Assert.That(result.ChangedProjectRelativePaths, Is.Empty); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + /// /// What: sibling-derived warnings are ordinal-deduped among themselves and skipped /// when the own-file list already contains the exact string, without collapsing @@ -732,6 +915,38 @@ private static void WriteSnapshot( new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(contents)); } + // Plants a stamp manifest as the capture would write it: the header line, then one + // "\t\t" line per entry, "\n" after every line. + private static void WriteStampManifest( + string projectRoot, + string assemblySnapshotDirectoryName, + params string[] lines) + { + string snapshotDirectory = Path.Combine( + projectRoot, + HotReloadConstants.SourceSnapshotRelativeDirectory, + assemblySnapshotDirectoryName); + Directory.CreateDirectory(snapshotDirectory); + StringBuilder text = new StringBuilder(); + text.Append(HotReloadConstants.SourceStampManifestHeader).Append('\n'); + foreach (string line in lines) + { + text.Append(line).Append('\n'); + } + + File.WriteAllText( + Path.Combine(snapshotDirectory, HotReloadConstants.SourceStampManifestFileName), + text.ToString(), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + private static string StampLine(string projectRelativePath, long length, DateTime lastWriteTimeUtc) + { + return HotReloadSourceSnapshotter.HashProjectRelativePath(projectRelativePath.Replace('\\', '/')) + + ".cs\t" + length.ToString(CultureInfo.InvariantCulture) + + "\t" + lastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture); + } + private static string AbsoluteProjectPath(string projectRoot, string projectRelativePath) { return Path.GetFullPath( diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs index 43e9e66ee6..9041e441d6 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.IO; using System.Linq; @@ -103,11 +104,43 @@ public void CaptureAssemblySourcesAtomically_ExistingAndMissingSources_CopiesExi Assert.That(File.Exists(firstSnapshot), Is.True); Assert.That(File.Exists(secondSnapshot), Is.True); Assert.That(Directory.Exists(snapshotDirectory + ".tmp"), Is.False); - Assert.That(Directory.GetFiles(snapshotDirectory).Length, Is.EqualTo(2)); + Assert.That(Directory.GetFiles(snapshotDirectory).Length, Is.EqualTo(3)); Assert.That(File.ReadAllBytes(firstSnapshot), Is.EqualTo(firstBytes)); Assert.That(File.ReadAllBytes(secondSnapshot), Is.EqualTo(secondBytes)); } + /// + /// Verifies that the capture writes one manifest line with the length and last write time of each copied source, in the order of the source list, and no line for a listed source that does not exist. + /// + [Test] + public void CaptureAssemblySourcesAtomically_RecordsTheLengthAndWriteTimeOfEachCopiedSource() + { + string sourceDirectory = Path.Combine(_tempRoot, "Sources"); + Directory.CreateDirectory(sourceDirectory); + DateTime firstWriteTimeUtc = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + DateTime secondWriteTimeUtc = firstWriteTimeUtc.AddDays(1); + WriteSourceAt(Path.Combine(sourceDirectory, "First.cs"), new byte[] { 1, 2, 3, 4, 5, 6, 7 }, firstWriteTimeUtc); + WriteSourceAt(Path.Combine(sourceDirectory, "Second.cs"), new byte[] { 1, 2 }, secondWriteTimeUtc); + string snapshotDirectory = Path.Combine(_tempRoot, "Fixture-" + Guid.NewGuid().ToString("N")); + string[] sourceFiles = { "Sources/First.cs", "Sources\\Second.cs", "Sources/Missing.cs" }; + + HotReloadSourceSnapshotter.CaptureAssemblySourcesAtomically( + _tempRoot, + snapshotDirectory, + sourceFiles, + "Fixture"); + + string expected = HotReloadConstants.SourceStampManifestHeader + "\n" + + HotReloadSourceSnapshotter.HashProjectRelativePath("Sources/First.cs") + ".cs\t7\t" + + firstWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture) + "\n" + + HotReloadSourceSnapshotter.HashProjectRelativePath("Sources/Second.cs") + ".cs\t2\t" + + secondWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture) + "\n"; + Assert.That( + File.ReadAllText(Path.Combine(snapshotDirectory, HotReloadConstants.SourceStampManifestFileName)), + Is.EqualTo(expected)); + Assert.That(Directory.Exists(snapshotDirectory + ".tmp"), Is.False); + } + /// /// Verifies that a leftover temporary directory from an interrupted capture is discarded, so its stale files never reach the published snapshot. /// @@ -129,10 +162,27 @@ public void CaptureAssemblySourcesAtomically_WhenTemporaryDirectoryIsLeftOver_Pu "Fixture"); string[] publishedNames = Directory.GetFiles(snapshotDirectory).Select(Path.GetFileName).ToArray(); + // Why EquivalentTo: the order GetFiles lists files in depends on the file system. Assert.That( publishedNames, - Is.EqualTo(new[] { HotReloadSourceSnapshotter.HashProjectRelativePath("Sources/Fresh.cs") + ".cs" })); + Is.EquivalentTo(new[] + { + HotReloadSourceSnapshotter.HashProjectRelativePath("Sources/Fresh.cs") + ".cs", + HotReloadConstants.SourceStampManifestFileName, + })); Assert.That(Directory.Exists(leftoverDirectory), Is.False); } + + // Why a write time the test chooses: SetLastWriteTimeUtc stores microseconds while + // LastWriteTimeUtc reports 100 ns ticks, so only a chosen value can be put back exactly. + private static void WriteSourceAt(string path, byte[] bytes, DateTime lastWriteTimeUtc) + { + File.WriteAllBytes(path, bytes); + File.SetLastWriteTimeUtc(path, lastWriteTimeUtc); + Assert.That( + new FileInfo(path).LastWriteTimeUtc, + Is.EqualTo(lastWriteTimeUtc), + "The write time must be settable exactly, or the stamp the capture records is not the one the test chose."); + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceStampManifestTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceStampManifestTests.cs new file mode 100644 index 0000000000..2ee96d3b58 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceStampManifestTests.cs @@ -0,0 +1,204 @@ +using System; +using System.IO; +using System.Text; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// EditMode coverage for the contract of : what it + /// writes reads back, and a file that cannot be parsed in full answers no stamp at all. + /// + public sealed class HotReloadSourceStampManifestTests + { + private const long FirstTicks = 637134336000000000; + private const long SecondTicks = 637135200000000000; + + private string _tempRoot; + + [SetUp] + public void SetUp() + { + _tempRoot = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempRoot); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempRoot)) + { + Directory.Delete(_tempRoot, true); + } + } + + /// + /// Verifies that a snapshot directory without a manifest answers no stamp. + /// + [Test] + public void Load_WhenTheFileIsMissing_IsEmpty() + { + HotReloadSourceStampManifest manifest = HotReloadSourceStampManifest.Load(_tempRoot); + + Assert.That(manifest.Count, Is.EqualTo(0)); + Assert.That(manifest.TryGetStamp("x.cs", out long _, out long _), Is.False); + } + + /// + /// Verifies that every line Write wrote is read back with its length and write time. + /// + [Test] + public void Load_RoundTripsWhatWriteWrote() + { + HotReloadSourceStampManifest.Write( + _tempRoot, + new[] + { + HotReloadSourceStampManifest.FormatLine("a.cs", 7, FirstTicks), + HotReloadSourceStampManifest.FormatLine("b.cs", 2, SecondTicks), + }); + + HotReloadSourceStampManifest manifest = HotReloadSourceStampManifest.Load(_tempRoot); + + Assert.That(manifest.Count, Is.EqualTo(2)); + Assert.That(manifest.TryGetStamp("a.cs", out long firstLength, out long firstTicks), Is.True); + Assert.That(firstLength, Is.EqualTo(7)); + Assert.That(firstTicks, Is.EqualTo(FirstTicks)); + Assert.That(manifest.TryGetStamp("b.cs", out long secondLength, out long secondTicks), Is.True); + Assert.That(secondLength, Is.EqualTo(2)); + Assert.That(secondTicks, Is.EqualTo(SecondTicks)); + } + + /// + /// Verifies that a manifest of another format version is ignored, even for a line that + /// would parse. + /// + [Test] + public void Load_WhenTheHeaderDiffers_IsEmpty() + { + WriteRawManifest("uloop-source-stamps 2\n" + ValidLine() + "\n"); + + AssertIgnoredAsAWhole(); + } + + /// + /// Verifies that a line with two fields makes the whole manifest ignored, including the + /// valid line before it. + /// + [Test] + public void Load_WhenALineHasTwoFields_IsEmpty() + { + WriteRawManifest(Header() + ValidLine() + "\n" + "a.cs\t7\n"); + + AssertIgnoredAsAWhole(); + } + + /// + /// Verifies that a length that is not an integer makes the whole manifest ignored. + /// + [Test] + public void Load_WhenALengthIsNotAnInteger_IsEmpty() + { + WriteRawManifest(Header() + ValidLine() + "\n" + "a.cs\t7x\t1\n"); + + AssertIgnoredAsAWhole(); + } + + /// + /// Verifies that a signed write time makes the whole manifest ignored rather than read as + /// a stamp no source can have. + /// + [Test] + public void Load_WhenAWriteTimeIsNegative_IsEmpty() + { + WriteRawManifest(Header() + ValidLine() + "\n" + "a.cs\t7\t-1\n"); + + AssertIgnoredAsAWhole(); + } + + /// + /// Verifies that a manifest whose last line has no line break, as a file cut off while + /// being written would, is ignored as a whole. + /// + [Test] + public void Load_WhenTheTextDoesNotEndWithALineBreak_IsEmpty() + { + WriteRawManifest(Header() + ValidLine() + "\n" + "a.cs\t7\t1"); + + AssertIgnoredAsAWhole(); + } + + /// + /// Verifies that a file name listed twice makes the whole manifest ignored, since neither + /// line can be told to be the right one. + /// + [Test] + public void Load_WhenAFileNameRepeats_IsEmpty() + { + WriteRawManifest(Header() + ValidLine() + "\n" + ValidLine() + "\n"); + + AssertIgnoredAsAWhole(); + } + + /// + /// Verifies that a manifest with CRLF line endings, as a Windows checkout tool could + /// leave it, still reads. + /// + [Test] + public void Load_AcceptsCarriageReturnLineEndings() + { + WriteRawManifest(HotReloadConstants.SourceStampManifestHeader + "\r\n" + ValidLine() + "\r\n"); + + HotReloadSourceStampManifest manifest = HotReloadSourceStampManifest.Load(_tempRoot); + + Assert.That(manifest.Count, Is.EqualTo(1)); + Assert.That(manifest.TryGetStamp("b.cs", out long length, out long ticks), Is.True); + Assert.That(length, Is.EqualTo(2)); + Assert.That(ticks, Is.EqualTo(SecondTicks)); + } + + /// + /// Verifies that a file name the manifest has no line for answers no stamp. + /// + [Test] + public void TryGetStamp_UnknownFileName_IsFalse() + { + WriteRawManifest(Header() + ValidLine() + "\n"); + + HotReloadSourceStampManifest manifest = HotReloadSourceStampManifest.Load(_tempRoot); + + Assert.That(manifest.TryGetStamp("a.cs", out long _, out long _), Is.False); + } + + private static string Header() + { + return HotReloadConstants.SourceStampManifestHeader + "\n"; + } + + // Why a valid line in front of every malformed one: a loader that skipped the bad line + // instead of dropping the file would still answer this one, so the test tells them apart. + private static string ValidLine() + { + return HotReloadSourceStampManifest.FormatLine("b.cs", 2, SecondTicks); + } + + private void WriteRawManifest(string text) + { + File.WriteAllText( + Path.Combine(_tempRoot, HotReloadConstants.SourceStampManifestFileName), + text, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + private void AssertIgnoredAsAWhole() + { + HotReloadSourceStampManifest manifest = HotReloadSourceStampManifest.Load(_tempRoot); + + Assert.That(manifest.Count, Is.EqualTo(0)); + Assert.That(manifest.TryGetStamp("b.cs", out long _, out long _), Is.False); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceStampManifestTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadSourceStampManifestTests.cs.meta new file mode 100644 index 0000000000..49d1429967 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceStampManifestTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0d4d41cb2b5da44c1a2b1096f8b1039e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs index 6e008adee5..803af9141f 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs @@ -183,17 +183,22 @@ internal static bool SourceMatchesSnapshotDirectory( return false; } - string snapshotPath = Path.Combine( + string snapshotDirectory = Path.Combine( projectRoot, HotReloadConstants.SourceSnapshotRelativeDirectory, - assemblySnapshotDirectoryName, + assemblySnapshotDirectoryName); + string snapshotPath = Path.Combine( + snapshotDirectory, HotReloadSourceSnapshotter.HashProjectRelativePath(projectRelativePath.Replace('\\', '/')) + ".cs"); if (!File.Exists(snapshotPath)) { return false; } - return MatchesSnapshot(source, snapshotPath); + return MatchesSnapshot( + source, + snapshotPath, + new Lazy(() => HotReloadSourceStampManifest.Load(snapshotDirectory))); } private static HotReloadChangedSourceScanResult DetectChangedFromSnapshotDirectory( @@ -211,6 +216,10 @@ private static HotReloadChangedSourceScanResult DetectChangedFromSnapshotDirecto return new HotReloadChangedSourceScanResult(false, new List(), string.Empty); } + // Why lazily: a run whose verdicts all come from the per-domain memo never opens the + // manifest, and a run that needs it reads it once for every source. + Lazy manifest = new Lazy( + () => HotReloadSourceStampManifest.Load(snapshotDirectory)); List changedProjectRelativePaths = new List(); if (sourceFiles == null || sourceFiles.Length == 0) { @@ -223,7 +232,8 @@ private static HotReloadChangedSourceScanResult DetectChangedFromSnapshotDirecto projectRoot, snapshotDirectory, sourceFiles[index], - excludedProjectRelativePaths); + excludedProjectRelativePaths, + manifest); if (changedPath != null) { changedProjectRelativePaths.Add(changedPath); @@ -237,7 +247,8 @@ private static string TryResolveChangedProjectRelativePath( string projectRoot, string snapshotDirectory, string projectRelativeSourcePath, - IReadOnlyCollection excludedProjectRelativePaths) + IReadOnlyCollection excludedProjectRelativePaths, + Lazy manifest) { if (string.IsNullOrEmpty(projectRelativeSourcePath)) { @@ -265,7 +276,7 @@ private static string TryResolveChangedProjectRelativePath( return null; } - if (MatchesSnapshot(source, snapshotPath)) + if (MatchesSnapshot(source, snapshotPath, manifest)) { return null; } @@ -274,9 +285,13 @@ private static string TryResolveChangedProjectRelativePath( return normalizedRelativePath; } - // Compares an existing source with an existing snapshot file, reusing the last verdict while - // the source keeps its length and write time. - private static bool MatchesSnapshot(FileInfo source, string snapshotPath) + // Compares an existing source with an existing snapshot file: reuses the last verdict while + // the source keeps its length and write time, then trusts the stamp the capture recorded + // for the same length and write time, and only then reads both files. + private static bool MatchesSnapshot( + FileInfo source, + string snapshotPath, + Lazy manifest) { long lastWriteTimeUtcTicks = source.LastWriteTimeUtc.Ticks; if (_verdicts.TryGetVerdict( @@ -289,13 +304,31 @@ private static bool MatchesSnapshot(FileInfo source, string snapshotPath) return matches; } - bool equal = BytesEqual(File.ReadAllBytes(source.FullName), File.ReadAllBytes(snapshotPath)); + bool equal = HasRecordedStamp(manifest.Value, snapshotPath, source.Length, lastWriteTimeUtcTicks) + || BytesEqual(File.ReadAllBytes(source.FullName), File.ReadAllBytes(snapshotPath)); // Why the stamp taken before reading: a write during the read moves the stamp on, so the // next scan reads the file again instead of trusting this verdict. _verdicts.Record(snapshotPath, source.FullName, source.Length, lastWriteTimeUtcTicks, equal); return equal; } + // Why a stamp stands for the bytes: the capture recorded it from the same stat the copy was + // taken under, so a source that still shows it holds the copied bytes (the same proof the + // per-domain memo relies on), and the two reads can be skipped. + private static bool HasRecordedStamp( + HotReloadSourceStampManifest manifest, + string snapshotPath, + long length, + long lastWriteTimeUtcTicks) + { + return manifest.TryGetStamp( + Path.GetFileName(snapshotPath), + out long recordedLength, + out long recordedTicks) + && recordedLength == length + && recordedTicks == lastWriteTimeUtcTicks; + } + private static HotReloadChangedSourceScanResult LimitChangedSources( List changedProjectRelativePaths) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 9400ebc9b0..1e03965648 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -49,6 +49,11 @@ internal static class HotReloadConstants // affect the on-disk layout. Adoption is decided at use time by PDB document checksum. public const string SourceSnapshotRelativeDirectory = "Library/UloopHotReload/SourceSnapshot"; + // The file inside a snapshot directory that records each copied source's length and last + // write time (UTC ticks), so a later domain tells an unchanged source by its stat alone. + public const string SourceStampManifestFileName = "source-stamps.txt"; + public const string SourceStampManifestHeader = "uloop-source-stamps 1"; + // Per-assembly lists of the documents a PDB's sequence points refer to, keyed by the dll's // file name and stamped with the dll's and the PDB's length, write time and MVID. Lets the // first hot reload run after a domain reload skip the walk over every sequence point. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index 6064b1ff9a..649ea487bc 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -218,15 +218,17 @@ internal static void CaptureAssemblySourcesAtomically( Directory.CreateDirectory(temporaryDirectory); int skippedSourceCount = 0; string firstSkippedSourcePath = null; + List manifestLines = new List(sourceFiles.Length); foreach (string projectRelativeSourcePath in sourceFiles) { - if (!CopySourceFileByteExact(projectRoot, temporaryDirectory, projectRelativeSourcePath)) + if (!CopySourceFileByteExact(projectRoot, temporaryDirectory, projectRelativeSourcePath, manifestLines)) { skippedSourceCount++; firstSkippedSourcePath ??= projectRelativeSourcePath; } } + HotReloadSourceStampManifest.Write(temporaryDirectory, manifestLines); Directory.Move(temporaryDirectory, assemblySnapshotDirectory); if (skippedSourceCount > 0) { @@ -239,22 +241,37 @@ internal static void CaptureAssemblySourcesAtomically( private static bool CopySourceFileByteExact( string projectRoot, string assemblySnapshotDirectory, - string projectRelativeSourcePath) + string projectRelativeSourcePath, + List manifestLines) { string normalizedRelativePath = projectRelativeSourcePath.Replace('\\', '/'); string absoluteSourcePath = Path.Combine(projectRoot, normalizedRelativePath.Replace('/', Path.DirectorySeparatorChar)); string fileSystemSourcePath = HotReloadFileSystemPath.GetFileSystemPath(absoluteSourcePath); - if (!File.Exists(fileSystemSourcePath)) + FileInfo sourceBefore = new FileInfo(fileSystemSourcePath); + if (!sourceBefore.Exists) { return true; } + long length = sourceBefore.Length; + long lastWriteTimeUtcTicks = sourceBefore.LastWriteTimeUtc.Ticks; try { string snapshotFileName = HashProjectRelativePath(normalizedRelativePath) + ".cs"; string destinationPath = Path.Combine(assemblySnapshotDirectory, snapshotFileName); byte[] bytes = File.ReadAllBytes(fileSystemSourcePath); File.WriteAllBytes(destinationPath, bytes); + // Why the stamp is taken before the read and checked again after it: a write during the + // read moves the stamp on, and a stamp recorded then could vouch for bytes the copy does + // not hold. Such a source gets no line and is compared by bytes until the next capture. + FileInfo sourceAfter = new FileInfo(fileSystemSourcePath); + if (sourceAfter.Exists + && sourceAfter.Length == length + && sourceAfter.LastWriteTimeUtc.Ticks == lastWriteTimeUtcTicks) + { + manifestLines.Add(HotReloadSourceStampManifest.FormatLine(snapshotFileName, length, lastWriteTimeUtcTicks)); + } + return true; } catch (Exception ex) when (IsSkippableSourceReadException(ex)) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceStampManifest.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceStampManifest.cs new file mode 100644 index 0000000000..3c747e6be8 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceStampManifest.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The length and last write time each source had when its snapshot copy was taken, kept in + /// the snapshot directory so a source that still has both is known to equal its copy without + /// reading either file. A manifest that cannot be parsed in full answers no stamp at all. + /// + internal sealed class HotReloadSourceStampManifest + { + internal static readonly HotReloadSourceStampManifest Empty = + new HotReloadSourceStampManifest(new Dictionary(StringComparer.Ordinal)); + + private readonly Dictionary _stamps; + + private HotReloadSourceStampManifest(Dictionary stamps) + { + _stamps = stamps; + } + + internal int Count => _stamps.Count; + + /// + /// Reads the manifest of a snapshot directory. A missing, truncated or malformed file + /// answers as a whole. + /// + /// + /// Why the whole file: a line that cannot be parsed leaves no way to tell which other + /// lines are right, and a stamp trusted by mistake would skip the byte comparison. + /// + internal static HotReloadSourceStampManifest Load(string snapshotDirectory) + { + Debug.Assert(!string.IsNullOrEmpty(snapshotDirectory), "snapshotDirectory must not be null or empty."); + + string path = Path.Combine(snapshotDirectory, HotReloadConstants.SourceStampManifestFileName); + if (!File.Exists(path)) + { + return Empty; + } + + string[] lines = File.ReadAllText(path, Encoding.UTF8).Split('\n'); + for (int index = 0; index < lines.Length; index++) + { + lines[index] = lines[index].TrimEnd('\r'); + } + + // The header, every stamp line, and the empty string after the last "\n". + if (lines.Length < 2 + || !string.Equals(lines[0], HotReloadConstants.SourceStampManifestHeader, StringComparison.Ordinal) + || lines[lines.Length - 1].Length != 0) + { + return Empty; + } + + Dictionary stamps = new Dictionary(lines.Length - 2, StringComparer.Ordinal); + for (int index = 1; index < lines.Length - 1; index++) + { + if (!TryParseLine(lines[index], out string fileName, out Stamp stamp) + || stamps.ContainsKey(fileName)) + { + return Empty; + } + + stamps.Add(fileName, stamp); + } + + return new HotReloadSourceStampManifest(stamps); + } + + /// + /// Returns the stamp recorded for a snapshot file name, or false when the manifest has none. + /// + internal bool TryGetStamp(string snapshotFileName, out long length, out long lastWriteTimeUtcTicks) + { + Debug.Assert(!string.IsNullOrEmpty(snapshotFileName), "snapshotFileName must not be null or empty."); + + if (_stamps.TryGetValue(snapshotFileName, out Stamp stamp)) + { + length = stamp.Length; + lastWriteTimeUtcTicks = stamp.LastWriteTimeUtcTicks; + return true; + } + + length = 0; + lastWriteTimeUtcTicks = 0; + return false; + } + + /// + /// Formats one manifest line. The file name is a hex hash plus ".cs", so it never holds a + /// TAB or a line break. + /// + internal static string FormatLine(string snapshotFileName, long length, long lastWriteTimeUtcTicks) + { + Debug.Assert(!string.IsNullOrEmpty(snapshotFileName), "snapshotFileName must not be null or empty."); + Debug.Assert(length >= 0, "length must not be negative."); + Debug.Assert(lastWriteTimeUtcTicks >= 0, "lastWriteTimeUtcTicks must not be negative."); + + return snapshotFileName + + "\t" + length.ToString(CultureInfo.InvariantCulture) + + "\t" + lastWriteTimeUtcTicks.ToString(CultureInfo.InvariantCulture); + } + + /// + /// Writes the manifest into a snapshot directory that is not published yet (the capture's + /// temporary directory); the directory move publishes it together with the copies. + /// + internal static void Write(string snapshotDirectory, IReadOnlyList lines) + { + Debug.Assert(Directory.Exists(snapshotDirectory), "snapshotDirectory must exist."); + Debug.Assert(lines != null, "lines must not be null."); + + StringBuilder text = new StringBuilder(); + text.Append(HotReloadConstants.SourceStampManifestHeader).Append('\n'); + for (int index = 0; index < lines.Count; index++) + { + text.Append(lines[index]).Append('\n'); + } + + File.WriteAllText( + Path.Combine(snapshotDirectory, HotReloadConstants.SourceStampManifestFileName), + text.ToString(), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + // Why NumberStyles.None: it rejects a sign, white space and separators, so "-1" or " 12" + // makes the line malformed rather than a stamp no source can have. + private static bool TryParseLine(string line, out string fileName, out Stamp stamp) + { + fileName = null; + stamp = default; + string[] fields = line.Split('\t'); + if (fields.Length != 3 || fields[0].Length == 0) + { + return false; + } + + if (!long.TryParse(fields[1], NumberStyles.None, CultureInfo.InvariantCulture, out long length)) + { + return false; + } + + if (!long.TryParse(fields[2], NumberStyles.None, CultureInfo.InvariantCulture, out long ticks)) + { + return false; + } + + fileName = fields[0]; + stamp = new Stamp(length, ticks); + return true; + } + + private readonly struct Stamp + { + internal readonly long Length; + internal readonly long LastWriteTimeUtcTicks; + + internal Stamp(long length, long lastWriteTimeUtcTicks) + { + Length = length; + LastWriteTimeUtcTicks = lastWriteTimeUtcTicks; + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceStampManifest.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceStampManifest.cs.meta new file mode 100644 index 0000000000..0e2fc2ba11 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceStampManifest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 26a698cf3ad4843919ab48e676b21352 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md index 715d2611ac..e42aaee8db 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md @@ -348,10 +348,12 @@ line each. A file with a declaration that hot reload refused to introduce (its `Warnings` line says the type requires a compile) gets no such line, because that compile also establishes its baseline. This also holds for an existing file that gains such a declaration, for example a nested type or a delegate. -Within one Editor session, a source compared with its baseline once is compared again -only when its length or last write time changes; a rewrite that keeps both (for example -a copy that preserves timestamps) is noticed only after the next domain reload, such as -`uloop compile`. This applies wherever a source is compared with its baseline: finding +A source whose length and last write time still equal those recorded when its baseline +was captured is taken to match it without being read, in later Editor sessions as well; a +rewrite that keeps both (for example a copy that preserves timestamps) is noticed only once +its assembly compiles again, which captures the baseline anew. A baseline captured by an +earlier package version carries no such record and is compared by bytes once per Editor +session. This applies wherever a source is compared with its baseline: finding drifted siblings, deciding which live patches to re-apply after a skip, and selecting files when `--files` is omitted. diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 6feca66a6b..8722ab823d 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -73,7 +73,9 @@ Harmony ID: `io.github.hatayama.uloop.hot-reload` (distinct from the pause point Caches: `Library/UloopHotReload/PublicizedRefs/fmt2/-.dll`, `Library/UloopHotReload/PublicizedExternalRefs/fmt1/-.dll`, `Library/UloopHotReload/Worker//`, -`Library/UloopHotReload/SourceSnapshot/-/`, and +`Library/UloopHotReload/SourceSnapshot/-/` (with a `source-stamps.txt` that +records each copied source's length and write time, so a run after a domain reload tells an +unchanged sibling by a stat instead of reading it), and `Library/UloopHotReload/PdbDocuments/fmt1/.txt` (the documents the PDB's sequence points refer to, stamped with the dll's and the PDB's length and write time and the MVID, so the first run after a domain reload does not walk the PDB again while they still match). From 62b1236c0b829fde0aba1364c86e603c6a6bef26 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 17:52:18 +0900 Subject: [PATCH 16/29] perf: Hot reload keeps its compilation assembly list across the import that follows a compile's domain reload (#3246) --- .../HotReloadCompilationAssembliesTests.cs | 63 ++++--- ...mpilationAssemblyListPostprocessorTests.cs | 170 ++++++++++++++++++ ...tionAssemblyListPostprocessorTests.cs.meta | 11 ++ .../Shared/HotReloadCompilationAssemblies.cs | 15 +- ...oadCompilationAssemblyListPostprocessor.cs | 72 ++++++++ ...mpilationAssemblyListPostprocessor.cs.meta | 11 ++ 6 files changed, 312 insertions(+), 30 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListPostprocessorTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListPostprocessorTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListPostprocessor.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListPostprocessor.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs index 753ae17ec4..ad07952154 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs @@ -22,25 +22,27 @@ public sealed class HotReloadCompilationAssembliesTests // Why string names: the handlers are private to the production class; a rename fails these tests // instead of silently passing them. private const string CompilationStartedHandlerName = "OnCompilationStarted"; - private const string ProjectChangedHandlerName = "OnProjectChanged"; /// - /// Verifies that the static constructor subscribed the memo's invalidation to compile start and - /// project change. + /// Verifies that the static constructor subscribed the memo's invalidation to compile start but not + /// to project change, because Unity raises project change for the import that triggered a compile + /// after the new domain's startup capture has already filled the memo. /// [Test] - public void StaticConstructor_SubscribesInvalidationToCompilationStartedAndProjectChanged() + public void StaticConstructor_SubscribesInvalidationToCompilationStartedOnly() { HotReloadCompilationAssemblies.Current(); Assert.That( - StaticEventHasHandler(typeof(CompilationPipeline), CompilationStartedHandlerName), + StaticEventHasHandler( + typeof(CompilationPipeline), + listener => IsNamedProductionHandler(listener, CompilationStartedHandlerName)), Is.True, "OnCompilationStarted must be subscribed on CompilationPipeline."); Assert.That( - StaticEventHasHandler(typeof(EditorApplication), ProjectChangedHandlerName), - Is.True, - "OnProjectChanged must be subscribed on EditorApplication."); + StaticEventHasHandler(typeof(EditorApplication), IsAnyProductionHandler), + Is.False, + "Nothing of HotReloadCompilationAssemblies or its cache may be subscribed on EditorApplication: projectChanged fires after the startup capture of every compile."); } /// @@ -58,14 +60,14 @@ public void Current_CalledTwice_ReturnsSameInstance() // Why: CompilationPipeline stores handlers on Delegate fields, but EditorApplication.projectChanged // lives on EventWithPerformanceTracker. A Delegate-only scan cannot see it. - private static bool StaticEventHasHandler(Type eventOwner, string handlerName) + private static bool StaticEventHasHandler(Type eventOwner, Func isMatch) { FieldInfo[] fields = eventOwner.GetFields( BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); for (int index = 0; index < fields.Length; index++) { object value = fields[index].GetValue(null); - if (ContainsProductionHandler(value, handlerName)) + if (ContainsHandler(value, isMatch)) { return true; } @@ -74,7 +76,7 @@ private static bool StaticEventHasHandler(Type eventOwner, string handlerName) return false; } - private static bool ContainsProductionHandler(object source, string handlerName) + private static bool ContainsHandler(object source, Func isMatch) { if (source == null) { @@ -84,18 +86,18 @@ private static bool ContainsProductionHandler(object source, string handlerName) Delegate current = source as Delegate; if (current != null) { - return InvocationListContains(current, handlerName); + return InvocationListContains(current, isMatch); } - return EnumeratorContainsHandler(source, handlerName); + return EnumeratorContainsHandler(source, isMatch); } - private static bool InvocationListContains(Delegate current, string handlerName) + private static bool InvocationListContains(Delegate current, Func isMatch) { Delegate[] listeners = current.GetInvocationList(); for (int listenerIndex = 0; listenerIndex < listeners.Length; listenerIndex++) { - if (IsProductionHandler(listeners[listenerIndex], handlerName)) + if (isMatch(listeners[listenerIndex])) { return true; } @@ -104,7 +106,7 @@ private static bool InvocationListContains(Delegate current, string handlerName) return false; } - private static bool EnumeratorContainsHandler(object source, string handlerName) + private static bool EnumeratorContainsHandler(object source, Func isMatch) { string typeName = source.GetType().Name; if (typeName.IndexOf("EventWithPerformanceTracker", StringComparison.Ordinal) < 0) @@ -136,7 +138,7 @@ private static bool EnumeratorContainsHandler(object source, string handlerName) while ((bool)moveNext.Invoke(enumerator, null)) { Delegate listener = currentProperty.GetValue(enumerator) as Delegate; - if (IsProductionHandler(listener, handlerName)) + if (listener != null && isMatch(listener)) { return true; } @@ -145,16 +147,31 @@ private static bool EnumeratorContainsHandler(object source, string handlerName) return false; } - private static bool IsProductionHandler(Delegate listener, string handlerName) + private static bool IsNamedProductionHandler(Delegate listener, string handlerName) { - if (listener == null) - { - return false; - } - MethodInfo listenerMethod = listener.Method; return listenerMethod.DeclaringType == typeof(HotReloadCompilationAssemblies) && listenerMethod.Name == handlerName; } + + // Why walk the declaring types outward: a lambda subscribed from the production class lives on a + // compiler-generated type nested in it, so a name or direct-type match would miss it. + // Why also the cache type: subscribing the memo's own Invalidate as a method group puts the + // handler on HotReloadCompilationAssemblyCache, a top-level type. + private static bool IsAnyProductionHandler(Delegate listener) + { + for (Type declaringType = listener.Method.DeclaringType; + declaringType != null; + declaringType = declaringType.DeclaringType) + { + if (declaringType == typeof(HotReloadCompilationAssemblies) + || declaringType == typeof(HotReloadCompilationAssemblyCache)) + { + return true; + } + } + + return false; + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListPostprocessorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListPostprocessorTests.cs new file mode 100644 index 0000000000..9354f12dd7 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListPostprocessorTests.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +using NUnit.Framework; + +using UnityEditor; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// EditMode coverage for the import callback that drops the compilation assembly list only when an + /// import changed the script set without a domain reload. + /// + public sealed class HotReloadCompilationAssemblyListPostprocessorTests + { + private static readonly string[] Empty = Array.Empty(); + + private const BindingFlags AnyStatic = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + + /// + /// Verifies that the callback has the five-parameter static shape Unity discovers, and no + /// four-parameter form that Unity would call instead. + /// + [Test] + public void OnPostprocessAllAssets_IsTheFiveParameterStaticFormUnityPrefers() + { + Type postprocessor = typeof(HotReloadCompilationAssemblyListPostprocessor); + + Assert.That(postprocessor.IsSubclassOf(typeof(AssetPostprocessor)), Is.True); + Assert.That( + postprocessor.GetMethod( + "OnPostprocessAllAssets", + AnyStatic, + null, + new[] { typeof(string[]), typeof(string[]), typeof(string[]), typeof(string[]), typeof(bool) }, + null), + Is.Not.Null); + Assert.That( + postprocessor.GetMethod( + "OnPostprocessAllAssets", + AnyStatic, + null, + new[] { typeof(string[]), typeof(string[]), typeof(string[]), typeof(string[]) }, + null), + Is.Null); + } + + /// + /// Verifies that importing a source, assembly definition, assembly reference or precompiled + /// assembly without a domain reload drops the kept list, whatever the extension's case. + /// + [TestCase("Assets/Scripts/New.cs")] + [TestCase("Assets/Scripts/New.asmdef")] + [TestCase("Assets/Scripts/New.asmref")] + [TestCase("Assets/Plugins/Lib.dll")] + [TestCase("Assets/Scripts/Upper.CS")] + public void OnPostprocessAllAssets_AssemblyListAssetImportedWithoutReload_DropsTheMemo(string assetPath) + { + AssertBatchDropsTheMemo(new[] { assetPath }, Empty, Empty, Empty); + } + + /// + /// Verifies that a source imported after an asset that does not change the assembly list, in the + /// same batch without a domain reload, still drops the kept list. + /// + [Test] + public void OnPostprocessAllAssets_AssemblyListAssetAfterOtherAssetWithoutReload_DropsTheMemo() + { + AssertBatchDropsTheMemo( + new[] { "Assets/Textures/A.png", "Assets/Scripts/New.cs" }, + Empty, + Empty, + Empty); + } + + /// + /// Verifies that deleting a source without a domain reload drops the kept list. + /// + [Test] + public void OnPostprocessAllAssets_AssemblyListAssetDeletedWithoutReload_DropsTheMemo() + { + AssertBatchDropsTheMemo(Empty, new[] { "Assets/Scripts/Gone.cs" }, Empty, Empty); + } + + /// + /// Verifies that a source that appears as a move destination without a domain reload drops the + /// kept list. + /// + [Test] + public void OnPostprocessAllAssets_AssemblyListAssetMovedWithoutReload_DropsTheMemo() + { + AssertBatchDropsTheMemo(Empty, Empty, new[] { "Assets/Scripts/Gone.cs" }, Empty); + } + + /// + /// Verifies that a source that appears as a move origin without a domain reload drops the kept + /// list. + /// + [Test] + public void OnPostprocessAllAssets_AssemblyListAssetMovedFromWithoutReload_DropsTheMemo() + { + AssertBatchDropsTheMemo(Empty, Empty, Empty, new[] { "Assets/Scripts/Gone.cs" }); + } + + /// + /// Verifies that the batch of the import whose compile reloaded the domain keeps the list the new + /// domain already filled. + /// + [Test] + public void OnPostprocessAllAssets_AssemblyListAssetImportedWithReload_KeepsTheMemo() + { + IReadOnlyList first = HotReloadCompilationAssemblies.Current(); + + HotReloadCompilationAssemblyListPostprocessor.OnPostprocessAllAssets( + new[] { "Assets/Scripts/New.cs" }, + Empty, + Empty, + Empty, + true); + IReadOnlyList second = HotReloadCompilationAssemblies.Current(); + + Assert.That(second, Is.SameAs(first)); + } + + /// + /// Verifies that importing assets that do not change the assembly list, or an empty batch, keeps + /// the kept list. + /// + [TestCase("Assets/Textures/A.png")] + [TestCase("Assets/Prefabs/A.prefab")] + [TestCase("Assets/Data/a.json")] + [TestCase("Assets/Notes/Readme.cs.txt")] + [TestCase("")] + public void OnPostprocessAllAssets_OtherAssetsImportedWithoutReload_KeepsTheMemo(string assetPath) + { + string[] imported = assetPath.Length == 0 ? Empty : new[] { assetPath }; + IReadOnlyList first = HotReloadCompilationAssemblies.Current(); + + HotReloadCompilationAssemblyListPostprocessor.OnPostprocessAllAssets(imported, Empty, Empty, Empty, false); + IReadOnlyList second = HotReloadCompilationAssemblies.Current(); + + Assert.That(second, Is.SameAs(first)); + } + + private static void AssertBatchDropsTheMemo( + string[] importedAssets, + string[] deletedAssets, + string[] movedAssets, + string[] movedFromAssetPaths) + { + IReadOnlyList first = HotReloadCompilationAssemblies.Current(); + + HotReloadCompilationAssemblyListPostprocessor.OnPostprocessAllAssets( + importedAssets, + deletedAssets, + movedAssets, + movedFromAssetPaths, + false); + IReadOnlyList second = HotReloadCompilationAssemblies.Current(); + + Assert.That(second, Is.Not.Empty); + Assert.That(second, Is.Not.SameAs(first)); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListPostprocessorTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListPostprocessorTests.cs.meta new file mode 100644 index 0000000000..15b2c8b010 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListPostprocessorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7577180f29bbd496e8303add859cc78d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs index 842fe71c12..4d0bd6bc71 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; -using UnityEditor; using UnityEditor.Compilation; using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; @@ -10,8 +9,10 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// /// The compilation assembly list for hot reload, asked of Unity once per domain. /// Why: on a project with several hundred assemblies one CompilationPipeline.GetAssemblies() call - /// takes 340-620 ms, while the list only changes through an import or a compile. The startup - /// snapshot capture is the first caller after a domain reload, so runs find the list already kept. + /// takes 340-620 ms, while the list only changes through a compile, or through an import that + /// changes the script set without compiling (see HotReloadCompilationAssemblyListPostprocessor). + /// The startup snapshot capture is the first caller after a domain reload, so runs find the list + /// already kept. /// internal static class HotReloadCompilationAssemblies { @@ -23,9 +24,6 @@ static HotReloadCompilationAssemblies() // Why: a compile that fails keeps the domain alive, so without this the list would keep // the asmdefs and source files from before that compile until the next reload. CompilationPipeline.compilationStarted += OnCompilationStarted; - // Why: an import does not always compile (Play Mode with Recompile After Finished Playing - // postpones it), yet it can change the source files of an assembly. - EditorApplication.projectChanged += OnProjectChanged; } /// @@ -49,7 +47,10 @@ private static void OnCompilationStarted(object context) Cache.Invalidate(); } - private static void OnProjectChanged() + /// + /// Drops the kept list because an import changed the script set without a domain reload. + /// + internal static void DropForScriptSetChange() { Cache.Invalidate(); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListPostprocessor.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListPostprocessor.cs new file mode 100644 index 0000000000..97c21dfd95 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListPostprocessor.cs @@ -0,0 +1,72 @@ +using System; +using System.IO; + +using UnityEditor; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Drops the compilation assembly list when an import changed the script set without a domain + /// reload. Why not EditorApplication.projectChanged: Unity raises it for every import, including + /// the one whose compile just reloaded the domain, and after the new domain's startup capture + /// has filled the list; dropping it there made the first hot reload after every compile ask + /// Unity for the list again (0.7-1.7 s on a project with several hundred assemblies). + /// + internal sealed class HotReloadCompilationAssemblyListPostprocessor : AssetPostprocessor + { + // Assets whose import, deletion or move changes what CompilationPipeline.GetAssemblies() + // answers: sources, assembly definitions and references, and precompiled references. + private static readonly string[] AssemblyListExtensions = { ".cs", ".asmdef", ".asmref", ".dll" }; + + // Called by Unity through reflection after every import batch. Why this five-parameter + // form and no four-parameter one: Unity calls the four-parameter form when both exist, + // and only this one says whether the batch's compile already reloaded the domain. + internal static void OnPostprocessAllAssets( + string[] importedAssets, + string[] deletedAssets, + string[] movedAssets, + string[] movedFromAssetPaths, + bool didDomainReload) + { + Debug.Assert(importedAssets != null, "importedAssets must not be null."); + Debug.Assert(deletedAssets != null, "deletedAssets must not be null."); + Debug.Assert(movedAssets != null, "movedAssets must not be null."); + Debug.Assert(movedFromAssetPaths != null, "movedFromAssetPaths must not be null."); + + // Why keep the list after a reload: the new domain fills it after the reload, from the + // list the compile produced, so the import that triggered that compile has nothing newer. + if (didDomainReload) + { + return; + } + + if (!ChangesTheAssemblyList(importedAssets) + && !ChangesTheAssemblyList(deletedAssets) + && !ChangesTheAssemblyList(movedAssets) + && !ChangesTheAssemblyList(movedFromAssetPaths)) + { + return; + } + + HotReloadCompilationAssemblies.DropForScriptSetChange(); + } + + private static bool ChangesTheAssemblyList(string[] assetPaths) + { + foreach (string assetPath in assetPaths) + { + string extension = Path.GetExtension(assetPath); + foreach (string candidate in AssemblyListExtensions) + { + if (string.Equals(extension, candidate, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + + return false; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListPostprocessor.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListPostprocessor.cs.meta new file mode 100644 index 0000000000..0f2132dacc --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListPostprocessor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b2aeae25795634ce991c493b4bc74809 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 9665068d1ebcfdb616dde2231ce52fdf5d064382 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 18:19:25 +0900 Subject: [PATCH 17/29] perf: Hot reload's caller scan reads an assembly's MemberRef table before walking it, so assemblies that name none of the edited methods are not loaded (#3247) --- .../HotReloadCallSiteScannerFixture.cs | 23 ++ .../HotReloadCallSiteScannerTests.cs | 121 ++++++++++ .../HotReloadReferencedMethodIndexTests.cs | 217 ++++++++++++++++++ ...otReloadReferencedMethodIndexTests.cs.meta | 11 + .../HotReloadCallSiteCrossAssemblyCaller.cs | 22 ++ .../HotReload/HotReloadCallSiteScanner.cs | 61 ++++- .../HotReloadCompiledCallSiteCache.cs | 3 +- .../HotReloadReferencedMethodIndex.cs | 139 +++++++++++ .../HotReloadReferencedMethodIndex.cs.meta | 11 + 9 files changed, 602 insertions(+), 6 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerFixture.cs index 8a2f8dfaad..0c610db0d9 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerFixture.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerFixture.cs @@ -161,6 +161,20 @@ public static int Called() { return 7; } + + public static int GenericMethod() + { + return 10; + } + + /// Nested target so a cross-assembly caller's key keeps the '/' separator. + public static class Nested + { + public static int CalledFromOtherAssembly() + { + return 11; + } + } } /// @@ -196,6 +210,15 @@ public int Target() } } + /// Open generic host whose constructed form is called from another assembly only. + public class HotReloadCrossAssemblyGenericHost + { + public int Target() + { + return 12; + } + } + /// /// Supplies compiled MonoBehaviour caller shapes for caller-aware lifecycle note tests. /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerTests.cs index da0a6472f8..2b549965ab 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerTests.cs @@ -34,6 +34,18 @@ public class HotReloadCallSiteScannerTests private const string QualifiedCallerIdentityTargetTypeMetadataName = "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadQualifiedCallerIdentityTarget"; + private const string CrossAssemblyGenericHostTypeMetadataName = + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadCrossAssemblyGenericHost`1"; + + private const string CrossAssemblyNestedTargetTypeMetadataName = + CrossAssemblyTargetTypeMetadataName + "/Nested"; + + private const string CrossAssemblyCallerAssemblyName = + "UnityCLILoop.Tests.Editor.HotReload.CallSiteCrossAssembly"; + + private const string CrossAssemblyCallerTypeMetadataName = + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadCallSiteCrossAssemblyCaller"; + /// /// What: a method called from an ordinary method is reported with that caller's method key. /// @@ -467,6 +479,115 @@ public void FindCallSites_TwoTargetsMatchingOneCallSite_ReportsOneHitForTheFirst Is.EqualTo(FixtureTypeMetadataName + "::GenericParameterTarget`1(System.Int32)")); } + /// + /// What: a call in another assembly through a constructed generic type is reported, so an + /// assembly whose MemberRef table names the method only via the instantiation is still read. + /// + [Test] + public void FindCallSites_CrossAssemblyGenericTypeInstantiation_ReportsCaller() + { + List hits = FindHits( + CrossAssemblyGenericHostTypeMetadataName, + nameof(HotReloadCrossAssemblyGenericHost.Target), + Array.Empty(), + 0); + + Assert.That(hits.Count, Is.EqualTo(1)); + Assert.That(hits[0].CallerAssemblyName, Is.EqualTo(CrossAssemblyCallerAssemblyName)); + Assert.That( + hits[0].CallerMethodKey, + Is.EqualTo(CrossAssemblyCallerTypeMetadataName + "::CallGenericHostTarget()")); + } + + /// + /// What: an instantiated generic method of another assembly is reported both via Call and via + /// Ldftn, so a MethodSpec operand is traced back to the MemberRef row it instantiates. + /// + [Test] + public void FindCallSites_CrossAssemblyGenericMethodInstantiation_ReportsCallAndLdftn() + { + List hits = FindHits( + CrossAssemblyTargetTypeMetadataName, + nameof(HotReloadCallSiteScannerCrossAssemblyTarget.GenericMethod), + Array.Empty(), + 1); + + Assert.That(hits.Count, Is.EqualTo(2)); + HotReloadCallSiteScanner.CallSiteHit call = hits.Find(hit => !hit.IsFunctionPointerLoad); + HotReloadCallSiteScanner.CallSiteHit load = hits.Find(hit => hit.IsFunctionPointerLoad); + Assert.That(call.CallerAssemblyName, Is.EqualTo(CrossAssemblyCallerAssemblyName)); + Assert.That(load.CallerAssemblyName, Is.EqualTo(CrossAssemblyCallerAssemblyName)); + Assert.That( + call.CallerMethodKey, + Is.EqualTo(CrossAssemblyCallerTypeMetadataName + "::CallGenericMethodTarget()")); + Assert.That( + load.CallerMethodKey, + Is.EqualTo(CrossAssemblyCallerTypeMetadataName + "::CaptureGenericMethodTarget()")); + } + + /// + /// What: a call in another assembly to a method of a nested type is reported, so the nested + /// type's metadata name with '/' finds the caller's MemberRef row. + /// + [Test] + public void FindCallSites_CrossAssemblyNestedTarget_ReportsCaller() + { + List hits = FindHits( + CrossAssemblyNestedTargetTypeMetadataName, + nameof(HotReloadCallSiteScannerCrossAssemblyTarget.Nested.CalledFromOtherAssembly), + Array.Empty(), + 0); + + Assert.That(hits.Count, Is.EqualTo(1)); + Assert.That( + hits[0].CallerMethodKey, + Is.EqualTo(CrossAssemblyCallerTypeMetadataName + "::CallNestedTarget()")); + } + + /// + /// What: a scan of a method no other assembly names reads only the target assembly and + /// lists the referencing assembly as skipped. + /// + [Test] + public void FindCallSites_TargetNoOtherAssemblyMentions_ReadsOnlyTheTargetAssembly() + { + HotReloadCompiledCallSiteCache.Shared.Clear(); + int before = HotReloadCompiledCallSiteCache.Shared.LoadCount; + + HotReloadCallSiteScanner.HotReloadCallSiteScanResult result = Scan( + FixtureTypeMetadataName, + nameof(HotReloadCallSiteScannerFixture.NeverCalled), + Array.Empty(), + 0); + + Assert.That(HotReloadCompiledCallSiteCache.Shared.LoadCount - before, Is.EqualTo(1)); + Assert.That(result.SkippedScanAssemblyNames, Does.Contain(CrossAssemblyCallerAssemblyName)); + Assert.That(result.Hits, Is.Empty); + } + + /// + /// What: a scan of a method another assembly calls reads that assembly too and does not list + /// it as skipped. + /// + [Test] + public void FindCallSites_CrossAssemblyCaller_ReadsTheCallerAssembly() + { + HotReloadCompiledCallSiteCache.Shared.Clear(); + int before = HotReloadCompiledCallSiteCache.Shared.LoadCount; + + HotReloadCallSiteScanner.HotReloadCallSiteScanResult result = Scan( + CrossAssemblyTargetTypeMetadataName, + nameof(HotReloadCallSiteScannerCrossAssemblyTarget.Called), + Array.Empty(), + 0); + + Assert.That(HotReloadCompiledCallSiteCache.Shared.LoadCount - before, Is.EqualTo(2)); + Assert.That( + result.SkippedScanAssemblyNames, + Does.Not.Contain(CrossAssemblyCallerAssemblyName)); + Assert.That(result.Hits.Count, Is.EqualTo(1)); + } + private static List FindHits( string typeMetadataName, string methodName, diff --git a/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs b/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs new file mode 100644 index 0000000000..4343bc18f4 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs @@ -0,0 +1,217 @@ +using System; +using System.IO; + +using NUnit.Framework; + +using UnityEditor.Compilation; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of the per-assembly referenced-method index: which methods of other assemblies a + /// dll's MemberRef table names, and when the dll is read again. A test that changes a dll works + /// on a copy in a private temp directory so that ScriptAssemblies is never touched. + /// + public class HotReloadReferencedMethodIndexTests + { + private const string TestScriptProjectRelativePath = + "Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs"; + + private const string CrossAssemblyCallerAssemblyName = + "UnityCLILoop.Tests.Editor.HotReload.CallSiteCrossAssembly"; + + private const string FixtureTypeMetadataName = + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadCallSiteScannerFixture"; + + private const string CrossAssemblyTargetTypeMetadataName = + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadCallSiteScannerCrossAssemblyTarget"; + + private const string CrossAssemblyGenericHostTypeMetadataName = + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadCrossAssemblyGenericHost`1"; + + private string _temporaryDirectory; + + [SetUp] + public void SetUp() + { + _temporaryDirectory = Path.Combine( + Path.GetTempPath(), + "uloop-referenced-method-index-" + Guid.NewGuid().ToString("N")); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_temporaryDirectory)) + { + Directory.Delete(_temporaryDirectory, recursive: true); + } + } + + /// + /// What: a dll that calls a method of another assembly names that method. + /// + [Test] + public void MentionsAny_MethodOfAnotherAssemblyTheDllCalls_IsTrue() + { + HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(); + + bool mentioned = index.MentionsAny( + CrossDll(), + new[] { Key(TestAssemblyName(), CrossAssemblyTargetTypeMetadataName, "Called") }); + + Assert.That(mentioned, Is.True); + } + + /// + /// What: a method no other assembly calls is not named by the referencing dll. + /// + [Test] + public void MentionsAny_MethodNobodyCalls_IsFalse() + { + HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(); + + bool mentioned = index.MentionsAny( + CrossDll(), + new[] { Key(TestAssemblyName(), FixtureTypeMetadataName, "NeverCalled") }); + + Assert.That(mentioned, Is.False); + } + + /// + /// What: a call through a constructed generic type is filed under the open type's name. + /// + [Test] + public void MentionsAny_CallThroughConstructedGenericType_IsFiledUnderTheOpenType() + { + HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(); + + bool mentioned = index.MentionsAny( + CrossDll(), + new[] { Key(TestAssemblyName(), CrossAssemblyGenericHostTypeMetadataName, "Target") }); + + Assert.That(mentioned, Is.True); + } + + /// + /// What: a method of a nested type is filed under the metadata name with '/', not the + /// reflection or source spelling. + /// + [Test] + public void MentionsAny_NestedTypeKey_UsesTheMetadataSeparator() + { + HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(); + const string methodName = "CalledFromOtherAssembly"; + + bool metadataSpelling = index.MentionsAny( + CrossDll(), + new[] { Key(TestAssemblyName(), CrossAssemblyTargetTypeMetadataName + "/Nested", methodName) }); + bool reflectionSpelling = index.MentionsAny( + CrossDll(), + new[] { Key(TestAssemblyName(), CrossAssemblyTargetTypeMetadataName + "+Nested", methodName) }); + bool sourceSpelling = index.MentionsAny( + CrossDll(), + new[] { Key(TestAssemblyName(), CrossAssemblyTargetTypeMetadataName + ".Nested", methodName) }); + + Assert.That(metadataSpelling, Is.True); + Assert.That(reflectionSpelling, Is.False); + Assert.That(sourceSpelling, Is.False); + } + + /// + /// What: a method of the dll's own module that the dll calls is not named, because that call + /// is a MethodDef operand with no MemberRef row. + /// + [Test] + public void MentionsAny_OwnMethodDefCallee_IsFalse() + { + HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(); + + // The cross assembly defines its own type with the fixture's full name, and its + // CallSameFullNameTarget calls this method. + bool mentioned = index.MentionsAny( + CrossDll(), + new[] { Key(CrossAssemblyCallerAssemblyName, FixtureTypeMetadataName, "CalledFromCrossAssembly") }); + + Assert.That(mentioned, Is.False); + } + + /// + /// What: a second question about an unchanged dll is answered without reading it again. + /// + [Test] + public void MentionsAny_SameDllTwice_ReadsOnce() + { + HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(); + string[] keys = { Key(TestAssemblyName(), CrossAssemblyTargetTypeMetadataName, "Called") }; + + index.MentionsAny(CrossDll(), keys); + index.MentionsAny(CrossDll(), keys); + + Assert.That(index.LoadCount, Is.EqualTo(1)); + } + + /// + /// What: a dll whose write time changed after it was read is read again. + /// + [Test] + public void MentionsAny_DllWriteTimeChanged_ReadsAgain() + { + HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(); + string copy = CopyCrossDllToTemp(); + string[] keys = { Key(TestAssemblyName(), CrossAssemblyTargetTypeMetadataName, "Called") }; + + bool first = index.MentionsAny(copy, keys); + File.SetLastWriteTimeUtc(copy, File.GetLastWriteTimeUtc(copy).AddMinutes(1)); + bool second = index.MentionsAny(copy, keys); + + Assert.That(first, Is.True); + Assert.That(second, Is.True); + Assert.That(index.LoadCount, Is.EqualTo(2)); + } + + private static string Key(string assemblyName, string openDeclaringTypeFullName, string methodName) + { + return HotReloadReferencedMethodIndex.BuildKey(assemblyName, openDeclaringTypeFullName, methodName); + } + + private string CopyCrossDllToTemp() + { + string sourceDllPath = CrossDll(); + Assert.That(File.Exists(sourceDllPath), Is.True, "Assembly dll missing: " + sourceDllPath); + + Directory.CreateDirectory(_temporaryDirectory); + string copy = Path.Combine(_temporaryDirectory, Path.GetFileName(sourceDllPath)); + File.Copy(sourceDllPath, copy); + return copy; + } + + private static string CrossDll() + { + return DllPath(CrossAssemblyCallerAssemblyName); + } + + private static string DllPath(string assemblyName) + { + return Path.Combine( + ProjectRoot(), + HotReloadConstants.ScriptAssembliesRelativeDirectory, + assemblyName + HotReloadConstants.CompiledAssemblyExtension); + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + + private static string TestAssemblyName() + { + string rawAssemblyName = CompilationPipeline.GetAssemblyNameFromScriptPath( + TestScriptProjectRelativePath); + return Path.GetFileNameWithoutExtension(rawAssemblyName); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs.meta new file mode 100644 index 0000000000..ae606938e4 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3922c3c4140c74f68ab319264ff17757 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReloadCallSiteCrossAssembly/HotReloadCallSiteCrossAssemblyCaller.cs b/Assets/Tests/Editor/HotReloadCallSiteCrossAssembly/HotReloadCallSiteCrossAssemblyCaller.cs index 556065a66c..4cf6b16be0 100644 --- a/Assets/Tests/Editor/HotReloadCallSiteCrossAssembly/HotReloadCallSiteCrossAssemblyCaller.cs +++ b/Assets/Tests/Editor/HotReloadCallSiteCrossAssembly/HotReloadCallSiteCrossAssemblyCaller.cs @@ -1,3 +1,5 @@ +using System; + namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// @@ -15,6 +17,26 @@ public static int CallSameFullNameTarget() { return HotReloadCallSiteScannerFixture.CalledFromCrossAssembly(); } + + public static int CallGenericHostTarget() + { + return new HotReloadCrossAssemblyGenericHost().Target(); + } + + public static int CallGenericMethodTarget() + { + return HotReloadCallSiteScannerCrossAssemblyTarget.GenericMethod(); + } + + public static Func CaptureGenericMethodTarget() + { + return HotReloadCallSiteScannerCrossAssemblyTarget.GenericMethod; + } + + public static int CallNestedTarget() + { + return HotReloadCallSiteScannerCrossAssemblyTarget.Nested.CalledFromOtherAssembly(); + } } /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs index 24d6e91006..e9164b5722 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs @@ -31,14 +31,22 @@ internal sealed class HotReloadCallSiteScanResult /// public int ExaminedCallSiteCount; + /// + /// Assemblies of the scan set whose MemberRef table names none of the targets, so they + /// were not read; diagnostic, like ExaminedCallSiteCount. + /// + public List SkippedScanAssemblyNames; + public HotReloadCallSiteScanResult( List hits, List missingScanAssemblyNames, - int examinedCallSiteCount = 0) + int examinedCallSiteCount = 0, + List skippedScanAssemblyNames = null) { Hits = hits; MissingScanAssemblyNames = missingScanAssemblyNames; ExaminedCallSiteCount = examinedCallSiteCount; + SkippedScanAssemblyNames = skippedScanAssemblyNames ?? new List(); } } @@ -105,7 +113,10 @@ public static HotReloadCallSiteScanResult FindCallSites( return new HotReloadCallSiteScanResult(hits, missingScanAssemblyNames); } - HashSet scanAssemblyNames = CollectScanAssemblyNames(targets); + HashSet targetAssemblyNames = CollectTargetAssemblyNames(targets); + HashSet scanAssemblyNames = CollectScanAssemblyNames(targets, targetAssemblyNames); + HashSet targetKeys = CollectTargetKeys(targets); + List skippedScanAssemblyNames = new List(); int examinedCallSiteCount = 0; CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); foreach (string assemblyName in scanAssemblyNames) @@ -121,19 +132,59 @@ public static HotReloadCallSiteScanResult FindCallSites( continue; } + // Why a target assembly is always walked: its own call sites are MethodDef operands + // with no MemberRef row, so the table cannot vouch for them. + if (!targetAssemblyNames.Contains(assemblyName) + && !HotReloadReferencedMethodIndex.Shared.MentionsAny(dllPath, targetKeys)) + { + skippedScanAssemblyNames.Add(assemblyName); + continue; + } + examinedCallSiteCount += CollectHitsFromAssembly(assemblyName, dllPath, targets, hits); } - return new HotReloadCallSiteScanResult(hits, missingScanAssemblyNames, examinedCallSiteCount); + return new HotReloadCallSiteScanResult( + hits, + missingScanAssemblyNames, + examinedCallSiteCount, + skippedScanAssemblyNames); } - private static HashSet CollectScanAssemblyNames(CompiledMethodIdentity[] targets) + private static HashSet CollectTargetAssemblyNames(CompiledMethodIdentity[] targets) { HashSet targetAssemblyNames = new HashSet(StringComparer.Ordinal); - HashSet targetDllFileNames = new HashSet(StringComparer.Ordinal); foreach (CompiledMethodIdentity target in targets) { targetAssemblyNames.Add(target.AssemblyName); + } + + return targetAssemblyNames; + } + + // Keys the referenced-method index files a MemberRef row under. The type name is the + // metadata name (nested types joined with '/'), the spelling Cecil's FullName uses. + private static HashSet CollectTargetKeys(CompiledMethodIdentity[] targets) + { + HashSet targetKeys = new HashSet(StringComparer.Ordinal); + foreach (CompiledMethodIdentity target in targets) + { + targetKeys.Add(HotReloadReferencedMethodIndex.BuildKey( + target.AssemblyName, + target.TypeMetadataName.Value, + target.MethodName)); + } + + return targetKeys; + } + + private static HashSet CollectScanAssemblyNames( + CompiledMethodIdentity[] targets, + HashSet targetAssemblyNames) + { + HashSet targetDllFileNames = new HashSet(StringComparer.Ordinal); + foreach (CompiledMethodIdentity target in targets) + { targetDllFileNames.Add(target.AssemblyName + HotReloadConstants.CompiledAssemblyExtension); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteCache.cs index ac61191eeb..3f69a885c7 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteCache.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteCache.cs @@ -458,7 +458,8 @@ private void EvictEntry(string path) evicted.Dispose(); } - private static DllFingerprint ReadFingerprint(string fullPath) + // The referenced-method index reads the same identity, so both caches agree on when a dll changed. + internal static DllFingerprint ReadFingerprint(string fullPath) { FileInfo fileInfo = new FileInfo(fullPath); return new DllFingerprint( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs new file mode 100644 index 0000000000..bee8d7d5b7 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Mono.Cecil; + +using UnityEngine; + +using DllFingerprint = io.github.hatayama.UnityCliLoop.FirstPartyTools.HotReloadCompiledCallSiteCache.DllFingerprint; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Per compiled assembly, the methods of other assemblies its MemberRef table names, keyed + /// "<assembly>|<open declaring type full name>::<method name>". Why: a caller scan of a + /// method reads and walks every assembly that references the method's assembly, although + /// an assembly whose MemberRef table does not name the method cannot call it (a cross-assembly + /// call site is a MemberRef row); the table is read in milliseconds, the walk in hundreds. + /// Kept while the dll's length, write time and MVID match, like the compiled call-site cache. + /// + internal sealed class HotReloadReferencedMethodIndex + { + public static HotReloadReferencedMethodIndex Shared { get; } = new HotReloadReferencedMethodIndex(); + + private readonly object _gate = new object(); + private readonly Dictionary _entries = new Dictionary(StringComparer.Ordinal); + private int _loadCount; + + /// Number of times a dll's MemberRef table was read. + internal int LoadCount + { + get + { + lock (_gate) + { + return _loadCount; + } + } + } + + /// Key of a method as the index files it; the scan builds the same key from a target. + internal static string BuildKey(string assemblyName, string openDeclaringTypeFullName, string methodName) + { + return assemblyName + "|" + openDeclaringTypeFullName + "::" + methodName; + } + + /// + /// Whether the dll's MemberRef table names any of . True is a + /// necessary condition for a call site, not a sufficient one; false means the dll holds + /// no call site of those methods. The file must exist. + /// + internal bool MentionsAny(string dllPath, IReadOnlyCollection keys) + { + Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); + Debug.Assert(keys != null, "keys must not be null."); + + string fullPath = Path.GetFullPath(dllPath); + DllFingerprint fingerprint = HotReloadCompiledCallSiteCache.ReadFingerprint(fullPath); + lock (_gate) + { + if (!_entries.TryGetValue(fullPath, out Entry entry) || !entry.Fingerprint.Equals(fingerprint)) + { + // Why stored under the fingerprint read before the table: a dll replaced while + // it is read shows another fingerprint next time and is read again. + HashSet referenced = ReadReferencedMethodKeys(fullPath); + _loadCount++; + entry = new Entry(fingerprint, referenced); + _entries[fullPath] = entry; + } + + foreach (string key in keys) + { + if (entry.Keys.Contains(key)) + { + return true; + } + } + + return false; + } + } + + internal void Clear() + { + lock (_gate) + { + _entries.Clear(); + } + } + + private static HashSet ReadReferencedMethodKeys(string fullDllPath) + { + // Deferred reading leaves method bodies untouched; only the MemberRef table is walked. + ReaderParameters metadataOnly = new ReaderParameters { ReadingMode = ReadingMode.Deferred }; + using (AssemblyDefinition assembly = AssemblyDefinition.ReadAssembly(fullDllPath, metadataOnly)) + { + HashSet keys = new HashSet(StringComparer.Ordinal); + foreach (MemberReference member in assembly.MainModule.GetMemberReferences()) + { + MethodReference method = member as MethodReference; + // Fields, and a method row without a declaring type, are never a scan's match. + if (method == null || method.DeclaringType == null) + { + continue; + } + + // The same opening the scan's identity match uses, so a call through Host + // is filed under Host`1. + TypeReference open = HotReloadCompiledCallSiteIndex.GetOpenDeclaringType(method.DeclaringType); + // Why every assembly scope and not only project assemblies: the project's + // assembly list is empty while Unity compiles, and a key set built then would + // hide real callers until this dll is rebuilt. A module-scoped row names a type + // of this dll itself, which a scan of another assembly never matches. + AssemblyNameReference scope = open.Scope as AssemblyNameReference; + if (scope == null) + { + continue; + } + + keys.Add(BuildKey(scope.Name, open.FullName, method.Name)); + } + + return keys; + } + } + + private sealed class Entry + { + public readonly DllFingerprint Fingerprint; + public readonly HashSet Keys; + + public Entry(DllFingerprint fingerprint, HashSet keys) + { + Fingerprint = fingerprint; + Keys = keys; + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs.meta new file mode 100644 index 0000000000..7a1049d59f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2ba33c29a0017414db28ba24cb06a889 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 1ed19803f7d4065edd930d7da7d4671efac0eac3 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 18:49:03 +0900 Subject: [PATCH 18/29] perf: Hot reload keeps each assembly's referenced-method set on disk, so the first caller scan after a domain reload reads only assemblies that name an edited method (#3248) --- .../HotReloadReferencedMethodIndexTests.cs | 239 +++++++++++++++++- .../HotReloadReferencedMethodIndex.cs | 156 +++++++++++- .../HotReload/Shared/HotReloadConstants.cs | 6 + docs/hot-reload.md | 8 +- 4 files changed, 393 insertions(+), 16 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs b/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs index 4343bc18f4..abd0d4ada2 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.IO; using NUnit.Framework; @@ -33,13 +34,17 @@ public class HotReloadReferencedMethodIndexTests "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadCrossAssemblyGenericHost`1"; private string _temporaryDirectory; + private string _persistenceDirectory; [SetUp] public void SetUp() { + // Why a directory per test: a set persisted by an earlier test or domain would let a new + // index answer without reading the dll and break every LoadCount expectation. _temporaryDirectory = Path.Combine( Path.GetTempPath(), "uloop-referenced-method-index-" + Guid.NewGuid().ToString("N")); + _persistenceDirectory = Path.Combine(_temporaryDirectory, "persisted"); } [TearDown] @@ -57,7 +62,7 @@ public void TearDown() [Test] public void MentionsAny_MethodOfAnotherAssemblyTheDllCalls_IsTrue() { - HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(); + HotReloadReferencedMethodIndex index = NewIndex(); bool mentioned = index.MentionsAny( CrossDll(), @@ -72,7 +77,7 @@ public void MentionsAny_MethodOfAnotherAssemblyTheDllCalls_IsTrue() [Test] public void MentionsAny_MethodNobodyCalls_IsFalse() { - HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(); + HotReloadReferencedMethodIndex index = NewIndex(); bool mentioned = index.MentionsAny( CrossDll(), @@ -87,7 +92,7 @@ public void MentionsAny_MethodNobodyCalls_IsFalse() [Test] public void MentionsAny_CallThroughConstructedGenericType_IsFiledUnderTheOpenType() { - HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(); + HotReloadReferencedMethodIndex index = NewIndex(); bool mentioned = index.MentionsAny( CrossDll(), @@ -103,7 +108,7 @@ public void MentionsAny_CallThroughConstructedGenericType_IsFiledUnderTheOpenTyp [Test] public void MentionsAny_NestedTypeKey_UsesTheMetadataSeparator() { - HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(); + HotReloadReferencedMethodIndex index = NewIndex(); const string methodName = "CalledFromOtherAssembly"; bool metadataSpelling = index.MentionsAny( @@ -128,7 +133,7 @@ public void MentionsAny_NestedTypeKey_UsesTheMetadataSeparator() [Test] public void MentionsAny_OwnMethodDefCallee_IsFalse() { - HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(); + HotReloadReferencedMethodIndex index = NewIndex(); // The cross assembly defines its own type with the fixture's full name, and its // CallSameFullNameTarget calls this method. @@ -145,7 +150,7 @@ public void MentionsAny_OwnMethodDefCallee_IsFalse() [Test] public void MentionsAny_SameDllTwice_ReadsOnce() { - HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(); + HotReloadReferencedMethodIndex index = NewIndex(); string[] keys = { Key(TestAssemblyName(), CrossAssemblyTargetTypeMetadataName, "Called") }; index.MentionsAny(CrossDll(), keys); @@ -160,7 +165,7 @@ public void MentionsAny_SameDllTwice_ReadsOnce() [Test] public void MentionsAny_DllWriteTimeChanged_ReadsAgain() { - HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(); + HotReloadReferencedMethodIndex index = NewIndex(); string copy = CopyCrossDllToTemp(); string[] keys = { Key(TestAssemblyName(), CrossAssemblyTargetTypeMetadataName, "Called") }; @@ -173,6 +178,226 @@ public void MentionsAny_DllWriteTimeChanged_ReadsAgain() Assert.That(index.LoadCount, Is.EqualTo(2)); } + /// + /// What: a new index on the same directory answers from the set the first index persisted, + /// without reading the dll. + /// + [Test] + public void MentionsAny_NewIndexOnTheSameDirectory_AnswersFromThePersistedFileWithoutReadingTheDll() + { + HotReloadReferencedMethodIndex first = NewIndex(); + bool mentionedFirst = first.MentionsAny(CrossDll(), CalledKeys()); + HotReloadReferencedMethodIndex second = NewIndex(); + + bool mentionedSecond = second.MentionsAny(CrossDll(), CalledKeys()); + + Assert.That(mentionedFirst, Is.True); + Assert.That(first.LoadCount, Is.EqualTo(1)); + Assert.That(mentionedSecond, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(0)); + Assert.That(second.PersistedLoadCount, Is.EqualTo(1)); + } + + /// + /// What: a dll written after its set was persisted makes a new index read the dll again and + /// persist the set under the new stamp. + /// + [Test] + public void MentionsAny_DllWriteTimeChangedAfterTheFileWasPersisted_ReadsTheDllAgainAndPersistsTheNewFile() + { + string copy = CopyCrossDllToTemp(); + bool mentionedFirst = NewIndex().MentionsAny(copy, CalledKeys()); + DateTime newWriteTime = File.GetLastWriteTimeUtc(copy).AddMinutes(1); + File.SetLastWriteTimeUtc(copy, newWriteTime); + HotReloadReferencedMethodIndex second = NewIndex(); + + bool mentionedSecond = second.MentionsAny(copy, CalledKeys()); + + string[] stampFields = File.ReadAllText(PersistedPath(copy)).Split('\n')[1].Split('\t'); + Assert.That(mentionedFirst, Is.True); + Assert.That(mentionedSecond, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(1)); + Assert.That( + stampFields[1], + Is.EqualTo(File.GetLastWriteTimeUtc(copy).Ticks.ToString(CultureInfo.InvariantCulture))); + } + + /// + /// What: an empty persisted file is treated as missing: the dll is read and the set is + /// written again. + /// + [Test] + public void MentionsAny_PersistedFileEmpty_ReadsTheDll() + { + NewIndex().MentionsAny(CrossDll(), CalledKeys()); + File.WriteAllBytes(PersistedPath(CrossDll()), Array.Empty()); + HotReloadReferencedMethodIndex second = NewIndex(); + + bool mentioned = second.MentionsAny(CrossDll(), CalledKeys()); + + Assert.That(mentioned, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(1)); + Assert.That(new FileInfo(PersistedPath(CrossDll())).Length, Is.GreaterThan(0)); + } + + /// + /// What: a persisted file that lost its last key line, while still claiming the original + /// count, is treated as missing. + /// + [Test] + public void MentionsAny_PersistedFileMissingItsLastLine_ReadsTheDll() + { + AssertRewrittenFileIsNotUsed(persisted => + { + string withoutTrailingNewline = persisted.Substring(0, persisted.Length - 1); + return withoutTrailingNewline.Substring(0, withoutTrailingNewline.LastIndexOf('\n') + 1); + }); + } + + /// + /// What: a persisted file with one key line more than its count, still ending with a + /// newline, is treated as missing. + /// + [Test] + public void MentionsAny_PersistedFileWithAnExtraLine_ReadsTheDll() + { + AssertRewrittenFileIsNotUsed(persisted => + { + string withoutTrailingNewline = persisted.Substring(0, persisted.Length - 1); + string lastLine = withoutTrailingNewline.Substring(withoutTrailingNewline.LastIndexOf('\n') + 1); + return persisted + lastLine + "\n"; + }); + } + + /// + /// What: a persisted file whose stamp names another MVID is not used, because it was written + /// for another build of the dll. + /// + [Test] + public void MentionsAny_PersistedFileWithAnotherStamp_ReadsTheDll() + { + AssertRewrittenFileIsNotUsed(persisted => + { + string[] lines = persisted.Split('\n'); + string[] stampFields = lines[1].Split('\t'); + stampFields[2] = Guid.NewGuid().ToString("N"); + lines[1] = string.Join("\t", stampFields); + return string.Join("\n", lines); + }); + } + + /// + /// What: a persisted file with text after its last newline is treated as missing, although + /// splitting it still yields the expected number of parts. + /// + [Test] + public void MentionsAny_PersistedFileWithTextAfterTheLastNewline_ReadsTheDll() + { + AssertRewrittenFileIsNotUsed(persisted => persisted + "x"); + } + + /// + /// What: a persisted file whose line endings became CRLF (a Windows checkout tool or editor) + /// is still read instead of the dll. + /// + [Test] + public void MentionsAny_PersistedFileWithCarriageReturns_StillAnswersFromIt() + { + NewIndex().MentionsAny(CrossDll(), CalledKeys()); + string persisted = File.ReadAllText(PersistedPath(CrossDll())); + File.WriteAllText(PersistedPath(CrossDll()), persisted.Replace("\n", "\r\n")); + HotReloadReferencedMethodIndex second = NewIndex(); + + bool mentioned = second.MentionsAny(CrossDll(), CalledKeys()); + + Assert.That(mentioned, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(0)); + Assert.That(second.PersistedLoadCount, Is.EqualTo(1)); + } + + /// + /// What: a persisted file with three known keys answers for both its first and its last key, + /// so a key loop that drops the first or the last line fails here instead of returning a + /// set that is one key short. + /// + [Test] + public void MentionsAny_PersistedFileWithThreeKeys_AnswersForTheFirstAndTheLastKey() + { + string firstKey = Key(TestAssemblyName(), CrossAssemblyGenericHostTypeMetadataName, "Target"); + string middleKey = CalledKeys()[0]; + string lastKey = Key( + TestAssemblyName(), + CrossAssemblyTargetTypeMetadataName + "/Nested", + "CalledFromOtherAssembly"); + NewIndex().MentionsAny(CrossDll(), CalledKeys()); + string[] lines = File.ReadAllText(PersistedPath(CrossDll())).Split('\n'); + string[] stampFields = lines[1].Split('\t'); + stampFields[3] = "3"; + File.WriteAllText( + PersistedPath(CrossDll()), + lines[0] + "\n" + string.Join("\t", stampFields) + "\n" + + firstKey + "\n" + middleKey + "\n" + lastKey + "\n"); + HotReloadReferencedMethodIndex second = NewIndex(); + + bool mentionsFirst = second.MentionsAny(CrossDll(), new[] { firstKey }); + bool mentionsLast = second.MentionsAny(CrossDll(), new[] { lastKey }); + + Assert.That(mentionsFirst, Is.True); + Assert.That(mentionsLast, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(0)); + Assert.That(second.PersistedLoadCount, Is.EqualTo(1)); + } + + /// + /// What: when the persisted set cannot be moved into place, the question throws after the + /// dll was read and no temp file is left behind. + /// + [Test] + public void MentionsAny_PersistedPathBlocked_ThrowsAndLeavesNoTempFile() + { + HotReloadReferencedMethodIndex index = NewIndex(); + // Why a directory: File.Exists is false for it, so the write skips the delete and the move throws. + Directory.CreateDirectory(PersistedPath(CrossDll())); + TestDelegate mention = () => index.MentionsAny(CrossDll(), CalledKeys()); + + Assert.That(mention, Throws.Exception); + Assert.That(Directory.GetFiles(_persistenceDirectory, "*.tmp-*"), Is.Empty); + Assert.That(index.LoadCount, Is.EqualTo(1)); + } + + // Persists the set with one index, rewrites the file, and asserts that a new index reads the + // dll instead of answering from the rewritten file. + private void AssertRewrittenFileIsNotUsed(Func rewrite) + { + bool mentionedFirst = NewIndex().MentionsAny(CrossDll(), CalledKeys()); + string persisted = File.ReadAllText(PersistedPath(CrossDll())); + Assert.That(persisted, Does.EndWith("\n")); + File.WriteAllText(PersistedPath(CrossDll()), rewrite(persisted)); + HotReloadReferencedMethodIndex second = NewIndex(); + + bool mentionedSecond = second.MentionsAny(CrossDll(), CalledKeys()); + + Assert.That(mentionedFirst, Is.True); + Assert.That(mentionedSecond, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(1)); + Assert.That(second.PersistedLoadCount, Is.EqualTo(0)); + } + + private HotReloadReferencedMethodIndex NewIndex() + { + return new HotReloadReferencedMethodIndex(_persistenceDirectory); + } + + private string PersistedPath(string dllPath) + { + return Path.Combine(_persistenceDirectory, Path.GetFileNameWithoutExtension(dllPath) + ".txt"); + } + + private static string[] CalledKeys() + { + return new[] { Key(TestAssemblyName(), CrossAssemblyTargetTypeMetadataName, "Called") }; + } + private static string Key(string assemblyName, string openDeclaringTypeFullName, string methodName) { return HotReloadReferencedMethodIndex.BuildKey(assemblyName, openDeclaringTypeFullName, methodName); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs index bee8d7d5b7..9147cc1cd4 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; +using System.Text; using Mono.Cecil; @@ -16,15 +18,29 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// method reads and walks every assembly that references the method's assembly, although /// an assembly whose MemberRef table does not name the method cannot call it (a cross-assembly /// call site is a MemberRef row); the table is read in milliseconds, the walk in hundreds. - /// Kept while the dll's length, write time and MVID match, like the compiled call-site cache. + /// Kept while the dll's length, write time and MVID match, like the compiled call-site cache, + /// both in memory and in a file per dll so that a new domain does not read the dll again. /// internal sealed class HotReloadReferencedMethodIndex { - public static HotReloadReferencedMethodIndex Shared { get; } = new HotReloadReferencedMethodIndex(); + public static HotReloadReferencedMethodIndex Shared { get; } = new HotReloadReferencedMethodIndex( + Path.Combine( + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + HotReloadConstants.ReferencedMethodsRelativeDirectory)); private readonly object _gate = new object(); private readonly Dictionary _entries = new Dictionary(StringComparer.Ordinal); + private readonly string _persistenceDirectory; + private const string PersistedFormatHeader = "uloop-referenced-methods 1"; + private int _loadCount; + private int _persistedLoadCount; + + internal HotReloadReferencedMethodIndex(string persistenceDirectory) + { + Debug.Assert(!string.IsNullOrEmpty(persistenceDirectory), "persistenceDirectory must not be null or empty."); + _persistenceDirectory = persistenceDirectory; + } /// Number of times a dll's MemberRef table was read. internal int LoadCount @@ -38,6 +54,18 @@ internal int LoadCount } } + /// Number of times a dll's set was read from its persisted file instead of the dll. + internal int PersistedLoadCount + { + get + { + lock (_gate) + { + return _persistedLoadCount; + } + } + } + /// Key of a method as the index files it; the scan builds the same key from a target. internal static string BuildKey(string assemblyName, string openDeclaringTypeFullName, string methodName) { @@ -62,8 +90,20 @@ internal bool MentionsAny(string dllPath, IReadOnlyCollection keys) { // Why stored under the fingerprint read before the table: a dll replaced while // it is read shows another fingerprint next time and is read again. - HashSet referenced = ReadReferencedMethodKeys(fullPath); - _loadCount++; + string persistedPath = PersistedSetPath(fullPath); + HashSet referenced = TryReadPersistedSet(persistedPath, fingerprint); + if (referenced != null) + { + _persistedLoadCount++; + } + else + { + // Why the read before any write: a read that throws leaves no entry and no file. + referenced = ReadReferencedMethodKeys(fullPath); + _loadCount++; + WritePersistedSet(persistedPath, fingerprint, referenced); + } + entry = new Entry(fingerprint, referenced); _entries[fullPath] = entry; } @@ -80,11 +120,113 @@ internal bool MentionsAny(string dllPath, IReadOnlyCollection keys) } } - internal void Clear() + private string PersistedSetPath(string fullDllPath) { - lock (_gate) + return Path.Combine(_persistenceDirectory, Path.GetFileNameWithoutExtension(fullDllPath) + ".txt"); + } + + // File format: UTF-8 without a BOM, every line ends with "\n", fields are TAB-separated and + // numbers are invariant. + // line 1: the format header + // line 2: dll length, dll write time ticks, MVID ("N" format), count + // then count lines: one key each + // Null for a missing file, another stamp, or any malformed part: a file cut short or edited + // by hand must not answer with part of a set, because a missing key would skip a caller. + private static HashSet TryReadPersistedSet(string path, DllFingerprint fingerprint) + { + if (!File.Exists(path)) + { + return null; + } + + string[] lines = File.ReadAllText(path, Encoding.UTF8).Split('\n'); + for (int index = 0; index < lines.Length; index++) + { + lines[index] = lines[index].TrimEnd('\r'); + } + + if (lines.Length < 3 || !string.Equals(lines[0], PersistedFormatHeader, StringComparison.Ordinal)) { - _entries.Clear(); + return null; + } + + if (!TryParseStampLine(lines[1], out DllFingerprint persisted, out int count) + || !persisted.Equals(fingerprint)) + { + return null; + } + + // The header, the stamp, count keys and the empty string after the last "\n". + if (lines.Length != 3 + count || lines[lines.Length - 1].Length != 0) + { + return null; + } + + HashSet keys = new HashSet(StringComparer.Ordinal); + for (int index = 0; index < count; index++) + { + string key = lines[2 + index]; + if (key.Length == 0) + { + return null; + } + + keys.Add(key); + } + + return keys; + } + + private static bool TryParseStampLine(string line, out DllFingerprint fingerprint, out int count) + { + fingerprint = default; + count = 0; + string[] fields = line.Split('\t'); + if (fields.Length != 4 + || !long.TryParse(fields[0], NumberStyles.None, CultureInfo.InvariantCulture, out long length) + || !long.TryParse(fields[1], NumberStyles.None, CultureInfo.InvariantCulture, out long ticks) + || !Guid.TryParseExact(fields[2], "N", out Guid moduleVersionId) + || !int.TryParse(fields[3], NumberStyles.None, CultureInfo.InvariantCulture, out count)) + { + return false; + } + + fingerprint = new DllFingerprint(length, ticks, moduleVersionId); + return true; + } + + private void WritePersistedSet(string path, DllFingerprint fingerprint, HashSet keys) + { + Directory.CreateDirectory(_persistenceDirectory); + StringBuilder text = new StringBuilder(); + text.Append(PersistedFormatHeader).Append('\n'); + text.Append(fingerprint.Length.ToString(CultureInfo.InvariantCulture)).Append('\t') + .Append(fingerprint.LastWriteTimeUtcTicks.ToString(CultureInfo.InvariantCulture)).Append('\t') + .Append(fingerprint.ModuleVersionId.ToString("N")).Append('\t') + .Append(keys.Count.ToString(CultureInfo.InvariantCulture)).Append('\n'); + foreach (string key in keys) + { + text.Append(key).Append('\n'); + } + + // Why a temp file and a move: a reader in another domain never sees a half-written set. + string tempPath = path + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + File.WriteAllText(tempPath, text.ToString(), new UTF8Encoding(false)); + if (File.Exists(path)) + { + File.Delete(path); + } + + File.Move(tempPath, path); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 1e03965648..376158527e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -60,6 +60,12 @@ internal static class HotReloadConstants // "fmt1" = generation of the file format, bumped the same way as PublicizedRefs. public const string PdbDocumentsRelativeDirectory = "Library/UloopHotReload/PdbDocuments/fmt1"; + // Per-assembly sets of the methods of other assemblies the dll's MemberRef table names, keyed + // by the dll's file name and stamped with its length, write time and MVID. Lets the first hot + // reload run after a domain reload skip reading assemblies that name none of the edited methods. + // "fmt1" = generation of the file format, bumped the same way as PublicizedRefs. + public const string ReferencedMethodsRelativeDirectory = "Library/UloopHotReload/ReferencedMethods/fmt1"; + // Package-relative directory of the out-of-process transform worker sources (tilde dir = Unity-ignored). public const string WorkerSourcePackageRelativePath = "Editor/FirstPartyTools/HotReload/TransformWorker~"; diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 8722ab823d..e1b3ef3887 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -75,10 +75,14 @@ Caches: `Library/UloopHotReload/PublicizedRefs/fmt2/-.dll`, `Library/UloopHotReload/Worker//`, `Library/UloopHotReload/SourceSnapshot/-/` (with a `source-stamps.txt` that records each copied source's length and write time, so a run after a domain reload tells an -unchanged sibling by a stat instead of reading it), and +unchanged sibling by a stat instead of reading it), `Library/UloopHotReload/PdbDocuments/fmt1/.txt` (the documents the PDB's sequence points refer to, stamped with the dll's and the PDB's length and write time and the MVID, so the -first run after a domain reload does not walk the PDB again while they still match). +first run after a domain reload does not walk the PDB again while they still match), and +`Library/UloopHotReload/ReferencedMethods/fmt1/.txt` (the methods of other +assemblies the dll's MemberRef table names, stamped with the dll's length, write time and MVID, +so the first run after a domain reload does not read an assembly that names none of the edited +methods). The shim compile references a fully publicized copy of the edited assembly, and of every other project assembly that grants the edited one its internals through `InternalsVisibleTo`. Any other project assembly is referenced through a `PublicizedExternalRefs` copy that keeps its top-level From 7daed1afc5319e76bb897826765d3a129640409b Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 20:26:59 +0900 Subject: [PATCH 19/29] perf: Hot reload builds the PDB document list with System.Reflection.Metadata from the PDB and the dll's debug directory, so a rebuilt assembly's first run does not walk its dll (#3249) --- .../HotReloadPdbDocumentIndexTests.cs | 113 +++++++++++++++ .../Shared/HotReloadPdbDocumentIndex.cs | 136 ++++++++++++------ ...tPartyTools.HotReload.Shared.Editor.asmdef | 5 +- 3 files changed, 213 insertions(+), 41 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs index 9f739e6ade..970fcbdda0 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Security.Cryptography; +using Mono.Cecil; using Mono.Cecil.Cil; using NUnit.Framework; @@ -525,6 +527,117 @@ private void AssertRewrittenListIsNotUsed(Func rewrite) Assert.That(second.PersistedLoadCount, Is.EqualTo(0)); } + /// + /// What: the list read from the PDB holds the same documents, with the same url, hash algorithm + /// and hash, as a Cecil walk over the dll's types, methods and sequence points, and as many. + /// + [TestCase(TestAssemblyName)] + [TestCase(PausePointsRuntimeAssemblyName)] + public void ReadDocuments_MatchesTheCecilWalk_ForTheTestAssembly(string assemblyName) + { + string dllPath = DllPath(assemblyName); + string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + + List read = HotReloadPdbDocumentIndex.ReadDocuments(dllPath, pdbPath); + List walked = WalkDocumentsWithCecil(dllPath, pdbPath); + + Assert.That(read.Count, Is.GreaterThan(0)); + Assert.That(read.Count, Is.EqualTo(walked.Count)); + Assert.That(DocumentKeys(read), Is.EquivalentTo(DocumentKeys(walked))); + } + + /// + /// What: a PDB of another build than the dll makes the read throw instead of returning a list, + /// the same contract as Cecil's symbol match check. + /// + [Test] + public void ReadDocuments_PdbOfAnotherBuild_Throws() + { + string directory = Path.Combine( + Path.GetTempPath(), + "uloop-pdb-document-index-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + string dllPath = Path.Combine(directory, "X.dll"); + string pdbPath = Path.Combine(directory, "X.pdb"); + File.Copy(DllPath(TestAssemblyName), dllPath); + File.Copy(Path.ChangeExtension(DllPath(PausePointsRuntimeAssemblyName), ".pdb"), pdbPath); + + Assert.Throws(() => HotReloadPdbDocumentIndex.ReadDocuments(dllPath, pdbPath)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static HashSet DocumentKeys(List documents) + { + HashSet keys = new HashSet(); + foreach (HotReloadPdbDocument document in documents) + { + string hash = document.Hash == null ? string.Empty : BitConverter.ToString(document.Hash); + keys.Add(document.Url + "|" + (int)document.HashAlgorithm + "|" + hash); + } + + return keys; + } + + // The walk the index used before it read the PDB alone: every distinct document a visible + // sequence point of a method body refers to. + private static List WalkDocumentsWithCecil(string dllPath, string pdbPath) + { + using FileStream dllStream = File.Open(dllPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using FileStream pdbStream = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + ReaderParameters readerParameters = new ReaderParameters + { + InMemory = true, + ReadSymbols = true, + SymbolReaderProvider = new PortablePdbReaderProvider(), + SymbolStream = pdbStream + }; + + using AssemblyDefinition assemblyDefinition = AssemblyDefinition.ReadAssembly(dllStream, readerParameters); + List documents = new List(); + HashSet seen = new HashSet(); + foreach (TypeDefinition type in assemblyDefinition.MainModule.GetTypes()) + { + foreach (MethodDefinition method in type.Methods) + { + if (!method.HasBody) + { + continue; + } + + MethodDebugInformation debugInformation = method.DebugInformation; + if (debugInformation == null || !debugInformation.HasSequencePoints) + { + continue; + } + + foreach (SequencePoint sequencePoint in debugInformation.SequencePoints) + { + if (sequencePoint.IsHidden || sequencePoint.Document == null) + { + continue; + } + + if (seen.Add(sequencePoint.Document)) + { + documents.Add(new HotReloadPdbDocument( + sequencePoint.Document.Url, + sequencePoint.Document.HashAlgorithm, + sequencePoint.Document.Hash)); + } + } + } + } + + return documents; + } + private static string RewriteFirstDocumentField(string persisted, int fieldIndex, Func rewrite) { string[] lines = persisted.Split('\n'); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs index 54acfe57a1..fc03061f07 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs @@ -2,11 +2,11 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; using System.Text; -using Mono.Cecil; -using Mono.Cecil.Cil; -using Mono.Cecil.Pdb; +using DocumentHashAlgorithm = Mono.Cecil.Cil.DocumentHashAlgorithm; using UnityEngine; @@ -90,6 +90,11 @@ public override int GetHashCode() private const int PersistedStampFieldCount = 6; private const int PersistedDocumentFieldCount = 3; + // Hash algorithm GUIDs a portable PDB records for its documents. + private static readonly Guid Sha1HashAlgorithmGuid = new Guid("ff1816ec-aa5e-4d10-87f7-6f4963833460"); + private static readonly Guid Sha256HashAlgorithmGuid = new Guid("8829d00f-11b8-4213-878b-770e8597ac16"); + private static readonly Guid Md5HashAlgorithmGuid = new Guid("406ea660-64cf-4c82-b6f0-42d48172a799"); + private sealed class Entry { public readonly FileStamp Stamp; @@ -411,60 +416,111 @@ private static bool TryFind( return false; } - // Every distinct document a sequence point refers to, in the order a walk over types, - // methods and sequence points first meets them. Why not the PDB's document table: it - // also lists files without a method body, which the walk this replaces never returned. - private static List ReadDocuments(string dllPath, string pdbPath) + // Every distinct document a visible sequence point refers to, in the order the PDB's + // method debug information table, which follows method tokens, first meets them. Why not + // the PDB's document table: it also lists files without a method body, which the Cecil + // walk this replaces never returned. Why only the dll's debug directory: matching the PDB + // to its build needs the CodeView GUID alone, and reading the type and method tables took + // over a second on a large assembly. A PDB of another build is rejected by that GUID + // before any document is read. + internal static List ReadDocuments(string dllPath, string pdbPath) { - using FileStream dllStream = File.Open(dllPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + Guid codeViewGuid = ReadCodeViewGuid(dllPath); using FileStream pdbStream = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using MetadataReaderProvider provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream); + MetadataReader reader = provider.GetMetadataReader(); + + // Why checked here: the stamp pairs the dll and the PDB by time, not by build; a PDB of + // another build would describe sources the dll was not compiled from. + BlobContentId pdbId = new BlobContentId(reader.DebugMetadataHeader.Id); + if (pdbId.Guid != codeViewGuid) + { + throw new InvalidOperationException( + "The PDB beside " + Path.GetFileName(dllPath) + " belongs to another build of it."); + } - ReaderParameters readerParameters = new ReaderParameters + List ordered = CollectDocumentsWithVisibleSequencePoints(reader); + List documents = new List(ordered.Count); + foreach (DocumentHandle handle in ordered) { - InMemory = true, - ReadSymbols = true, - SymbolReaderProvider = new PortablePdbReaderProvider(), - SymbolStream = pdbStream - }; + Document document = reader.GetDocument(handle); + documents.Add(new HotReloadPdbDocument( + reader.GetString(document.Name), + ToHashAlgorithm(reader.GetGuid(document.HashAlgorithm)), + reader.GetBlobBytes(document.Hash))); + } - using AssemblyDefinition assemblyDefinition = AssemblyDefinition.ReadAssembly(dllStream, readerParameters); - List documents = new List(); - // Why by reference: Cecil hands out one Document per row of the PDB's document table. - HashSet seen = new HashSet(); - foreach (TypeDefinition type in assemblyDefinition.MainModule.GetTypes()) + return documents; + } + + // Why by handle: there is one DocumentHandle per row of the PDB's document table. + private static List CollectDocumentsWithVisibleSequencePoints(MetadataReader reader) + { + List ordered = new List(); + HashSet seen = new HashSet(); + foreach (MethodDebugInformationHandle handle in reader.MethodDebugInformation) { - foreach (MethodDefinition method in type.Methods) + MethodDebugInformation information = reader.GetMethodDebugInformation(handle); + if (information.SequencePointsBlob.IsNil) { - if (!method.HasBody) - { - continue; - } + continue; + } - MethodDebugInformation debugInformation = method.DebugInformation; - if (debugInformation == null || !debugInformation.HasSequencePoints) + foreach (SequencePoint point in information.GetSequencePoints()) + { + if (point.IsHidden || point.Document.IsNil) { continue; } - foreach (SequencePoint sequencePoint in debugInformation.SequencePoints) + if (seen.Add(point.Document)) { - if (sequencePoint.IsHidden || sequencePoint.Document == null) - { - continue; - } - - if (seen.Add(sequencePoint.Document)) - { - documents.Add(new HotReloadPdbDocument( - sequencePoint.Document.Url, - sequencePoint.Document.HashAlgorithm, - sequencePoint.Document.Hash)); - } + ordered.Add(point.Document); } } } - return documents; + return ordered; + } + + // Reads only the PE headers and the debug directory; the type and method tables stay unread. + private static Guid ReadCodeViewGuid(string dllPath) + { + using FileStream dllStream = File.Open(dllPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using PEReader peReader = new PEReader(dllStream); + foreach (DebugDirectoryEntry entry in peReader.ReadDebugDirectory()) + { + if (entry.Type != DebugDirectoryEntryType.CodeView) + { + continue; + } + + return peReader.ReadCodeViewDebugDirectoryData(entry).Guid; + } + + throw new InvalidOperationException( + Path.GetFileName(dllPath) + " has no CodeView debug directory entry, so its PDB cannot be matched."); + } + + // The same mapping Cecil's portable PDB reader applies to a document's hash algorithm GUID. + private static DocumentHashAlgorithm ToHashAlgorithm(Guid algorithm) + { + if (algorithm == Sha1HashAlgorithmGuid) + { + return DocumentHashAlgorithm.SHA1; + } + + if (algorithm == Sha256HashAlgorithmGuid) + { + return DocumentHashAlgorithm.SHA256; + } + + if (algorithm == Md5HashAlgorithmGuid) + { + return DocumentHashAlgorithm.MD5; + } + + return DocumentHashAlgorithm.None; } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/UnityCLILoop.FirstPartyTools.HotReload.Shared.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/UnityCLILoop.FirstPartyTools.HotReload.Shared.Editor.asmdef index 78ec4fc964..f5e70d3795 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/UnityCLILoop.FirstPartyTools.HotReload.Shared.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/UnityCLILoop.FirstPartyTools.HotReload.Shared.Editor.asmdef @@ -15,7 +15,10 @@ "precompiledReferences": [ "Mono.Cecil.dll", "Mono.Cecil.Pdb.dll", - "Newtonsoft.Json.dll" + "Newtonsoft.Json.dll", + "UnityCliLoop.System.Collections.Immutable.dll", + "UnityCliLoop.System.Runtime.CompilerServices.Unsafe.dll", + "UnityCliLoop.System.Reflection.Metadata.dll" ], "autoReferenced": false, "defineConstraints": [], From 4479359a1208708300fb6a964f74d0c46d820431 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 23:03:36 +0900 Subject: [PATCH 20/29] fix: A tool request that Unity never acknowledged is reported as UNITY_NOT_REACHABLE and retryable instead of an internal error (#3251) --- .../projectrunner/connection_retry.go | 1 + .../projectrunner/connection_retry_flow.go | 11 +- .../connection_retry_flow_test.go | 172 +++++++++++++++++- .../projectrunner/connection_retry_test.go | 11 ++ .../projectrunner/plain_tool_log_test.go | 10 + 5 files changed, 199 insertions(+), 6 deletions(-) diff --git a/cli/project-runner/internal/projectrunner/connection_retry.go b/cli/project-runner/internal/projectrunner/connection_retry.go index 8d42c908d1..3a523ee896 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry.go +++ b/cli/project-runner/internal/projectrunner/connection_retry.go @@ -279,6 +279,7 @@ func sendWithTransientConnectionRetryWithDeps( if !shouldRetryUndispatchedConnection(err, outcome) { return finishNonRetryableConnectionAttempt( ctx, + connection, sendAttempt{ outcome: outcome, err: err, diff --git a/cli/project-runner/internal/projectrunner/connection_retry_flow.go b/cli/project-runner/internal/projectrunner/connection_retry_flow.go index 856512656f..ce248030fa 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_flow.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_flow.go @@ -65,6 +65,7 @@ func finishBusyRetry( func finishNonRetryableConnectionAttempt( ctx context.Context, + connection unityipc.Connection, currentAttempt sendAttempt, lastAttempt sendAttempt, responseTimeout time.Duration, @@ -85,10 +86,18 @@ func finishNonRetryableConnectionAttempt( return lastAttempt.outcome, lastAttempt.err } } - if reason, ok := connectionRetryFocusReasonForError(currentAttempt.err, currentAttempt.outcome, responseTimeout); ok { + reason, ok := connectionRetryFocusReasonForError(currentAttempt.err, currentAttempt.outcome, responseTimeout) + if ok { focusController.tryFocus(ctx, reason, currentAttempt.err) focusController.keepUnityFocusedAfterReturn() } + if ok && reason == focusReasonPreAcceptTimeout { + // Why wrap here: the dial and the write succeeded, so a server was listening; what failed is + // its acknowledgement. The tool-failure writer maps this error, with a dispatched-but-unaccepted + // outcome, to UNITY_NOT_REACHABLE with Retryable true, where the raw timeout would read as an + // internal error that tells the caller to fix the environment. + return currentAttempt.outcome, newUnityServerNotRespondingError(connection, currentAttempt.err) + } return currentAttempt.outcome, currentAttempt.err } diff --git a/cli/project-runner/internal/projectrunner/connection_retry_flow_test.go b/cli/project-runner/internal/projectrunner/connection_retry_flow_test.go index fa4e5aa890..cfb2b7d3e7 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_flow_test.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_flow_test.go @@ -219,12 +219,12 @@ func TestFinishNonRetryableConnectionAttemptPrefersBusyOverAnUndispatchedTranspo current := sendAttempt{err: io.ErrUnexpectedEOF} last := sendAttempt{err: busy} - _, err := finishNonRetryableConnectionAttempt(context.Background(), current, last, 0, nil) + _, err := finishNonRetryableConnectionAttempt(context.Background(), unityipc.Connection{}, current, last, 0, nil) if !errors.Is(err, busy) { t.Fatalf("err = %v, want the earlier busy answer", err) } - _, err = finishNonRetryableConnectionAttempt(cancelledContext(), current, last, 0, nil) + _, err = finishNonRetryableConnectionAttempt(cancelledContext(), unityipc.Connection{}, current, last, 0, nil) if !errors.Is(err, context.Canceled) { t.Fatalf("err = %v, want context.Canceled", err) } @@ -264,7 +264,7 @@ func TestFinishNonRetryableConnectionAttemptKeepsADispatchedFailureAfterBusy(t * t.Run("reports the dispatched failure", func(t *testing.T) { // A positive response timeout keeps the accepted timeout out of the focus handling, // which needs a focus controller this test does not build. - outcome, err := finishNonRetryableConnectionAttempt(context.Background(), testCase.current, last, time.Second, nil) + outcome, err := finishNonRetryableConnectionAttempt(context.Background(), unityipc.Connection{}, testCase.current, last, time.Second, nil) if !errors.Is(err, testCase.current.err) || isUnityServerBusyRPCError(err) { t.Fatalf("err = %v, want the dispatched attempt's own error %v", err, testCase.current.err) } @@ -273,7 +273,7 @@ func TestFinishNonRetryableConnectionAttemptKeepsADispatchedFailureAfterBusy(t * } }) t.Run("reports the cancellation", func(t *testing.T) { - _, err := finishNonRetryableConnectionAttempt(cancelledContext(), testCase.current, last, time.Second, nil) + _, err := finishNonRetryableConnectionAttempt(cancelledContext(), unityipc.Connection{}, testCase.current, last, time.Second, nil) if !errors.Is(err, context.Canceled) { t.Fatalf("err = %v, want context.Canceled", err) } @@ -316,7 +316,7 @@ func TestFinishNonRetryableConnectionAttemptKeepsAnEditorUnresponsiveErrorAfterB // controller. Finding no Unity process keeps the focus itself from running. focusController := newConnectionRetryFocusController(unityipc.Connection{ProjectRoot: t.TempDir()}, "get-logs", deps) - outcome, err := finishNonRetryableConnectionAttempt(context.Background(), current, last, 0, focusController) + outcome, err := finishNonRetryableConnectionAttempt(context.Background(), unityipc.Connection{}, current, last, 0, focusController) if !errors.Is(err, current.err) || isUnityServerBusyRPCError(err) { t.Errorf("err = %v, want the accepted attempt's own error %v", err, current.err) } @@ -328,6 +328,168 @@ func TestFinishNonRetryableConnectionAttemptKeepsAnEditorUnresponsiveErrorAfterB } } +// Verifies a request that reached Unity but timed out before Unity accepted it comes back as +// UnityServerNotRespondingError carrying the timeout and the connection it was sent on, whether or +// not a Unity process was found and whether or not the caller has cancelled since. +func TestFinishNonRetryableConnectionAttemptWrapsAPreAcceptTimeoutAsUnityNotResponding(t *testing.T) { + cases := []struct { + name string + ctx context.Context + }{ + {name: "live context", ctx: context.Background()}, + {name: "cancelled context", ctx: cancelledContext()}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{Network: "unix", Address: "/tmp/uloop/sample.sock"}, + ProjectRoot: t.TempDir(), + } + current := sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true}, err: os.ErrDeadlineExceeded} + + outcome, err := finishNonRetryableConnectionAttempt(testCase.ctx, connection, current, sendAttempt{}, 0, nilProbeFocusController(t)) + + var notResponding clierrors.UnityServerNotRespondingError + if !errors.As(err, ¬Responding) { + t.Fatalf("err = %v, want UnityServerNotRespondingError", err) + } + if notResponding.ProjectRoot != connection.ProjectRoot || notResponding.Endpoint != connection.Endpoint.Address { + t.Errorf("error = %+v, want project root %q and endpoint %q", notResponding, connection.ProjectRoot, connection.Endpoint.Address) + } + if !errors.Is(err, os.ErrDeadlineExceeded) { + t.Errorf("err = %v, want it to carry the pre-accept timeout", err) + } + if !clierrors.IsFinalResponseTimeoutError(err) { + t.Errorf("err = %v, want it to still read as a timeout", err) + } + if !reflect.DeepEqual(outcome, current.outcome) { + t.Errorf("outcome = %+v, want the attempt's outcome %+v", outcome, current.outcome) + } + }) + } +} + +// Verifies every attempt other than a pre-accept timeout comes back unwrapped, as its own error (or +// the cancellation, when a busy answer came before and the caller cancelled). +func TestFinishNonRetryableConnectionAttemptLeavesOtherFailuresUnwrapped(t *testing.T) { + permanentConnectFailure := &unityipc.ConnectionAttemptError{Cause: os.ErrPermission} + cases := []struct { + name string + ctx context.Context + current sendAttempt + last sendAttempt + responseTimeout time.Duration + // needsFocus marks an attempt the focus handling sees, which needs a real focus controller. + needsFocus bool + wantErr error + }{ + { + name: "accepted then timed out", + current: sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}, err: os.ErrDeadlineExceeded}, + }, + { + name: "dropped before the accept", + current: sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true}, err: io.EOF}, + }, + { + name: "dropped after the accept", + current: sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}, err: io.ErrUnexpectedEOF}, + }, + { + name: "editor unresponsive after the accept", + current: sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}, err: &unityipc.EditorUnresponsiveError{StallSeconds: 30}}, + needsFocus: true, + }, + { + // A write that timed out; a dial that timed out is a ConnectionAttemptError and is retried + // before it gets here. + name: "undispatched timeout", + current: sendAttempt{outcome: unityipc.UnitySendOutcome{}, err: os.ErrDeadlineExceeded}, + }, + { + name: "accepted then timed out, no response timeout", + current: sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}, err: os.ErrDeadlineExceeded}, + responseTimeout: 0, + needsFocus: true, + }, + { + name: "cancelled after busy", + ctx: cancelledContext(), + current: sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true}, err: os.ErrDeadlineExceeded}, + last: busyAttemptAfterAccept(serverBusyRPCError(t)), + wantErr: context.Canceled, + }, + { + name: "succeeded", + current: sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}}, + }, + { + name: "malformed frame before the accept", + current: sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true}, err: errors.New("invalid frame")}, + }, + { + name: "undispatched disconnect", + current: sendAttempt{outcome: unityipc.UnitySendOutcome{}, err: io.EOF}, + }, + { + name: "cancelled after the accept", + current: sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}, err: context.Canceled}, + }, + { + name: "permanent connect failure", + current: sendAttempt{outcome: unityipc.UnitySendOutcome{}, err: permanentConnectFailure}, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + ctx := testCase.ctx + if ctx == nil { + ctx = context.Background() + } + responseTimeout := testCase.responseTimeout + if !testCase.needsFocus { + // A positive response timeout keeps the accepted timeout out of the focus handling. + responseTimeout = time.Second + } + var focusController *connectionRetryFocusController + if testCase.needsFocus { + focusController = nilProbeFocusController(t) + } + wantErr := testCase.wantErr + if wantErr == nil { + wantErr = testCase.current.err + } + + _, err := finishNonRetryableConnectionAttempt(ctx, unityipc.Connection{ProjectRoot: t.TempDir()}, testCase.current, testCase.last, responseTimeout, focusController) + + var notResponding clierrors.UnityServerNotRespondingError + if errors.As(err, ¬Responding) { + t.Fatalf("err = %v, want it left unwrapped", err) + } + if wantErr == nil { + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + return + } + if !errors.Is(err, wantErr) { + t.Fatalf("err = %v, want %v", err, wantErr) + } + }) + } +} + +// nilProbeFocusController builds a focus controller whose Unity process lookup finds nothing, so an +// attempt that enters the focus handling never focuses a real window. +func nilProbeFocusController(t *testing.T) *connectionRetryFocusController { + t.Helper() + deps := defaultConnectionRetryDeps() + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + return nil, nil + } + return newConnectionRetryFocusController(unityipc.Connection{ProjectRoot: t.TempDir()}, "get-logs", deps) +} + // Verifies the unity-alive retry reports the caller's cancellation when its retry context ends // because the caller cancelled, and Unity-not-responding otherwise. func TestFinishUnityAliveRetryWaitWhenRetryContextEnds(t *testing.T) { diff --git a/cli/project-runner/internal/projectrunner/connection_retry_test.go b/cli/project-runner/internal/projectrunner/connection_retry_test.go index 14d18a053b..f6d20556e7 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_test.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_test.go @@ -1000,6 +1000,13 @@ func TestSendWithTransientConnectionRetryKeepsUnityFocusedAfterPreAcceptTimeout( if err == nil { t.Fatal("expected pre-accept timeout") } + var notResponding clierrors.UnityServerNotRespondingError + if !errors.As(err, ¬Responding) { + t.Fatalf("err = %v, want UnityServerNotRespondingError wrapping the pre-accept timeout", err) + } + if !clierrors.IsFinalResponseTimeoutError(err) { + t.Fatalf("err = %v, want it to still read as a timeout", err) + } if focusCallCount != 1 { t.Fatalf("expected one focus attempt, got %d", focusCallCount) } @@ -1796,6 +1803,10 @@ func TestSendWithTransientConnectionRetrySurfacesAnUnansweredRequestAfterBusy(t if !clierrors.IsFinalResponseTimeoutError(err) { t.Fatalf("err = %v, want a response timeout", err) } + var notResponding clierrors.UnityServerNotRespondingError + if !errors.As(err, ¬Responding) { + t.Fatalf("err = %v, want UnityServerNotRespondingError wrapping the pre-accept timeout", err) + } if !outcome.RequestDispatched || outcome.RequestAccepted { t.Fatalf("outcome = %+v, want a dispatched request that was never accepted", outcome) } diff --git a/cli/project-runner/internal/projectrunner/plain_tool_log_test.go b/cli/project-runner/internal/projectrunner/plain_tool_log_test.go index 6424862e6c..a8fd86ef49 100644 --- a/cli/project-runner/internal/projectrunner/plain_tool_log_test.go +++ b/cli/project-runner/internal/projectrunner/plain_tool_log_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + clierrors "github.com/hatayama/unity-cli-loop/common/errors" "github.com/hatayama/unity-cli-loop/common/unityipc" "github.com/hatayama/unity-cli-loop/common/vibelog" ) @@ -313,3 +314,12 @@ func TestLogPlainToolRequestFailedNamesTheRunningToolOnlyForABusyAnswer(t *testi }) } } + +// Verifies a pre-accept timeout that the retry loop wrapped as UnityServerNotRespondingError is still +// logged as a final response timeout. +func TestClassifyPlainToolErrorSeesTheTimeoutInsideUnityNotResponding(t *testing.T) { + err := clierrors.UnityServerNotRespondingError{Cause: os.ErrDeadlineExceeded} + if got := classifyPlainToolError(err); got != "final_response_timeout" { + t.Fatalf("classifyPlainToolError = %q, want %q", got, "final_response_timeout") + } +} From e61f2854495cf9906d93f250ee3e6cf95c81a847 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 8 Oct 2026 23:14:28 +0900 Subject: [PATCH 21/29] feat: Hot reload writes vibe entries that split a cold run's analysis and shim-compile phases into worker start, request steps, shim references, publicized copies, and compiler time (#3250) --- .../HotReload/TransformWorkerClientTests.cs | 34 ++++++ .../HotReload/TransformWorkerDtoSyncTests.cs | 3 +- .../HotReload/TransformWorkerHostTests.cs | 57 +++++++++ .../TransformWorkerOutputReaderTests.cs | 28 +++++ .../TransformWorkerRequestTimingTests.cs | 108 ++++++++++++++++++ .../TransformWorkerRequestTimingTests.cs.meta | 11 ++ .../SharedRoslynCompilerWorkerHostProcess.cs | 26 ++++- .../HotReload/HotReloadGroupProcessor.cs | 5 + .../HotReload/HotReloadOrchestratorLog.cs | 26 +++++ .../HotReload/HotReloadShimFirstCompile.cs | 12 +- .../Patching/HotReloadShimCompiler.cs | 51 +++++++++ .../Patching/HotReloadShimReferenceBuilder.cs | 60 +++++++++- .../HotReload/Patching/ReferencePublicizer.cs | 11 ++ .../HotReload/Shared/HotReloadConstants.cs | 6 + .../HotReload/Shared/TransformWorkerDtos.cs | 16 +++ .../HotReload/Shared/TransformWorkerHost.cs | 108 +++++++++++++++--- .../Shared/TransformWorkerHostResult.cs | 24 +++- .../Shared/TransformWorkerRequestTiming.cs | 103 +++++++++++++++++ .../TransformWorkerRequestTiming.cs.meta | 11 ++ .../IntroducedTypePreparation.cs | 6 +- .../TransformWorker~/TransformWorker.cs | 6 +- .../TransformWorker~/WorkerGroupPipeline.cs | 17 ++- .../TransformWorker~/WorkerOutput.cs | 3 + .../TransformWorker~/WorkerRequestTimings.cs | 47 ++++++++ docs/vibe-logs.md | 15 +++ 25 files changed, 760 insertions(+), 34 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/TransformWorkerRequestTimingTests.cs create mode 100644 Assets/Tests/Editor/HotReload/TransformWorkerRequestTimingTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerRequestTiming.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerRequestTiming.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerRequestTimings.cs diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs index 17e2ce4d56..87686c7238 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs @@ -94,6 +94,40 @@ public async Task RunWorker_UnknownOperation_ReturnsFailure() Assert.That(result.ErrorMessage, Does.Contain("Unknown worker operation")); } + /// + /// What: the real worker reports how long each of its stages took: reading the input + /// first, the rest of the pipeline last, and parsing, reference loading and compilation + /// creation in that order between them, none negative. + /// + [Test] + public async Task BootstrapAndRun_OnE2EFixture_ReturnsTheWorkerStepsInOrder() + { + TransformWorkerClientResult result = await RunWorkerOnE2EFixtureAsync(); + Assert.That(result.Success, Is.True, result.ErrorMessage); + TransformWorkerTimingStepDto[] timings = result.Output.timings; + Assert.That(timings, Is.Not.Null, "timings"); + + int readInput = Array.FindIndex(timings, step => step.step == "read_input"); + int parseSources = Array.FindIndex(timings, step => step.step == "parse_sources"); + int collectReferences = Array.FindIndex(timings, step => step.step == "collect_references"); + int createCompilation = Array.FindIndex(timings, step => step.step == "create_compilation"); + int pipelineRemainder = Array.FindIndex(timings, step => step.step == "pipeline_remainder"); + // Why each presence first: a missing stage is -1 and would pass an order comparison. + Assert.That(readInput, Is.GreaterThanOrEqualTo(0), "read_input"); + Assert.That(parseSources, Is.GreaterThanOrEqualTo(0), "parse_sources"); + Assert.That(collectReferences, Is.GreaterThanOrEqualTo(0), "collect_references"); + Assert.That(createCompilation, Is.GreaterThanOrEqualTo(0), "create_compilation"); + Assert.That(pipelineRemainder, Is.GreaterThanOrEqualTo(0), "pipeline_remainder"); + Assert.That(readInput, Is.EqualTo(0), "read_input first"); + Assert.That(pipelineRemainder, Is.EqualTo(timings.Length - 1), "pipeline_remainder last"); + Assert.That(parseSources, Is.LessThan(collectReferences), "parse before references"); + Assert.That(collectReferences, Is.LessThan(createCompilation), "references before compilation"); + foreach (TransformWorkerTimingStepDto step in timings) + { + Assert.That(step.ms, Is.GreaterThanOrEqualTo(0), step.step); + } + } + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; private const int SelfSnapshotBatchSize = 16; private const string ExpectedListEnumeratorFullName = diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerDtoSyncTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerDtoSyncTests.cs index 29d301bd17..23f16f88c4 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerDtoSyncTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerDtoSyncTests.cs @@ -47,7 +47,8 @@ public sealed class TransformWorkerDtoSyncTests { typeof(TransformWorkerUnchangedMethodDto), "WorkerUnchangedMethod" }, { typeof(TransformWorkerEntryDto), "WorkerEntry" }, { typeof(TransformWorkerSkippedDto), "WorkerSkipped" }, - { typeof(TransformWorkerReasonDto), "WorkerReason" } + { typeof(TransformWorkerReasonDto), "WorkerReason" }, + { typeof(TransformWorkerTimingStepDto), "WorkerTimingStep" } }; /// diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerHostTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerHostTests.cs index 41ab740f7e..829041af0a 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerHostTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerHostTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -90,6 +91,51 @@ public async Task RunAsync_FirstProcessCrashes_RetriesOnceOnFreshProcess() Assert.That(_factory.Channels[0].HasExited, Is.True); } + /// + /// What: the request that starts the worker reports the start and its spawn step; the next + /// request on the same process reports reuse, with no spawn step but a response wait and an + /// output read. Each began one conversation. + /// + [Test] + public async Task RunAsync_FirstRequestStartsTheWorker_ReportsTheStartAndTheSecondReportsReuse() + { + _factory.Enqueue(ScriptStep.Succeed, ScriptStep.Succeed); + + TransformWorkerHostResult first = await _host.RunAsync(CreateInput(1), CancellationToken.None); + TransformWorkerHostResult second = await _host.RunAsync(CreateInput(1), CancellationToken.None); + + Assert.That(first.Kind, Is.EqualTo(TransformWorkerHostResultKind.Completed), first.ErrorMessage); + Assert.That(first.Timing.WorkerStarted, Is.True, "first WorkerStarted"); + Assert.That(first.Timing.Attempts, Is.EqualTo(1), "first Attempts"); + Assert.That(StepNames(first), Does.Contain(TransformWorkerRequestTiming.ProcessStartStep), "first steps"); + Assert.That(StepNames(first), Does.Contain(TransformWorkerRequestTiming.ResponseWaitStep), "first steps"); + Assert.That(StepNames(first), Does.Contain(TransformWorkerRequestTiming.ReadOutputStep), "first steps"); + Assert.That(second.Kind, Is.EqualTo(TransformWorkerHostResultKind.Completed), second.ErrorMessage); + Assert.That(second.Timing.WorkerStarted, Is.False, "second WorkerStarted"); + Assert.That(second.Timing.Attempts, Is.EqualTo(1), "second Attempts"); + Assert.That(StepNames(second), Does.Not.Contain(TransformWorkerRequestTiming.ProcessStartStep), "second steps"); + Assert.That(StepNames(second), Does.Contain(TransformWorkerRequestTiming.ResponseWaitStep), "second steps"); + Assert.That(StepNames(second), Does.Contain(TransformWorkerRequestTiming.ReadOutputStep), "second steps"); + } + + /// + /// What: a request whose first process dies without answering reports two conversations and + /// that it started a worker, the fresh one that answered the retry. + /// + [Test] + public async Task RunAsync_FirstConversationBreaks_ReportsTwoAttemptsAndAStart() + { + _factory.Enqueue(ScriptStep.Crash); + _factory.Enqueue(ScriptStep.Succeed); + + TransformWorkerHostResult result = await _host.RunAsync(CreateInput(1), CancellationToken.None); + + Assert.That(result.Kind, Is.EqualTo(TransformWorkerHostResultKind.Completed), result.ErrorMessage); + Assert.That(result.Timing.Attempts, Is.EqualTo(2), "Attempts"); + Assert.That(result.Timing.WorkerStarted, Is.True, "WorkerStarted"); + Assert.That(_host.LaunchCount, Is.EqualTo(2), "LaunchCount"); + } + /// /// What: two consecutive broken conversations end the request as RetryExhausted; no third /// process is started and the failure names the last reason. @@ -306,6 +352,7 @@ public async Task RunAsync_WorkerDirectoryChanges_RestartsProcess() Assert.That(_factory.Channels[0].HasExited, Is.True); Assert.That(_factory.Channels[0].QuitRequested, Is.True, "The old process must be asked to quit gracefully first."); Assert.That(_factory.Channels[1].WorkerDirectory, Is.EqualTo("/worker/b")); + Assert.That(second.Timing.WorkerStarted, Is.True, "A restart registers a new process."); } /// @@ -356,6 +403,7 @@ public async Task RunAsync_BootstrapFails_ReturnsBootstrapFailedWithoutLaunch() Assert.That(result.Kind, Is.EqualTo(TransformWorkerHostResultKind.BootstrapFailed)); Assert.That(result.ErrorMessage, Does.Contain("bootstrap failed for test")); Assert.That(_host.LaunchCount, Is.EqualTo(0)); + Assert.That(result.Timing.Attempts, Is.EqualTo(0), "Attempts"); } /// @@ -372,6 +420,8 @@ public async Task RunAsync_ProcessStartFailsTwice_ReportsRetryExhausted() Assert.That(result.Kind, Is.EqualTo(TransformWorkerHostResultKind.RetryExhausted)); Assert.That(result.ErrorMessage, Does.Contain("could not be started")); Assert.That(_host.LaunchCount, Is.EqualTo(0)); + Assert.That(result.Timing.Attempts, Is.EqualTo(2), "Attempts"); + Assert.That(result.Timing.WorkerStarted, Is.False, "WorkerStarted"); } /// @@ -388,6 +438,7 @@ public async Task Shutdown_DuringProcessStart_DiscardsNewProcessAndClosesRequest Assert.That(result.Kind, Is.EqualTo(TransformWorkerHostResultKind.LifecycleClosed), result.ErrorMessage); Assert.That(_host.LaunchCount, Is.EqualTo(0)); + Assert.That(result.Timing.WorkerStarted, Is.False, "A discarded process was never registered."); Assert.That(_host.CurrentProcessId, Is.Null); Assert.That(_factory.Channels.Count, Is.EqualTo(1)); Assert.That(_factory.Channels[0].HasExited, Is.True); @@ -416,6 +467,7 @@ await _factory.Channels[0].WaitForRequestAsync(WaitMilliseconds), Assert.That(firstResult.Kind, Is.EqualTo(TransformWorkerHostResultKind.TimedOut), firstResult.ErrorMessage); Assert.That(queuedResult.Kind, Is.EqualTo(TransformWorkerHostResultKind.TimedOut), queuedResult.ErrorMessage); Assert.That(queuedResult.ErrorMessage, Does.Contain("before attempt 1")); + Assert.That(queuedResult.Timing.Attempts, Is.EqualTo(0), "Attempts"); Assert.That(_host.LaunchCount, Is.EqualTo(1)); Assert.That(_factory.Channels.Count, Is.EqualTo(1)); } @@ -490,6 +542,11 @@ private static TransformWorkerInputDto CreateInput(int sourceCount) return new TransformWorkerInputDto { sources = sources }; } + + private static string[] StepNames(TransformWorkerHostResult result) + { + return result.Timing.Steps.Select(step => step.Key).ToArray(); + } } /// diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerOutputReaderTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerOutputReaderTests.cs index 1282ef01b9..b7023eb187 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerOutputReaderTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerOutputReaderTests.cs @@ -45,5 +45,33 @@ public void TryRead_PathIsDirectory_ReturnsNullWithReadFailureReason() Assert.That(output, Is.Null); Assert.That(error, Does.Contain("could not be read")); } + + /// + /// What: the worker's per-step timings are read in the order the worker wrote them. + /// + [Test] + public void TryDeserialize_WithTimings_ReadsTheStepsInOrder() + { + string json = "{\"files\":[],\"timings\":[{\"step\":\"read_input\",\"ms\":3},{\"step\":\"parse_sources\",\"ms\":12}]}"; + + TransformWorkerOutputDto output = TransformWorkerOutputReader.TryDeserialize(json, out string error); + + Assert.That(error, Is.Null, "error"); + Assert.That(output.timings.Length, Is.EqualTo(2), "count"); + Assert.That(output.timings[0].step, Is.EqualTo("read_input"), "first step"); + Assert.That(output.timings[1].ms, Is.EqualTo(12), "second ms"); + } + + /// + /// What: output from a worker that writes no timings, such as an older build, leaves them null. + /// + [Test] + public void TryDeserialize_WithoutTimings_LeavesTimingsNull() + { + TransformWorkerOutputDto output = TransformWorkerOutputReader.TryDeserialize("{\"files\":[]}", out string error); + + Assert.That(error, Is.Null, "error"); + Assert.That(output.timings, Is.Null, "timings"); + } } } diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerRequestTimingTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerRequestTimingTests.cs new file mode 100644 index 0000000000..d67136a55d --- /dev/null +++ b/Assets/Tests/Editor/HotReload/TransformWorkerRequestTimingTests.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Tests the accumulator that records the steps of one request to the transform worker. + /// + public sealed class TransformWorkerRequestTimingTests + { + /// + /// What: a step added twice, such as the two reads of one response, is summed into the + /// entry where it was first seen, and the other steps keep their order. + /// + [Test] + public void AddStep_SameStepTwice_SumsAndKeepsFirstSeenOrder() + { + TransformWorkerRequestTiming timing = new TransformWorkerRequestTiming(); + + timing.AddStep("a", 5); + timing.AddStep("b", 7); + timing.AddStep("a", 3); + + Assert.That(timing.Steps, Is.EqualTo(new[] + { + new KeyValuePair("a", 8), + new KeyValuePair("b", 7) + })); + } + + /// + /// What: a step without a name is refused, since the vibe entry could not tell it apart. + /// + [Test] + public void AddStep_EmptyStep_Throws() + { + TransformWorkerRequestTiming timing = new TransformWorkerRequestTiming(); + + Assert.Throws(() => timing.AddStep(string.Empty, 1)); + } + + /// + /// What: a negative span is refused, since elapsed time never is. + /// + [Test] + public void AddStep_NegativeMilliseconds_Throws() + { + TransformWorkerRequestTiming timing = new TransformWorkerRequestTiming(); + + Assert.Throws(() => timing.AddStep("a", -1)); + } + + /// + /// What: a measured scope disposed twice records its span once; the second dispose adds + /// nothing to the step. + /// + [Test] + public void Measure_DisposedTwice_AddsTheStepOnce() + { + TransformWorkerRequestTiming timing = new TransformWorkerRequestTiming(); + + IDisposable scope = timing.Measure(TransformWorkerRequestTiming.ReadOutputStep); + // Why sleep: a span of 0 ms added twice would still read 0, hiding a double count. + Thread.Sleep(2); + scope.Dispose(); + long afterFirstDispose = timing.Steps[0].Value; + scope.Dispose(); + + Assert.That(afterFirstDispose, Is.GreaterThanOrEqualTo(1), "first span"); + Assert.That(timing.Steps.Count, Is.EqualTo(1), "step count"); + Assert.That(timing.Steps[0].Key, Is.EqualTo(TransformWorkerRequestTiming.ReadOutputStep), "step"); + Assert.That(timing.Steps[0].Value, Is.EqualTo(afterFirstDispose), "ms after second dispose"); + } + + /// + /// What: each conversation a request begins is counted, so a retried request reports two. + /// + [Test] + public void MarkAttempt_CalledTwice_ReportsTwoAttempts() + { + TransformWorkerRequestTiming timing = new TransformWorkerRequestTiming(); + + timing.MarkAttempt(); + timing.MarkAttempt(); + + Assert.That(timing.Attempts, Is.EqualTo(2)); + } + + /// + /// What: a request starts out as not having started the worker, and marking it flips that. + /// + [Test] + public void MarkWorkerStarted_SetsWorkerStarted() + { + TransformWorkerRequestTiming timing = new TransformWorkerRequestTiming(); + Assert.That(timing.WorkerStarted, Is.False, "initial"); + + timing.MarkWorkerStarted(); + + Assert.That(timing.WorkerStarted, Is.True, "after mark"); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerRequestTimingTests.cs.meta b/Assets/Tests/Editor/HotReload/TransformWorkerRequestTimingTests.cs.meta new file mode 100644 index 0000000000..06d540d0a9 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/TransformWorkerRequestTimingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8a1d266e80c0e4df287dafde302501dc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs index a5df487c66..7ee7ebbf13 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs @@ -17,6 +17,7 @@ internal static class SharedRoslynCompilerWorkerHostProcess private const string RoslynWorkerSourceFileName = "RoslynCompilerWorker.cs"; private const string RoslynWorkerAssemblyFileName = "RoslynCompilerWorker.dll"; private const string RoslynWorkerCompileResponseFileName = "RoslynCompilerWorker.rsp"; + private const string VibeLogSharedWorkerStarted = "dynamic_code_shared_worker_started"; /// /// Provides Worker Paths behavior for Unity CLI Loop. @@ -132,23 +133,46 @@ internal static async Task EnsureWorkerReadyAsync( return earlyResult; } + Stopwatch watch = Stopwatch.StartNew(); WorkerPaths workerPaths = CreateWorkerPaths(session); SynchronizeWorkerSource(workerPaths); + long sourceSyncMs = watch.ElapsedMilliseconds; + watch.Restart(); WorkerStartupResult workerAssemblyResult = await EnsureWorkerAssemblyBuiltAsync( session, externalCompilerPaths, workerPaths).ConfigureAwait(false); + long assemblyEnsureMs = watch.ElapsedMilliseconds; if (!workerAssemblyResult.IsReady) { + LogWorkerStarted(sourceSyncMs, assemblyEnsureMs, 0, workerAssemblyResult); return workerAssemblyResult; } - return StartWorkerProcess( + watch.Restart(); + WorkerStartupResult startResult = StartWorkerProcess( session, externalCompilerPaths, workerPaths, lifecycleGenerationAtStart); + // Only the spawn: the worker's start-up until it answers lands in the first compile. + LogWorkerStarted(sourceSyncMs, assemblyEnsureMs, watch.ElapsedMilliseconds, startResult); + return startResult; + } + + // Written only when no live process could serve the request, which after a domain reload + // is the first compile, so the cold cost of the shared worker shows apart from compiles. + private static void LogWorkerStarted( + long sourceSyncMs, + long assemblyEnsureMs, + long processStartMs, + WorkerStartupResult result) + { + VibeLogger.LogInfo( + VibeLogSharedWorkerStarted, + "Shared Roslyn compiler worker started.", + new { sourceSyncMs, assemblyEnsureMs, processStartMs, ready = result.IsReady }); } internal static async Task EnsureWorkerAssemblyBuiltAsync( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs index 6919bd10bf..c2ba888cbb 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs @@ -461,14 +461,19 @@ internal static async Task GateAndCompileAsy Debug.Assert(collaborators != null, "collaborators must not be null."); // The worker-bound continuation after the pre-revert check is not guaranteed to // resume on Unity's context, while the signature gate reads compilation state. + Stopwatch switchWatch = Stopwatch.StartNew(); await MainThreadSwitcher.SwitchToMainThread(ct); + long mainThreadSwitchMs = switchWatch.ElapsedMilliseconds; ct.ThrowIfCancellationRequested(); IReadOnlyList files = context.Files; HotReloadGroupFile gateWarningSink = files[0]; + Stopwatch gateWatch = Stopwatch.StartNew(); HotReloadSignatureChangeGate.SignatureChangeGateResult gateResult = await HotReloadSignatureChangeGate.TryApplySignatureChangeGateAsync( collaborators, context, ct).ConfigureAwait(false); + HotReloadOrchestratorLog.LogHotReloadSignatureGateTiming( + mainThreadSwitchMs, gateWatch.ElapsedMilliseconds, gateResult.UsedWorkerRetry, context.CorrelationId); HotReloadWorkerNoticeAppender.AppendRetrySiblingConstDriftWarnings( gateWarningSink.Sinks.SiblingDerivedWarnings, gateResult.Isolation); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestratorLog.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestratorLog.cs index 0d59d8a041..25ec908d73 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestratorLog.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestratorLog.cs @@ -45,6 +45,32 @@ internal static void LogHotReloadWorkerResult( correlationId); } + internal static void LogHotReloadSignatureGateTiming( + long mainThreadSwitchMs, + long signatureGateMs, + bool usedWorkerRetry, + string correlationId) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSignatureGateTiming, + "Hot reload signature gate timing.", + new { mainThreadSwitchMs, signatureGateMs, usedWorkerRetry }, + correlationId); + } + + internal static void LogHotReloadShimFirstCompileTiming( + long mainThreadSwitchMs, + long membershipMs, + long compileShimMs, + string correlationId) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogShimFirstCompileTiming, + "Hot reload shim first compile timing.", + new { mainThreadSwitchMs, membershipMs, compileShimMs }, + correlationId); + } + internal static void LogHotReloadShimCompileFailed( HotReloadShimCompileResult compileResult, string stage, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadShimFirstCompile.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadShimFirstCompile.cs index 5354bac353..1bccbd6bbc 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadShimFirstCompile.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadShimFirstCompile.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using UnityEngine; +using Stopwatch = System.Diagnostics.Stopwatch; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -27,12 +28,17 @@ internal static async Task ResolveEntriesToPatchAsy Debug.Assert(context != null, "context must not be null."); Debug.Assert(gateResult != null, "gateResult must not be null."); + Stopwatch watch = Stopwatch.StartNew(); await MainThreadSwitcher.SwitchToMainThread(ct); + long mainThreadSwitchMs = watch.ElapsedMilliseconds; + watch.Restart(); if (!HotReloadGroupProcessor.TryAppendNewSourceMembershipFailure(collaborators, context.Files)) { return HotReloadGroupCompileResult.Failed(); } + long membershipMs = watch.ElapsedMilliseconds; + if (gateResult.UsedWorkerRetry) { AdoptRetryAddedMemberNames(context.Files, gateResult.Isolation.RetryFiles); @@ -66,7 +72,11 @@ internal static async Task ResolveEntriesToPatchAsy return HotReloadGroupCompileResult.ReadyWithoutMethods(); } - return await CompileShimForGroupAsync(collaborators, context, ct).ConfigureAwait(false); + watch.Restart(); + HotReloadGroupCompileResult compiled = await CompileShimForGroupAsync(collaborators, context, ct).ConfigureAwait(false); + HotReloadOrchestratorLog.LogHotReloadShimFirstCompileTiming( + mainThreadSwitchMs, membershipMs, watch.ElapsedMilliseconds, context.CorrelationId); + return compiled; } /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimCompiler.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimCompiler.cs index 3de2b82c8d..ac9da6a055 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimCompiler.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimCompiler.cs @@ -11,6 +11,7 @@ using io.github.hatayama.UnityCliLoop.ToolContracts; using Assembly = System.Reflection.Assembly; +using Stopwatch = System.Diagnostics.Stopwatch; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { @@ -39,8 +40,41 @@ public static async Task CompileAndLoadAsync( Debug.Assert(projectRelativePaths != null, "projectRelativePaths must not be null."); // Application.dataPath (ResolveWorkDirectory) needs the Unity main thread. + Stopwatch switchWatch = Stopwatch.StartNew(); await MainThreadSwitcher.SwitchToMainThread(ct); + long mainThreadSwitchMs = switchWatch.ElapsedMilliseconds; + + ShimCompileTiming timing = new ShimCompileTiming(); + HotReloadShimCompileResult result = await CompileAndLoadOnMainThreadAsync( + shimSource, + referencePaths, + defineSymbols, + projectRelativePaths, + timing, + ct).ConfigureAwait(false); + VibeLogger.LogInfo( + HotReloadConstants.VibeLogShimCompilerTiming, + "Hot reload shim compiler timing.", + new + { + mainThreadSwitchMs, + compileMs = timing.CompileMs, + loadMs = timing.LoadMs, + backendKind = timing.BackendKind, + success = result.Success + }); + return result; + } + // The compile and load proper, once on the main thread. Fills timing as it goes. + private static async Task CompileAndLoadOnMainThreadAsync( + string shimSource, + IReadOnlyList referencePaths, + IReadOnlyList defineSymbols, + IReadOnlyCollection projectRelativePaths, + ShimCompileTiming timing, + CancellationToken ct) + { string workDirectory = ResolveWorkDirectory(); try { @@ -60,9 +94,14 @@ public static async Task CompileAndLoadAsync( // Why constructed here rather than injected: this class is still static, so it has // no constructor to receive them. Move both to constructor injection when it // becomes an instance. + Stopwatch compileWatch = Stopwatch.StartNew(); HotReloadRoslynCompileOutcome outcome = await new HotReloadRoslynCompiler( new HotReloadRoslynCompilerEnvironment()).CompileAsync(request, ct) .ConfigureAwait(false); + timing.CompileMs = compileWatch.ElapsedMilliseconds; + timing.BackendKind = outcome.PathsResolved + ? outcome.BackendResult.BackendKind.ToString() + : "Unresolved"; if (!outcome.PathsResolved) { return HotReloadShimCompileResult.Failure( @@ -89,9 +128,11 @@ public static async Task CompileAndLoadAsync( return HotReloadShimCompileResult.Failure("Shim dll was not produced: " + dllPath); } + Stopwatch loadWatch = Stopwatch.StartNew(); byte[] assemblyBytes = File.ReadAllBytes(dllPath); byte[] pdbBytes = File.Exists(pdbPath) ? File.ReadAllBytes(pdbPath) : null; CompiledAssemblyLoadResult loadResult = CompiledAssemblyLoader.Load(assemblyBytes, pdbBytes); + timing.LoadMs = loadWatch.ElapsedMilliseconds; if (!loadResult.Success || loadResult.CompiledAssembly == null) { return HotReloadShimCompileResult.Failure( @@ -112,6 +153,16 @@ public static async Task CompileAndLoadAsync( } } + /// + /// Where one shim compile spent its time and which compiler backend served it. + /// + private sealed class ShimCompileTiming + { + public long CompileMs { get; set; } + public long LoadMs { get; set; } + public string BackendKind { get; set; } = string.Empty; + } + // Only these diagnostics indicate a member/type the compiled assembly does not have yet; // appending the "run a real compile" hint to unrelated errors (CS0229 ambiguity, syntax // errors) misdirects the caller. CS1501/CS7036 cover calls whose target signature changed diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs index 2d9548abc4..d1d923b691 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs @@ -13,6 +13,8 @@ using io.github.hatayama.UnityCliLoop.ToolContracts; +using Stopwatch = System.Diagnostics.Stopwatch; + using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -431,14 +433,18 @@ private static List BuildShimReferencePaths( // Derive Cecil search dirs from Unity's actual compile references so publicize // resolves netstandard/engine modules without hardcoding Editor Contents layouts. // Referenced assemblies count transitively: Cecil may need a DLL only they list. + Stopwatch watch = Stopwatch.StartNew(); IReadOnlyCollection resolverSearchDirectories = HotReloadResolverSearchDirectories.Collect(projectRoot, compilationAssembly); + ShimReferencesTiming timing = new ShimReferencesTiming { ResolverDirectoriesMs = watch.ElapsedMilliseconds }; + watch.Restart(); List references = new List(); string publicizedTarget = ReferencePublicizer.GetOrCreatePublicizedCopy( targetHome, resolverSearchDirectories); references.Add(publicizedTarget); + timing.TargetCopyMs = watch.ElapsedMilliseconds; AppendOptionalShimAssemblyReferences( references, @@ -446,16 +452,21 @@ private static List BuildShimReferencePaths( includeAddedFieldStoreReference); // Why here: the shim binds against the retained types the worker bound against, and a // shim that cannot see them fails to compile the bodies that use them. + watch.Restart(); AppendPublicizedIntroducedTypeArtifactReferences( references, introducedTypeArtifactHomes, resolverSearchDirectories); + timing.ArtifactCopiesMs = watch.ElapsedMilliseconds; if (compilationAssembly.allReferences == null) { + LogReferencesTiming(timing); return references; } + watch.Restart(); + string fullTarget = Path.GetFullPath(targetHome.DllPath); // The name another assembly's InternalsVisibleTo grant names the edited assembly by. string shimTargetAssemblyName = Path.GetFileNameWithoutExtension(fullTarget); @@ -478,17 +489,54 @@ private static List BuildShimReferencePaths( continue; } - references.Add( - PublicizeProjectReference( - fullReference, - scriptAssembliesDirectory, - resolverSearchDirectories, - shimTargetAssemblyName)); + string shimReference = PublicizeProjectReference( + fullReference, + scriptAssembliesDirectory, + resolverSearchDirectories, + shimTargetAssemblyName); + references.Add(shimReference); + timing.ReferenceCount++; + if (!string.Equals(shimReference, fullReference, StringComparison.Ordinal)) + { + timing.CopiedReferenceCount++; + } } + timing.ReferenceCopiesMs = watch.ElapsedMilliseconds; + LogReferencesTiming(timing); return references; } + private static void LogReferencesTiming(ShimReferencesTiming timing) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogShimReferencesTiming, + "Hot reload shim reference paths timing.", + new + { + resolverDirectoriesMs = timing.ResolverDirectoriesMs, + targetCopyMs = timing.TargetCopyMs, + artifactCopiesMs = timing.ArtifactCopiesMs, + referenceCopiesMs = timing.ReferenceCopiesMs, + referenceCount = timing.ReferenceCount, + copiedReferenceCount = timing.CopiedReferenceCount + }); + } + + /// + /// Where one build of the shim reference paths spent its time, and how many project + /// references it handed to the publicizer and got a copy back for. + /// + private sealed class ShimReferencesTiming + { + public long ResolverDirectoriesMs; + public long TargetCopyMs; + public long ArtifactCopiesMs; + public long ReferenceCopiesMs; + public int ReferenceCount; + public int CopiedReferenceCount; + } + /// /// Returns the reference a shim compile binds against for one of Unity's compile /// references: a publicized copy when the reference is a project assembly the shim may diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs index dd3c2fd5d1..fc1a0fc5be 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs @@ -6,9 +6,12 @@ using UnityEngine; +using io.github.hatayama.UnityCliLoop.ToolContracts; + using CecilFieldAttributes = Mono.Cecil.FieldAttributes; using CecilMethodAttributes = Mono.Cecil.MethodAttributes; using CecilTypeAttributes = Mono.Cecil.TypeAttributes; +using Stopwatch = System.Diagnostics.Stopwatch; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { @@ -195,6 +198,7 @@ private static string WriteOrReuseRewrittenCopy( // need the previous publicized copy, so drop stale siblings before writing the new one. DeleteStaleCopies(outputDirectory, assemblyName, outputDllPath); + Stopwatch watch = Stopwatch.StartNew(); foreach (ModuleDefinition module in assemblyDefinition.Modules) { foreach (TypeDefinition type in module.GetTypes()) @@ -209,6 +213,8 @@ private static string WriteOrReuseRewrittenCopy( } } + long rewriteMs = watch.ElapsedMilliseconds; + watch.Restart(); // Write to a temp path then Move so a thrown Write cannot leave a 0-byte final cache. string tempDllPath = outputDllPath + ".tmp-" + Guid.NewGuid().ToString("N"); try @@ -224,6 +230,11 @@ private static string WriteOrReuseRewrittenCopy( } } + // Only on a miss: a copy written is the cold cost of a rebuilt assembly. + VibeLogger.LogInfo( + HotReloadConstants.VibeLogPublicizedCopyWritten, + "Hot reload wrote a rewritten reference copy.", + new { assemblyName, variant = outputRelativeDirectory, rewriteMs, writeMs = watch.ElapsedMilliseconds }); return outputDllPath; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 376158527e..f82f4724a8 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -661,6 +661,12 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string VibeLogWorkerHostFallbackOneShot = "hot_reload_worker_fallback_one_shot"; public const string VibeLogFileStart = "hot_reload_file_start"; public const string VibeLogWorkerResult = "hot_reload_worker_result"; + public const string VibeLogWorkerRequestTiming = "hot_reload_worker_request_timing"; + public const string VibeLogSignatureGateTiming = "hot_reload_signature_gate_timing"; + public const string VibeLogShimFirstCompileTiming = "hot_reload_shim_first_compile_timing"; + public const string VibeLogShimReferencesTiming = "hot_reload_shim_references_timing"; + public const string VibeLogPublicizedCopyWritten = "hot_reload_publicized_copy_written"; + public const string VibeLogShimCompilerTiming = "hot_reload_shim_compiler_timing"; public const string VibeLogShimCompileFailed = "hot_reload_shim_compile_failed"; public const string VibeLogIsolationRetry = "hot_reload_isolation_retry"; public const string VibeLogEmptyEntriesClear = "hot_reload_empty_entries_clear"; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerDtos.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerDtos.cs index 94b4fcd59c..9839f54c9c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerDtos.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerDtos.cs @@ -300,6 +300,22 @@ internal sealed class TransformWorkerOutputDto // True when any emitted shim body rewrites an added-field access to HotReloadAddedFieldStore. // Drives ToolContracts assembly injection at both the first compile and isolation retry. public bool hasAddedFieldRewrites; + + // Milliseconds of each stage inside the worker, in the order the worker ran them. Only the + // request timing vibe entry reads them. Output of an older worker has none, so null. + public TransformWorkerTimingStepDto[] timings; + } + + /// + /// One stage inside the worker and the milliseconds it took. + /// + [Serializable] + internal sealed class TransformWorkerTimingStepDto + { + public string step; + + // Why int and not long: the worker writes an int, and the DTO sync test maps int only. + public int ms; } [Serializable] diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerHost.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerHost.cs index add26e4529..90c28a08b7 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerHost.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerHost.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading; @@ -111,20 +112,31 @@ public async Task RunAsync(TransformWorkerInputDto in // part of this request's latency, so a queued request must not be granted a fresh // response budget once it finally reaches the worker. Stopwatch deadline = Stopwatch.StartNew(); + TransformWorkerRequestTiming timing = new TransformWorkerRequestTiming(); // Why WaitAsync before the try: a cancel while queued must not release a gate this // request never acquired. - await _conversationGate.WaitAsync(ct).ConfigureAwait(false); + using (timing.Measure(TransformWorkerRequestTiming.GateWaitStep)) + { + await _conversationGate.WaitAsync(ct).ConfigureAwait(false); + } + try { int generation = ReadGeneration(); - TransformWorkerLaunchTarget target = await _resolveLaunchTarget(ct).ConfigureAwait(false); - if (!target.Success) + TransformWorkerLaunchTarget target; + using (timing.Measure(TransformWorkerRequestTiming.ResolveLaunchTargetStep)) { - return TransformWorkerHostResult.Failure(TransformWorkerHostResultKind.BootstrapFailed, target.ErrorMessage); + target = await _resolveLaunchTarget(ct).ConfigureAwait(false); } - return await RunConversationsAsync(input, target, generation, deadline, ct).ConfigureAwait(false); + TransformWorkerHostResult result = target.Success + ? await RunConversationsAsync(input, target, generation, deadline, timing, ct).ConfigureAwait(false) + : TransformWorkerHostResult.Failure(TransformWorkerHostResultKind.BootstrapFailed, target.ErrorMessage); + LogRequestTiming(input, result, timing, deadline.ElapsedMilliseconds); + // Why one exit: results are built in many places, and attaching here gives every + // path a timing without touching each of them. + return result.WithTiming(timing); } finally { @@ -169,11 +181,17 @@ private async Task RunConversationsAsync( TransformWorkerLaunchTarget target, int generation, Stopwatch deadline, + TransformWorkerRequestTiming timing, CancellationToken ct) { using (TransformWorkerRequestFiles requestFiles = new TransformWorkerRequestFiles()) { - string inputJsonPath = requestFiles.WriteInputJson(input); + string inputJsonPath; + using (timing.Measure(TransformWorkerRequestTiming.WriteInputStep)) + { + inputJsonPath = requestFiles.WriteInputJson(input); + } + string outputJsonPath = requestFiles.OutputJsonPath; string requestLine = TransformWorkerServeProtocol.EncodeRequestLine(inputJsonPath, outputJsonPath); @@ -195,8 +213,10 @@ private async Task RunConversationsAsync( + " ms before attempt " + attempt + " could start."); } + // Counted only here: a request that gave up before touching a process began none. + timing.MarkAttempt(); TransformWorkerConversationOutcome outcome = await RunOneConversationAsync( - target, requestLine, outputJsonPath, input.sources.Length, generation, deadline, ct).ConfigureAwait(false); + target, requestLine, outputJsonPath, input.sources.Length, generation, deadline, timing, ct).ConfigureAwait(false); if (outcome.Result != null) { return outcome.Result; @@ -230,9 +250,10 @@ private async Task RunOneConversationAsync( int expectedFileCount, int generation, Stopwatch deadline, + TransformWorkerRequestTiming timing, CancellationToken ct) { - ITransformWorkerChannel channel = EnsureChannel(target, generation, out string startFailure, out bool lifecycleClosed); + ITransformWorkerChannel channel = EnsureChannel(target, generation, timing, out string startFailure, out bool lifecycleClosed); if (channel == null) { return lifecycleClosed @@ -251,10 +272,15 @@ private async Task RunOneConversationAsync( try { - channel.RequestWriter.WriteLine(requestLine); - channel.RequestWriter.Flush(); + string header; + using (timing.Measure(TransformWorkerRequestTiming.ResponseWaitStep)) + { + channel.RequestWriter.WriteLine(requestLine); + channel.RequestWriter.Flush(); + header = await ReadWithinDeadlineAsync(channel, deadline, ct).ConfigureAwait(false); + } - string header = await ReadWithinDeadlineAsync(channel, deadline, ct).ConfigureAwait(false); + // Why outside the measured scope: killing a hung process is not time spent waiting. if (header == null) { return await HandleMissingLineAsync(channel, deadline, "response header").ConfigureAwait(false); @@ -266,7 +292,12 @@ private async Task RunOneConversationAsync( return TransformWorkerConversationOutcome.Broken("unrecognized response header: " + header); } - string diagnosticsLine = await ReadWithinDeadlineAsync(channel, deadline, ct).ConfigureAwait(false); + string diagnosticsLine; + using (timing.Measure(TransformWorkerRequestTiming.ResponseWaitStep)) + { + diagnosticsLine = await ReadWithinDeadlineAsync(channel, deadline, ct).ConfigureAwait(false); + } + if (diagnosticsLine == null) { return await HandleMissingLineAsync(channel, deadline, "diagnostics line").ConfigureAwait(false); @@ -285,7 +316,7 @@ private async Task RunOneConversationAsync( return TransformWorkerConversationOutcome.Final(LifecycleClosed("after receiving the response")); } - return InterpretResponse(channel, exitCode, diagnostics, outputJsonPath, expectedFileCount); + return InterpretResponse(channel, exitCode, diagnostics, outputJsonPath, expectedFileCount, timing); } catch (IOException ex) { @@ -311,7 +342,8 @@ private TransformWorkerConversationOutcome InterpretResponse( int exitCode, string diagnostics, string outputJsonPath, - int expectedFileCount) + int expectedFileCount, + TransformWorkerRequestTiming timing) { if (exitCode != 0) { @@ -323,7 +355,13 @@ private TransformWorkerConversationOutcome InterpretResponse( + "\nstderr:\n" + channel.ReadStandardErrorTail())); } - TransformWorkerOutputDto output = TransformWorkerOutputReader.TryRead(outputJsonPath, out string readError); + TransformWorkerOutputDto output; + string readError; + using (timing.Measure(TransformWorkerRequestTiming.ReadOutputStep)) + { + output = TransformWorkerOutputReader.TryRead(outputJsonPath, out readError); + } + if (output == null) { // Why broken and not WorkerFailed: exit 0 without a usable output file means the @@ -400,6 +438,7 @@ private int RemainingMilliseconds(Stopwatch deadline) private ITransformWorkerChannel EnsureChannel( TransformWorkerLaunchTarget target, int generation, + TransformWorkerRequestTiming timing, out string startFailure, out bool lifecycleClosed) { @@ -424,7 +463,13 @@ private ITransformWorkerChannel EnsureChannel( _channelTermination.Terminate(stale); } - ITransformWorkerChannel started = _channelFactory(target.WorkerDirectory, target.DotnetHostPath); + ITransformWorkerChannel started; + // Only the spawn: the worker's start-up until it reads the request is in the response wait. + using (timing.Measure(TransformWorkerRequestTiming.ProcessStartStep)) + { + started = _channelFactory(target.WorkerDirectory, target.DotnetHostPath); + } + if (started == null) { startFailure = "worker process could not be started from " + target.WorkerDirectory; @@ -456,6 +501,7 @@ private ITransformWorkerChannel EnsureChannel( return null; } + timing.MarkWorkerStarted(); VibeLogger.LogInfo( restarted ? HotReloadConstants.VibeLogWorkerHostRestarted : HotReloadConstants.VibeLogWorkerHostStarted, restarted ? "Resident transform worker restarted." : "Resident transform worker started.", @@ -463,6 +509,36 @@ private ITransformWorkerChannel EnsureChannel( return started; } + private void LogRequestTiming( + TransformWorkerInputDto input, + TransformWorkerHostResult result, + TransformWorkerRequestTiming timing, + long totalMs) + { + List steps = new List(timing.Steps.Count); + foreach (KeyValuePair step in timing.Steps) + { + steps.Add(new { step = step.Key, ms = step.Value }); + } + + // A failed request carries no output, and an older worker writes no timings. + TransformWorkerTimingStepDto[] workerSteps = result.Output?.timings ?? Array.Empty(); + VibeLogger.LogInfo( + HotReloadConstants.VibeLogWorkerRequestTiming, + "Resident transform worker request timing.", + new + { + operation = string.IsNullOrEmpty(input.operation) ? "transform" : input.operation, + kind = result.Kind.ToString(), + workerStarted = timing.WorkerStarted, + attempts = timing.Attempts, + launchCount = LaunchCount, + totalMs, + steps = steps.ToArray(), + workerSteps + }); + } + // Removes and terminates the channel only if it is still the current one; a concurrent // Shutdown that already took it owns its termination. private void DiscardChannel(ITransformWorkerChannel channel) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerHostResult.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerHostResult.cs index b4bc584dc5..59fb2e9ad4 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerHostResult.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerHostResult.cs @@ -43,24 +43,42 @@ internal sealed class TransformWorkerHostResult public TransformWorkerOutputDto Output { get; } public string ErrorMessage { get; } - private TransformWorkerHostResult(TransformWorkerHostResultKind kind, TransformWorkerOutputDto output, string errorMessage) + /// + /// Where the request spent its time. Set on every result the host returns; null only on a + /// result built outside the host. + /// + public TransformWorkerRequestTiming Timing { get; } + + private TransformWorkerHostResult( + TransformWorkerHostResultKind kind, + TransformWorkerOutputDto output, + string errorMessage, + TransformWorkerRequestTiming timing) { Kind = kind; Output = output; ErrorMessage = errorMessage; + Timing = timing; } public static TransformWorkerHostResult Completed(TransformWorkerOutputDto output) { Debug.Assert(output != null, "output must not be null."); - return new TransformWorkerHostResult(TransformWorkerHostResultKind.Completed, output, string.Empty); + return new TransformWorkerHostResult(TransformWorkerHostResultKind.Completed, output, string.Empty, null); } public static TransformWorkerHostResult Failure(TransformWorkerHostResultKind kind, string errorMessage) { Debug.Assert(kind != TransformWorkerHostResultKind.Completed, "Completed must carry output."); Debug.Assert(!string.IsNullOrEmpty(errorMessage), "errorMessage must not be empty."); - return new TransformWorkerHostResult(kind, null, errorMessage); + return new TransformWorkerHostResult(kind, null, errorMessage, null); + } + + /// The same result carrying the request's timing. + public TransformWorkerHostResult WithTiming(TransformWorkerRequestTiming timing) + { + Debug.Assert(timing != null, "timing must not be null."); + return new TransformWorkerHostResult(Kind, Output, ErrorMessage, timing); } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerRequestTiming.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerRequestTiming.cs new file mode 100644 index 0000000000..e11d574ec2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerRequestTiming.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Records where one request to the transform worker spent its time, whether it started the + /// worker process, and how many conversations it began. Only the + /// hot_reload_worker_request_timing vibe entry reads it. + /// + /// + /// Why not HotReloadRunTiming: that accumulator lives in the hot-reload application assembly, + /// which this shared kernel cannot reference, and its steps sit outside the phases while these + /// split the analysis phase itself. + /// + internal sealed class TransformWorkerRequestTiming + { + public const string GateWaitStep = "gate_wait"; + public const string ResolveLaunchTargetStep = "resolve_launch_target"; + public const string WriteInputStep = "write_input"; + public const string ProcessStartStep = "process_start"; + public const string ResponseWaitStep = "response_wait"; + public const string ReadOutputStep = "read_output"; + + private readonly List> _steps = new List>(); + + public IReadOnlyList> Steps => _steps; + + /// Whether this request registered a new worker process. + public bool WorkerStarted { get; private set; } + + /// Number of conversations this request began with a worker process. + public int Attempts { get; private set; } + + public void AddStep(string step, long milliseconds) + { + if (string.IsNullOrEmpty(step)) + { + throw new ArgumentException("step must not be null or empty.", nameof(step)); + } + + if (milliseconds < 0) + { + throw new ArgumentOutOfRangeException(nameof(milliseconds), milliseconds, "Elapsed milliseconds must not be negative."); + } + + int index = _steps.FindIndex(pair => pair.Key == step); + if (index < 0) + { + _steps.Add(new KeyValuePair(step, milliseconds)); + return; + } + + _steps[index] = new KeyValuePair(step, _steps[index].Value + milliseconds); + } + + /// + /// Measures the wall-clock time until the returned scope is disposed and adds it to the step. + /// + public IDisposable Measure(string step) + { + return new StepScope(this, step); + } + + public void MarkWorkerStarted() + { + WorkerStarted = true; + } + + public void MarkAttempt() + { + Attempts++; + } + + private sealed class StepScope : IDisposable + { + private readonly TransformWorkerRequestTiming _timing; + private readonly string _step; + private readonly Stopwatch _watch = Stopwatch.StartNew(); + private bool _disposed; + + public StepScope(TransformWorkerRequestTiming timing, string step) + { + _timing = timing; + _step = step; + } + + public void Dispose() + { + // Why only once: a second dispose would count the same span twice. + if (_disposed) + { + return; + } + + _disposed = true; + _watch.Stop(); + _timing.AddStep(_step, _watch.ElapsedMilliseconds); + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerRequestTiming.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerRequestTiming.cs.meta new file mode 100644 index 0000000000..99fab806e8 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerRequestTiming.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 947c89194dd4b4701bef52111062a131 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/IntroducedTypePreparation.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/IntroducedTypePreparation.cs index 60a7ba3588..92b8b481cc 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/IntroducedTypePreparation.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/IntroducedTypePreparation.cs @@ -65,7 +65,7 @@ private static CSharpCompilation CreateConstDriftCompilation( options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); } - internal static WorkerOutput Prepare(WorkerInput input) + internal static WorkerOutput Prepare(WorkerInput input, WorkerRequestTimings timings) { CSharpParseOptions parseOptions = new CSharpParseOptions( languageVersion: LanguageVersion.Latest, @@ -84,11 +84,13 @@ internal static WorkerOutput Prepare(WorkerInput input) } } + timings.Lap("parse_sources"); List referenceParseErrors = new List(); (List references, MetadataReference targetTypesReference) = WorkerGroupPipeline.CollectMetadataReferences(input, referenceParseErrors); List<(WorkerIntroducedTypeArtifact Artifact, MetadataReference Reference)> artifactReferences = IntroducedTypeArtifactReferences.Collect(input, references, referenceParseErrors); + timings.Lap("collect_references"); List analyzableRoots = new List(analyzableUnits.Count); foreach (WorkerSourceUnit analyzableUnit in analyzableUnits) { @@ -101,6 +103,7 @@ internal static WorkerOutput Prepare(WorkerInput input) List assemblyGlobalUsings = WorkerUsingCollector.CollectAssemblyGlobalUsings(input, parseOptions, analyzableRoots); SyntaxTree globalUsingTree = WorkerGlobalUsingBindingTree.Build(assemblyGlobalUsings, analyzableRoots, parseOptions); + timings.Lap("global_usings"); // Why appended only here and not to syntaxTrees: the const drift compilation adds the // changed siblings and builds a tree of its own, which would otherwise repeat directives. CSharpCompilation compilation = CSharpCompilation.Create( @@ -108,6 +111,7 @@ internal static WorkerOutput Prepare(WorkerInput input) syntaxTrees: WorkerGlobalUsingBindingTree.Append(syntaxTrees, globalUsingTree), references: references, options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + timings.Lap("create_compilation"); AppendUnreadableReferenceErrors(compilation, references, referenceParseErrors); IAssemblySymbol targetAssembly = WorkerCompiledAssemblySymbols.ResolveWithAllMembers(compilation, targetTypesReference); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TransformWorker.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TransformWorker.cs index a2ff604e3b..d7f52f0ace 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TransformWorker.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TransformWorker.cs @@ -105,9 +105,13 @@ private static string ReadRoslynDirectorySidecar() private static int RunTransform(string inputJsonPath, string outputJsonPath) { + WorkerRequestTimings timings = new WorkerRequestTimings(); WorkerInput input = ReadInput(inputJsonPath); + timings.Lap("read_input"); WorkerOutput invalidSources = TryCreateInvalidSourcesOutput(input); - WorkerOutput output = invalidSources ?? WorkerGroupPipeline.Run(input); + WorkerOutput output = invalidSources ?? WorkerGroupPipeline.Run(input, timings); + timings.Lap("pipeline_remainder"); + output.Timings = timings.ToArray(); WriteOutput(outputJsonPath, output); return 0; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerGroupPipeline.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerGroupPipeline.cs index 643bacd8dd..36567994f7 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerGroupPipeline.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerGroupPipeline.cs @@ -23,11 +23,11 @@ internal static class WorkerGroupPipeline { internal const string PrepareIntroducedTypesOperation = "prepareIntroducedTypes"; - internal static WorkerOutput Run(WorkerInput input) + internal static WorkerOutput Run(WorkerInput input, WorkerRequestTimings timings) { if (string.Equals(input.Operation, PrepareIntroducedTypesOperation, StringComparison.Ordinal)) { - return IntroducedTypePreparation.Prepare(input); + return IntroducedTypePreparation.Prepare(input, timings); } if (!string.IsNullOrEmpty(input.Operation)) @@ -35,10 +35,10 @@ internal static WorkerOutput Run(WorkerInput input) return CreateRunFailureOutput("Unknown worker operation: " + input.Operation); } - return Transform(input); + return Transform(input, timings); } - internal static WorkerOutput Transform(WorkerInput input) + internal static WorkerOutput Transform(WorkerInput input, WorkerRequestTimings timings) { CSharpParseOptions parseOptions = new CSharpParseOptions( languageVersion: LanguageVersion.Latest, @@ -49,6 +49,8 @@ internal static WorkerOutput Transform(WorkerInput input) units.Add(WorkerSourceLoader.Load(source, parseOptions)); } + timings.Lap("parse_sources"); + List loadedUnits = new List(units.Count); foreach (WorkerSourceUnit unit in units) { @@ -70,6 +72,8 @@ internal static WorkerOutput Transform(WorkerInput input) loadedUnit.ParseErrors.AddRange(referenceParseErrors); } + timings.Lap("collect_references"); + List transformUnits = SelectTransformableUnits(loadedUnits); List editedRoots = new List(transformUnits.Count); foreach (WorkerSourceUnit transformUnit in transformUnits) @@ -80,12 +84,14 @@ internal static WorkerOutput Transform(WorkerInput input) // Why loaded before the compilation: a body of a partial type may name a member another // file declares, and only the parts of the type in those files let it bind. PartialTypeParts partialTypeParts = PartialTypePartLoader.Load(input, parseOptions, units, transformUnits); + timings.Lap("partial_type_parts"); // Why collected before any compilation: the global usings other files of the assembly // declare must bind the edited files' signatures, not only reach the emitted shims. List assemblyGlobalUsings = WorkerUsingCollector.CollectAssemblyGlobalUsings(input, parseOptions, editedRoots); SyntaxTree globalUsingTree = WorkerGlobalUsingBindingTree.Build(assemblyGlobalUsings, editedRoots, parseOptions); + timings.Lap("global_usings"); // Why a run-level failure and not a per-file diagnostic: the orchestrator advances to // revert, gating and compile whenever the run succeeds, so a run that could not trust its @@ -98,6 +104,7 @@ internal static WorkerOutput Transform(WorkerInput input) targetTypesReference, parseOptions, globalUsingTree); + timings.Lap("prepare_binding_trees"); if (artifactFailure != null) { return CreateRunFailureOutput(artifactFailure); @@ -132,12 +139,14 @@ internal static WorkerOutput Transform(WorkerInput input) WorkerTypeHome home = new WorkerTypeHome( input.TargetAssemblyName, WorkerCompiledAssemblySymbols.ResolveWithAllMembers(compilation, targetTypesReference)); + timings.Lap("create_compilation"); List siblingConstDriftWarnings = SiblingConstDriftCollector.CollectConstDriftWarnings( input.ChangedSiblingSourcePaths, parseOptions, references, home, assemblyGlobalUsings); + timings.Lap("sibling_const_drift"); List entries = new List(); List skipped = new List(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerOutput.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerOutput.cs index 295095533a..5c56928967 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerOutput.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerOutput.cs @@ -37,4 +37,7 @@ internal sealed class WorkerOutput // True when shim bodies rewrite added-field accesses to HotReloadAddedFieldStore. public bool HasAddedFieldRewrites { get; set; } + + // Milliseconds of each stage of this request, in the order they ran. + public WorkerTimingStep[] Timings { get; set; } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerRequestTimings.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerRequestTimings.cs new file mode 100644 index 0000000000..b0cd51ab8b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerRequestTimings.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +// One stage inside the worker and the milliseconds it took. +internal sealed class WorkerTimingStep +{ + public string Step { get; set; } + + // Why int and not long: the Editor-side DTO sync test maps int only, and no stage of one + // request comes near int's range. + public int Ms { get; set; } +} + +// Records how long each stage of one request took, as the time between consecutive laps. +// Why laps and not scopes: the pipeline stages are statements of one long method, and wrapping +// each in a scope would break the locals the later stages read. +internal sealed class WorkerRequestTimings +{ + private readonly List _steps = new List(); + private readonly Stopwatch _lap = Stopwatch.StartNew(); + + // Adds the time since the previous lap to the step; a step lapped again is summed in place. + public void Lap(string step) + { + if (string.IsNullOrEmpty(step)) + { + throw new ArgumentException("step must not be null or empty.", nameof(step)); + } + + int elapsed = (int)_lap.ElapsedMilliseconds; + _lap.Restart(); + WorkerTimingStep existing = _steps.Find(candidate => candidate.Step == step); + if (existing == null) + { + _steps.Add(new WorkerTimingStep { Step = step, Ms = elapsed }); + return; + } + + existing.Ms += elapsed; + } + + public WorkerTimingStep[] ToArray() + { + return _steps.ToArray(); + } +} diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 0d59cfb84d..41a93bb62a 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -39,6 +39,21 @@ command ran against: `hot_reload_call_site_cache_evicted` with the evicted count and bytes, the bytes still cached, and the budget — a sign that the assemblies one run scans do not fit the cache and are read again on the next run. + The time inside the analysis and shim-compile phases is split by separate entries, never by + `steps`, so `unaccountedMs` keeps its meaning. `hot_reload_worker_request_timing` (one per + request to the transform worker, prepare and transform) has the gate wait, launch target + resolution, input write, process spawn, response wait and output read, whether the request + started the worker, and the stages the worker timed itself (`workerSteps`). + `hot_reload_signature_gate_timing` and `hot_reload_shim_first_compile_timing` (one per group) + have the main-thread switches, the signature gate, the membership check and the shim compile. + `hot_reload_shim_references_timing` (one per shim compile) has the resolver directories, the + target's publicized copy, the artifact copies and the reference copies with their counts. + `hot_reload_publicized_copy_written` appears only when a rewritten copy was written, with + its rewrite and write time. `hot_reload_shim_compiler_timing` has the compile, the load and + the backend kind. `dynamic_code_shared_worker_started` appears whenever no live shared Roslyn + worker could serve the compile and one had to be started, with its source sync, worker + assembly check and process spawn; `ready` is false when the start failed (no worker + assembly, or the process did not start). - CLI side: only when the `ULOOP_DEBUG` environment variable is set to a value other than empty, `0`, or `false` (`cli/common/vibelog/cli_vibe.go`). - A missing line is evidence only when the define was set and the code path logs at all. From 1bcad3b724e10abbb32e07396361367dd26206ba Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 9 Oct 2026 01:09:32 +0900 Subject: [PATCH 22/29] perf: The first dynamic code run and hot reload after a domain reload reuse a cached compiler worker instead of rebuilding it (#3252) --- .../ExternalCompilerPathResolverTests.cs | 4 + .../RoslynCompilerBackendTests.cs | 6 + ...dRoslynCompilerWorkerAssemblyCacheTests.cs | 296 ++++++++++++++++++ ...ynCompilerWorkerAssemblyCacheTests.cs.meta | 11 + .../SharedRoslynCompilerWorkerCacheScope.cs | 54 ++++ ...aredRoslynCompilerWorkerCacheScope.cs.meta | 11 + .../SharedRoslynCompilerWorkerHostTests.cs | 243 ++++++++++++++ ...SharedRoslynCompilerWorkerAssemblyCache.cs | 213 +++++++++++++ ...dRoslynCompilerWorkerAssemblyCache.cs.meta | 11 + .../SharedRoslynCompilerWorkerHost.cs | 5 + .../SharedRoslynCompilerWorkerHostProcess.cs | 92 ++++-- .../SharedRoslynCompilerWorkerHostResults.cs | 48 +++ .../SharedRoslynCompilerWorkerSession.cs | 15 + docs/vibe-logs.md | 6 +- 14 files changed, 989 insertions(+), 26 deletions(-) create mode 100644 Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerAssemblyCacheTests.cs create mode 100644 Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerAssemblyCacheTests.cs.meta create mode 100644 Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerCacheScope.cs create mode 100644 Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerCacheScope.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyCache.cs create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyCache.cs.meta diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs index 8d1f9326e5..ced3fffd25 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs @@ -360,6 +360,7 @@ public async Task CompileAsync_WhenSharedWorkerBuildFails_ShouldFallbackToOneSho new System.Text.RegularExpressions.Regex( "execute-dynamic-code shared Roslyn worker is unavailable; falling back to one-shot compiler execution; reason=worker_unavailable")); + SharedRoslynCompilerWorkerCacheScope cacheScope = SharedRoslynCompilerWorkerCacheScope.ForHost(); Func previousWorkerCompiler = SharedRoslynCompilerWorkerHost.SwapWorkerAssemblyCompilerForTests( (ExternalCompilerPaths paths, string workerSourcePath, string workerAssemblyPath, string workerCompileResponseFilePath) => @@ -398,6 +399,7 @@ public async Task CompileAsync_WhenSharedWorkerBuildFails_ShouldFallbackToOneSho { SharedRoslynCompilerWorkerHost.SwapWorkerAssemblyCompilerForTests(previousWorkerCompiler); SharedRoslynCompilerWorkerHost.ShutdownForTests(); + cacheScope.Dispose(); } } @@ -427,6 +429,7 @@ public async Task CompileAsync_WhenWorkerLifecycleClosesDuringStartup_ShouldFall SharedRoslynCompilerWorkerHost.ShutdownForTests(); LogAssert.NoUnexpectedReceived(); + SharedRoslynCompilerWorkerCacheScope cacheScope = SharedRoslynCompilerWorkerCacheScope.ForHost(); Func previousWorkerCompiler = SharedRoslynCompilerWorkerHost.SwapWorkerAssemblyCompilerForTests( (ExternalCompilerPaths paths, string workerSourcePath, string workerAssemblyPath, string workerCompileResponseFilePath) => @@ -462,6 +465,7 @@ public async Task CompileAsync_WhenWorkerLifecycleClosesDuringStartup_ShouldFall { SharedRoslynCompilerWorkerHost.SwapWorkerAssemblyCompilerForTests(previousWorkerCompiler); SharedRoslynCompilerWorkerHost.ShutdownForTests(); + cacheScope.Dispose(); } } diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/RoslynCompilerBackendTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/RoslynCompilerBackendTests.cs index 2d439c6e40..1ec791f986 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/RoslynCompilerBackendTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/RoslynCompilerBackendTests.cs @@ -291,6 +291,7 @@ public async Task CompileMultipleSourcesAsync_InfrastructureFailure_DoesNotInvok File.WriteAllText(firstSourcePath, "public class First { }"); File.WriteAllText(secondSourcePath, "public class Second { }"); int fallbackCalls = 0; + SharedRoslynCompilerWorkerCacheScope cacheScope = SharedRoslynCompilerWorkerCacheScope.ForHost(); Func previousWorker = SharedRoslynCompilerWorkerHost.SwapWorkerAssemblyCompilerForTests( (ExternalCompilerPaths _, string __, string ___, string ____) => @@ -367,6 +368,7 @@ public async Task CompileMultipleSourcesAsync_InfrastructureFailure_DoesNotInvok { Directory.Delete(directory, recursive: true); } + cacheScope.Dispose(); } } @@ -525,6 +527,7 @@ public async Task CompileMultipleSourcesAsync_WorkerBuildFailure_UsesRealOneShot new List(), null, paths); + SharedRoslynCompilerWorkerCacheScope cacheScope = SharedRoslynCompilerWorkerCacheScope.ForHost(); Func previousWorker = SharedRoslynCompilerWorkerHost.SwapWorkerAssemblyCompilerForTests( (ExternalCompilerPaths _, string __, string ___, string ____) => @@ -583,6 +586,7 @@ public async Task CompileMultipleSourcesAsync_WorkerBuildFailure_UsesRealOneShot { Directory.Delete(directory, recursive: true); } + cacheScope.Dispose(); } } @@ -638,6 +642,7 @@ public async Task CompileMultipleSourcesAsync_SecondSourceError_ReportsSecondSou File.WriteAllText(firstSourcePath, "public class ValidFirst { }"); File.WriteAllText(secondSourcePath, "public class BrokenSecond { public MissingType Value; }"); List references = new DynamicReferenceSetBuilderService().BuildReferenceSet(new List(), null, paths); + SharedRoslynCompilerWorkerCacheScope cacheScope = SharedRoslynCompilerWorkerCacheScope.ForHost(); Func previousWorker = SharedRoslynCompilerWorkerHost.SwapWorkerAssemblyCompilerForTests( (ExternalCompilerPaths _, string __, string ___, string ____) => @@ -663,6 +668,7 @@ public async Task CompileMultipleSourcesAsync_SecondSourceError_ReportsSecondSou SharedRoslynCompilerWorkerHost.SwapWorkerAssemblyCompilerForTests(previousWorker); SharedRoslynCompilerWorkerHost.ShutdownForTests(); Directory.Delete(directory, recursive: true); + cacheScope.Dispose(); } } diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerAssemblyCacheTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerAssemblyCacheTests.cs new file mode 100644 index 0000000000..c2b755fe9e --- /dev/null +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerAssemblyCacheTests.cs @@ -0,0 +1,296 @@ +using System; +using System.IO; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.DynamicCodeToolTests +{ + /// + /// Test fixture that verifies the shared Roslyn worker assembly cache: its key, and copying + /// assemblies out of and publishing them into a cache directory. + /// + [TestFixture] + public class SharedRoslynCompilerWorkerAssemblyCacheTests + { + private static readonly byte[] AssemblyBytes = { 0x4D, 0x5A, 0x01, 0x02 }; + + private string _rootPath; + + [SetUp] + public void SetUp() + { + _rootPath = Path.Combine( + Path.GetTempPath(), + "RoslynWorkerAssemblyCacheTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_rootPath); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_rootPath)) + { + Directory.Delete(_rootPath, true); + } + } + + /// + /// Verifies that the key is a stable 64-character lowercase hex string for the same inputs. + /// + [Test] + public void ComputeCacheKey_SameSourceAndPaths_ReturnsTheSameLowercaseHexKey() + { + string first = SharedRoslynCompilerWorkerAssemblyCache.ComputeCacheKey( + "// source", CreatePaths("csc.dll")); + string second = SharedRoslynCompilerWorkerAssemblyCache.ComputeCacheKey( + "// source", CreatePaths("csc.dll")); + + Assert.That(first, Is.EqualTo(second)); + Assert.That(first, Does.Match("^[0-9a-f]{64}$")); + } + + /// + /// Verifies that a one-character change in the worker source changes the key. + /// + [Test] + public void ComputeCacheKey_SourceDiffersByOneCharacter_ReturnsADifferentKey() + { + string first = SharedRoslynCompilerWorkerAssemblyCache.ComputeCacheKey( + "// source A", CreatePaths("csc.dll")); + string second = SharedRoslynCompilerWorkerAssemblyCache.ComputeCacheKey( + "// source B", CreatePaths("csc.dll")); + + Assert.That(first, Is.Not.EqualTo(second)); + } + + /// + /// Verifies that another compiler path changes the key, so two Unity versions do not share one assembly. + /// + [Test] + public void ComputeCacheKey_CompilerDllPathDiffers_ReturnsADifferentKey() + { + string first = SharedRoslynCompilerWorkerAssemblyCache.ComputeCacheKey( + "// source", CreatePaths("first/csc.dll")); + string second = SharedRoslynCompilerWorkerAssemblyCache.ComputeCacheKey( + "// source", CreatePaths("second/csc.dll")); + + Assert.That(first, Is.Not.EqualTo(second)); + } + + /// + /// Verifies that a missing cached assembly is reported as not copied. + /// + [Test] + public void TryCopyCachedAssembly_WhenTheCachedAssemblyIsMissing_ReturnsFalse() + { + string destination = Path.Combine(_rootPath, "worker.dll"); + + bool copied = SharedRoslynCompilerWorkerAssemblyCache.TryCopyCachedAssembly( + Path.Combine(_rootPath, "missing.dll"), destination); + + Assert.That(copied, Is.False); + Assert.That(File.Exists(destination), Is.False); + } + + /// + /// Verifies that an empty cached file, which a crashed Editor can leave, counts as no cached assembly. + /// + [Test] + public void TryCopyCachedAssembly_WhenTheCachedAssemblyIsEmpty_ReturnsFalse() + { + string cached = Path.Combine(_rootPath, "cached.dll"); + File.WriteAllBytes(cached, Array.Empty()); + string destination = Path.Combine(_rootPath, "worker.dll"); + + bool copied = SharedRoslynCompilerWorkerAssemblyCache.TryCopyCachedAssembly(cached, destination); + + Assert.That(copied, Is.False); + Assert.That(File.Exists(destination), Is.False); + } + + /// + /// Verifies that a cached assembly is copied to the worker directory. + /// + [Test] + public void TryCopyCachedAssembly_WhenTheCachedAssemblyExists_CopiesItAndReturnsTrue() + { + string cached = Path.Combine(_rootPath, "cached.dll"); + File.WriteAllBytes(cached, AssemblyBytes); + string destination = Path.Combine(_rootPath, "worker.dll"); + + bool copied = SharedRoslynCompilerWorkerAssemblyCache.TryCopyCachedAssembly(cached, destination); + + Assert.That(copied, Is.True); + Assert.That(File.ReadAllBytes(destination), Is.EqualTo(AssemblyBytes)); + } + + /// + /// Verifies that a copy failing for an environmental reason returns false instead of throwing, + /// so the caller builds the assembly instead. + /// + [Test] + public void TryCopyCachedAssembly_WhenTheDestinationDirectoryIsMissing_ReturnsFalseWithoutThrowing() + { + string cached = Path.Combine(_rootPath, "cached.dll"); + File.WriteAllBytes(cached, AssemblyBytes); + string destination = Path.Combine(_rootPath, "missing", "worker.dll"); + + bool copied = true; + Assert.DoesNotThrow(() => + copied = SharedRoslynCompilerWorkerAssemblyCache.TryCopyCachedAssembly(cached, destination)); + + Assert.That(copied, Is.False); + Assert.That(File.Exists(destination), Is.False); + } + + /// + /// Verifies that a built assembly is published to an empty cache and no temp file remains. + /// + [Test] + public void PublishBuiltAssembly_WhenTheCacheIsEmpty_PublishesTheAssemblyWithoutLeavingATempFile() + { + string built = WriteBuiltAssembly(); + string cached = Path.Combine(_rootPath, "cache", "key", "RoslynCompilerWorker.dll"); + + CachePublishOutcome outcome = SharedRoslynCompilerWorkerAssemblyCache.PublishBuiltAssembly(built, cached); + + Assert.That(outcome.Kind, Is.EqualTo(CachePublishKind.Published)); + Assert.That(outcome.Error, Is.Empty); + Assert.That(File.ReadAllBytes(cached), Is.EqualTo(AssemblyBytes)); + Assert.That(Directory.GetFiles(Path.GetDirectoryName(cached)), Is.EqualTo(new[] { cached })); + } + + /// + /// Verifies that an assembly another Editor already published is left untouched. + /// + [Test] + public void PublishBuiltAssembly_WhenTheCachedAssemblyExists_LeavesItUntouched() + { + string built = WriteBuiltAssembly(); + string cached = Path.Combine(_rootPath, "cache", "key", "RoslynCompilerWorker.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(cached)); + byte[] publishedBytes = { 0x4D, 0x5A, 0x09 }; + File.WriteAllBytes(cached, publishedBytes); + DateTime fixedWriteTime = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(cached, fixedWriteTime); + + CachePublishOutcome outcome = SharedRoslynCompilerWorkerAssemblyCache.PublishBuiltAssembly(built, cached); + + Assert.That(outcome.Kind, Is.EqualTo(CachePublishKind.AlreadyPresent)); + Assert.That(File.GetLastWriteTimeUtc(cached), Is.EqualTo(fixedWriteTime)); + Assert.That(File.ReadAllBytes(cached), Is.EqualTo(publishedBytes)); + Assert.That(Directory.GetFiles(Path.GetDirectoryName(cached)), Is.EqualTo(new[] { cached })); + } + + /// + /// Verifies that an empty cached file is replaced, so it does not stay in the cache forever. + /// + [Test] + public void PublishBuiltAssembly_WhenTheCachedAssemblyIsEmpty_ReplacesIt() + { + string built = WriteBuiltAssembly(); + string cached = Path.Combine(_rootPath, "cache", "key", "RoslynCompilerWorker.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(cached)); + File.WriteAllBytes(cached, Array.Empty()); + + CachePublishOutcome outcome = SharedRoslynCompilerWorkerAssemblyCache.PublishBuiltAssembly(built, cached); + + Assert.That(outcome.Kind, Is.EqualTo(CachePublishKind.Published)); + Assert.That(File.ReadAllBytes(cached), Is.EqualTo(AssemblyBytes)); + Assert.That(Directory.GetFiles(Path.GetDirectoryName(cached)), Is.EqualTo(new[] { cached })); + } + + /// + /// Verifies that a cache directory that cannot be created yields a failed outcome with an error + /// instead of an exception. + /// + [Test] + public void PublishBuiltAssembly_WhenTheCacheDirectoryCannotBeCreated_ReturnsFailedWithoutThrowing() + { + string built = WriteBuiltAssembly(); + string blockingFile = Path.Combine(_rootPath, "cache"); + File.WriteAllBytes(blockingFile, AssemblyBytes); + string cached = Path.Combine(blockingFile, "key", "RoslynCompilerWorker.dll"); + + CachePublishOutcome outcome = default; + Assert.DoesNotThrow(() => + outcome = SharedRoslynCompilerWorkerAssemblyCache.PublishBuiltAssembly(built, cached)); + + Assert.That(outcome.Kind, Is.EqualTo(CachePublishKind.Failed)); + Assert.That(outcome.Error, Is.Not.Empty); + Assert.That( + Directory.GetFiles(_rootPath), + Is.EquivalentTo(new[] { built, blockingFile })); + } + + /// + /// Verifies that a publish failing after the temp copy was written (the move cannot replace a + /// directory at the cached path) reports a failure and removes the temp file. + /// + [Test] + public void PublishBuiltAssembly_WhenTheMoveFails_ReturnsFailedAndLeavesNoTempFile() + { + string built = WriteBuiltAssembly(); + string cached = Path.Combine(_rootPath, "cache", "key", "RoslynCompilerWorker.dll"); + Directory.CreateDirectory(cached); + + CachePublishOutcome outcome = default; + Assert.DoesNotThrow(() => + outcome = SharedRoslynCompilerWorkerAssemblyCache.PublishBuiltAssembly(built, cached)); + + Assert.That(outcome.Kind, Is.EqualTo(CachePublishKind.Failed)); + Assert.That(outcome.Error, Is.Not.Empty); + Assert.That(Directory.GetFiles(Path.GetDirectoryName(cached)), Is.Empty); + Assert.That(Directory.Exists(cached), Is.True); + } + + /// + /// Verifies that the default outcome means nothing was offered to the cache. + /// + [Test] + public void CachePublishOutcome_Default_IsNotAttempted() + { + CachePublishOutcome outcome = default; + + Assert.That(outcome.Kind, Is.EqualTo(CachePublishKind.NotAttempted)); + Assert.That(outcome.Error, Is.Empty); + Assert.That(outcome.ToVibeValue(), Is.EqualTo("not_attempted")); + } + + /// + /// Verifies the vibe value written for each publish outcome. + /// + [Test] + public void CachePublishOutcome_ToVibeValue_NamesEachOutcome() + { + Assert.That(CachePublishOutcome.NotAttempted.ToVibeValue(), Is.EqualTo("not_attempted")); + Assert.That(CachePublishOutcome.Published.ToVibeValue(), Is.EqualTo("published")); + Assert.That(CachePublishOutcome.AlreadyPresent.ToVibeValue(), Is.EqualTo("present")); + Assert.That(CachePublishOutcome.Failed("IOException: denied").ToVibeValue(), Is.EqualTo("failed")); + Assert.That(CachePublishOutcome.Failed("IOException: denied").Error, Is.EqualTo("IOException: denied")); + } + + private string WriteBuiltAssembly() + { + string built = Path.Combine(_rootPath, "built.dll"); + File.WriteAllBytes(built, AssemblyBytes); + return built; + } + + private static ExternalCompilerPaths CreatePaths(string compilerDllPath) + { + return new ExternalCompilerPaths( + "contents", + "scripting", + "dotnet", + compilerDllPath, + "csc.runtimeconfig.json", + "csc.deps.json", + "Microsoft.CodeAnalysis.dll", + null, + "shared", + ExternalCompilerLayoutKind.Unknown); + } + } +} diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerAssemblyCacheTests.cs.meta b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerAssemblyCacheTests.cs.meta new file mode 100644 index 0000000000..5c1b518159 --- /dev/null +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerAssemblyCacheTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ecb1bff0d800d45d691e7c9b83a8c291 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerCacheScope.cs b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerCacheScope.cs new file mode 100644 index 0000000000..60cdef8703 --- /dev/null +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerCacheScope.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.DynamicCodeToolTests +{ + /// + /// Points the shared Roslyn worker at an empty, test-owned assembly cache until disposed, so a + /// worker assembly another run cached does not skip the worker build a test depends on. + /// + internal sealed class SharedRoslynCompilerWorkerCacheScope : IDisposable + { + private readonly Func, Func> _swap; + private readonly Func _previousResolver; + + public string CacheRoot { get; } + + private SharedRoslynCompilerWorkerCacheScope(Func, Func> swap) + { + CacheRoot = Path.Combine( + Path.GetTempPath(), + "RoslynWorkerCacheTests_" + Guid.NewGuid().ToString("N")); + _swap = swap; + _previousResolver = swap(() => CacheRoot); + } + + /// + /// Swaps the cache root of the shared worker the host facade uses. + /// + public static SharedRoslynCompilerWorkerCacheScope ForHost() + { + return new SharedRoslynCompilerWorkerCacheScope( + SharedRoslynCompilerWorkerHost.SwapWorkerAssemblyCacheRootForTests); + } + + /// + /// Swaps the cache root of a session the test created itself. + /// + public static SharedRoslynCompilerWorkerCacheScope ForSession(SharedRoslynCompilerWorkerSession session) + { + return new SharedRoslynCompilerWorkerCacheScope(session.SwapWorkerAssemblyCacheRootForTests); + } + + public void Dispose() + { + _swap(_previousResolver); + if (Directory.Exists(CacheRoot)) + { + Directory.Delete(CacheRoot, true); + } + } + } +} diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerCacheScope.cs.meta b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerCacheScope.cs.meta new file mode 100644 index 0000000000..f2539d20ed --- /dev/null +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerCacheScope.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ca7210dcfc3b24fe59e4cf0f1f7c5094 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs index 036e3be6bd..e469cefdbe 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs @@ -19,6 +19,9 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.DynamicCodeToolTests [TestFixture] public class SharedRoslynCompilerWorkerHostTests { + private const string WorkerSourceA = "// worker source A"; + private const string WorkerSourceB = "// worker source B"; + /// /// Verifies lifecycle closure is returned as a non-error compile outcome. /// @@ -703,5 +706,245 @@ public void CreateProgramSource_WhenEncodedPayloadIsMalformed_ShouldRecoverRawPa Assert.That(programSource, Does.Contain("return RecoverRawRequestPath(requestPath);")); Assert.That(programSource, Does.Not.Contain("catch (FormatException)")); } + + /// + /// Verifies that the first ensure in an empty cache builds the assembly and publishes the same bytes to the cache. + /// + [Test] + public async Task EnsureWorkerAssemblyBuilt_FirstTime_BuildsAndPublishesToTheCache() + { + using WorkerAssemblyEnsureFixture fixture = new(); + fixture.SynchronizeSource(WorkerSourceA); + + WorkerAssemblyEnsureResult result = await fixture.EnsureAsync(WorkerSourceA); + + Assert.That(fixture.BuildCount, Is.EqualTo(1)); + Assert.That(result.Result.IsReady, Is.True); + Assert.That(result.AssemblySource, Is.EqualTo("built")); + Assert.That(result.Publish.Kind, Is.EqualTo(CachePublishKind.Published)); + string cachedAssemblyPath = fixture.ResolveCachedAssemblyPath(WorkerSourceA); + Assert.That(File.Exists(cachedAssemblyPath), Is.True); + Assert.That( + File.ReadAllBytes(cachedAssemblyPath), + Is.EqualTo(File.ReadAllBytes(fixture.WorkerPaths.AssemblyPath))); + } + + /// + /// Verifies that a fresh worker directory, as after a domain reload, gets the cached assembly + /// copied without running the build again. + /// + [Test] + public async Task EnsureWorkerAssemblyBuilt_InAFreshWorkerDirectoryWithACachedAssembly_CopiesAndDoesNotBuild() + { + using WorkerAssemblyEnsureFixture fixture = new(); + fixture.SynchronizeSource(WorkerSourceA); + await fixture.EnsureAsync(WorkerSourceA); + File.Delete(fixture.WorkerPaths.AssemblyPath); + + WorkerAssemblyEnsureResult result = await fixture.EnsureAsync(WorkerSourceA); + + Assert.That(fixture.BuildCount, Is.EqualTo(1)); + Assert.That(result.Result.IsReady, Is.True); + Assert.That(result.AssemblySource, Is.EqualTo("cache")); + Assert.That(result.Publish.Kind, Is.EqualTo(CachePublishKind.NotAttempted)); + Assert.That(File.Exists(fixture.WorkerPaths.AssemblyPath), Is.True); + } + + /// + /// Verifies that a changed worker source misses the cache, builds again, and is cached under a second key. + /// + [Test] + public async Task EnsureWorkerAssemblyBuilt_WhenTheSourceChanged_BuildsAgain() + { + using WorkerAssemblyEnsureFixture fixture = new(); + fixture.SynchronizeSource(WorkerSourceA); + await fixture.EnsureAsync(WorkerSourceA); + fixture.SynchronizeSource(WorkerSourceB); + + WorkerAssemblyEnsureResult result = await fixture.EnsureAsync(WorkerSourceB); + + Assert.That(fixture.BuildCount, Is.EqualTo(2)); + Assert.That(result.AssemblySource, Is.EqualTo("built")); + Assert.That(Directory.GetDirectories(fixture.CacheRoot).Length, Is.EqualTo(2)); + } + + /// + /// Verifies that a build with errors fails the worker start and leaves nothing in the cache. + /// + [Test] + public async Task EnsureWorkerAssemblyBuilt_WhenTheBuildHasErrors_PublishesNothing() + { + using WorkerAssemblyEnsureFixture fixture = new(); + fixture.BuildFails = true; + fixture.SynchronizeSource(WorkerSourceA); + + WorkerAssemblyEnsureResult result = await fixture.EnsureAsync(WorkerSourceA); + + Assert.That(result.Result.IsReady, Is.False); + Assert.That(result.Result.FailureReason, Is.EqualTo("worker_build_failed")); + Assert.That(result.AssemblySource, Is.Empty); + Assert.That(result.Publish.Kind, Is.EqualTo(CachePublishKind.NotAttempted)); + Assert.That(File.Exists(fixture.WorkerPaths.AssemblyPath), Is.False); + Assert.That( + !Directory.Exists(fixture.CacheRoot) || Directory.GetFileSystemEntries(fixture.CacheRoot).Length == 0, + Is.True, + "A failed build must not create a cache entry."); + } + + /// + /// Verifies that shutdown deletes the per-process worker directory but keeps the cached assembly. + /// + [Test] + public async Task Shutdown_DeletesTheWorkerDirectoryAndKeepsTheCache() + { + using WorkerAssemblyEnsureFixture fixture = new(); + fixture.SynchronizeSource(WorkerSourceA); + await fixture.EnsureAsync(WorkerSourceA); + + fixture.Session.Shutdown(fixture.WorkerPaths.DirectoryPath); + + Assert.That(Directory.Exists(fixture.WorkerPaths.DirectoryPath), Is.False); + Assert.That(File.Exists(fixture.ResolveCachedAssemblyPath(WorkerSourceA)), Is.True); + } + + /// + /// Verifies that a cache that cannot be written still lets the worker start with the assembly it built, + /// and that the failure is reported in the publish outcome. + /// + [Test] + public async Task EnsureWorkerAssemblyBuilt_WhenTheCachePublishFails_IsStillReady() + { + using WorkerAssemblyEnsureFixture fixture = new(); + string blockingFile = Path.Combine(fixture.WorkerPaths.DirectoryPath, "cache-root-is-a-file"); + File.WriteAllText(blockingFile, "not a directory"); + Func previousResolver = fixture.Session.SwapWorkerAssemblyCacheRootForTests(() => blockingFile); + try + { + fixture.SynchronizeSource(WorkerSourceA); + + WorkerAssemblyEnsureResult result = await fixture.EnsureAsync(WorkerSourceA); + + Assert.That(fixture.BuildCount, Is.EqualTo(1)); + Assert.That(result.Result.IsReady, Is.True); + Assert.That(result.AssemblySource, Is.EqualTo("built")); + Assert.That(result.Publish.Kind, Is.EqualTo(CachePublishKind.Failed)); + Assert.That(result.Publish.Error, Is.Not.Empty); + Assert.That(File.Exists(fixture.WorkerPaths.AssemblyPath), Is.True); + } + finally + { + fixture.Session.SwapWorkerAssemblyCacheRootForTests(previousResolver); + } + } + + /// + /// Verifies that an assembly already in the worker directory is used as is: no build, and the + /// cache is neither read nor written, because nothing checked where that assembly came from. + /// + [Test] + public async Task EnsureWorkerAssemblyBuilt_WhenTheWorkerAssemblyExists_DoesNotTouchTheCache() + { + using WorkerAssemblyEnsureFixture fixture = new(); + fixture.SynchronizeSource(WorkerSourceA); + File.WriteAllBytes(fixture.WorkerPaths.AssemblyPath, new byte[] { 0x4D, 0x5A, 0x07 }); + + WorkerAssemblyEnsureResult result = await fixture.EnsureAsync(WorkerSourceA); + + Assert.That(fixture.BuildCount, Is.EqualTo(0)); + Assert.That(result.AssemblySource, Is.EqualTo("existing")); + Assert.That(result.Publish.Kind, Is.EqualTo(CachePublishKind.NotAttempted)); + Assert.That(Directory.Exists(fixture.CacheRoot), Is.False); + } + + /// + /// A session with a fake csc, an empty test-owned cache, and a temp worker directory, for + /// driving the worker assembly ensure step directly. + /// + private sealed class WorkerAssemblyEnsureFixture : IDisposable + { + private readonly SharedRoslynCompilerWorkerCacheScope _cacheScope; + private readonly ExternalCompilerPaths _paths; + + public SharedRoslynCompilerWorkerSession Session { get; } + + public SharedRoslynCompilerWorkerHostProcess.WorkerPaths WorkerPaths { get; } + + public int BuildCount { get; private set; } + + public bool BuildFails { get; set; } + + public string CacheRoot => _cacheScope.CacheRoot; + + public WorkerAssemblyEnsureFixture() + { + _paths = ExternalCompilerPathResolver.Resolve(); + Assert.That(_paths, Is.Not.Null, "Unity external compiler layout should be available."); + Session = new SharedRoslynCompilerWorkerSession(); + _cacheScope = SharedRoslynCompilerWorkerCacheScope.ForSession(Session); + string workerDirectoryPath = Path.Combine( + Path.GetTempPath(), + "RoslynWorkerHostTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workerDirectoryPath); + WorkerPaths = new SharedRoslynCompilerWorkerHostProcess.WorkerPaths( + workerDirectoryPath, + Path.Combine(workerDirectoryPath, "RoslynCompilerWorker.cs"), + Path.Combine(workerDirectoryPath, "RoslynCompilerWorker.dll"), + Path.Combine(workerDirectoryPath, "RoslynCompilerWorker.rsp")); + Session.SwapWorkerAssemblyCompilerForTests(BuildWithFakeCompiler); + } + + public void SynchronizeSource(string workerSource) + { + SharedRoslynCompilerWorkerHostProcess.SynchronizeWorkerSource(WorkerPaths, workerSource); + } + + public Task EnsureAsync(string workerSource) + { + return SharedRoslynCompilerWorkerHostProcess.EnsureWorkerAssemblyBuiltAsync( + Session, + _paths, + WorkerPaths, + workerSource); + } + + public string ResolveCachedAssemblyPath(string workerSource) + { + return SharedRoslynCompilerWorkerAssemblyCache.ResolveCachedAssemblyPath( + CacheRoot, + SharedRoslynCompilerWorkerAssemblyCache.ComputeCacheKey(workerSource, _paths)); + } + + public void Dispose() + { + _cacheScope.Dispose(); + if (Directory.Exists(WorkerPaths.DirectoryPath)) + { + Directory.Delete(WorkerPaths.DirectoryPath, true); + } + } + + private CompilerMessage[] BuildWithFakeCompiler( + ExternalCompilerPaths paths, + string sourcePath, + string assemblyPath, + string responseFilePath) + { + BuildCount++; + if (BuildFails) + { + return new[] + { + new CompilerMessage + { + type = CompilerMessageType.Error, + message = "synthetic worker build failure" + } + }; + } + + File.WriteAllBytes(assemblyPath, new byte[] { 0x4D, 0x5A, 0x01 }); + return Array.Empty(); + } + } } } diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyCache.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyCache.cs new file mode 100644 index 0000000000..be36edaf46 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyCache.cs @@ -0,0 +1,213 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using Debug = UnityEngine.Debug; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// What happened when a freshly built worker assembly was offered to the cache. + /// + internal enum CachePublishKind + { + // Why zero: the default outcome must read as "nothing was offered to the cache". + NotAttempted = 0, + Published, + AlreadyPresent, + Failed + } + + /// + /// Result of offering a built worker assembly to the cache; only a failure carries an error. + /// + internal readonly struct CachePublishOutcome + { + private readonly string _error; + + public CachePublishKind Kind { get; } + + public string Error => _error ?? string.Empty; + + private CachePublishOutcome(CachePublishKind kind, string error) + { + Kind = kind; + _error = error; + } + + public static CachePublishOutcome NotAttempted => new(CachePublishKind.NotAttempted, string.Empty); + + public static CachePublishOutcome Published => new(CachePublishKind.Published, string.Empty); + + public static CachePublishOutcome AlreadyPresent => new(CachePublishKind.AlreadyPresent, string.Empty); + + public static CachePublishOutcome Failed(string error) + { + Debug.Assert(!string.IsNullOrEmpty(error), "a failed publish must say why"); + return new CachePublishOutcome(CachePublishKind.Failed, error); + } + + internal string ToVibeValue() + { + switch (Kind) + { + case CachePublishKind.Published: + return "published"; + case CachePublishKind.AlreadyPresent: + return "present"; + case CachePublishKind.Failed: + return "failed"; + default: + return "not_attempted"; + } + } + } + + /// + /// Keeps built shared Roslyn worker assemblies under a key made from the worker source and the + /// toolchain, so a new domain or Editor copies the assembly instead of running csc again. + /// + internal static class SharedRoslynCompilerWorkerAssemblyCache + { + private const string WorkerAssemblyFileName = "RoslynCompilerWorker.dll"; + + // Why the source text and the toolchain paths: either one changing makes a different + // assembly. The paths stand in for the toolchain contents; a Unity upgrade changes them. + internal static string ComputeCacheKey(string workerSource, ExternalCompilerPaths paths) + { + Debug.Assert(!string.IsNullOrEmpty(workerSource), "workerSource must not be empty"); + Debug.Assert(paths != null, "paths must not be null"); + + string toolchain = (paths.CompilerDllPath ?? string.Empty) + + "|" + (paths.CompilerRuntimeConfigPath ?? string.Empty) + + "|" + (paths.DotnetHostPath ?? string.Empty) + + "|" + (paths.CodeAnalysisDllPath ?? string.Empty) + + "|" + (paths.CodeAnalysisCSharpDllPath ?? string.Empty); + byte[] sourceBytes = Encoding.UTF8.GetBytes(workerSource); + byte[] toolchainBytes = Encoding.UTF8.GetBytes(toolchain); + byte[] input = new byte[sourceBytes.Length + 1 + toolchainBytes.Length]; + Buffer.BlockCopy(sourceBytes, 0, input, 0, sourceBytes.Length); + input[sourceBytes.Length] = 0; + Buffer.BlockCopy(toolchainBytes, 0, input, sourceBytes.Length + 1, toolchainBytes.Length); + + byte[] hash; + using (SHA256 sha256 = SHA256.Create()) + { + hash = sha256.ComputeHash(input); + } + + StringBuilder builder = new(hash.Length * 2); + for (int index = 0; index < hash.Length; index++) + { + builder.Append(hash[index].ToString("x2")); + } + + return builder.ToString(); + } + + // Why OS temp and not Library: the worker can be prepared on a pool thread, where the project + // root cannot be read, and the per-process worker directory already lives beside this one. + internal static string ResolveCacheRoot() + { + return Path.Combine(Path.GetTempPath(), "UnityCliLoopCompilation", "RoslynWorkerCache"); + } + + internal static string ResolveCachedAssemblyPath(string cacheRoot, string cacheKey) + { + return Path.Combine(cacheRoot, cacheKey, WorkerAssemblyFileName); + } + + /// + /// Copies the cached worker assembly to the worker directory. Returns false when there is no + /// usable cached assembly or the copy fails, so the caller builds the assembly instead. + /// + internal static bool TryCopyCachedAssembly(string cachedAssemblyPath, string destinationAssemblyPath) + { + if (!HasNonEmptyFile(cachedAssemblyPath)) + { + return false; + } + + // Why catch here: the cache only saves a build. A copy that fails for an environmental + // reason must fall back to building, not fail the worker start. + try + { + File.Copy(cachedAssemblyPath, destinationAssemblyPath, true); + return true; + } + catch (Exception ex) when (IsCacheIoException(ex)) + { + if (File.Exists(destinationAssemblyPath)) + { + File.Delete(destinationAssemblyPath); + } + + return false; + } + } + + /// + /// Offers an assembly this run built to the cache. An assembly already in the cache is kept, + /// and a failure is returned as an outcome because the built assembly is still usable. + /// + internal static CachePublishOutcome PublishBuiltAssembly(string builtAssemblyPath, string cachedAssemblyPath) + { + string tempPath = cachedAssemblyPath + ".tmp-" + Guid.NewGuid().ToString("N"); + // Why one catch over the whole body: creating the directory, deleting, copying, and moving + // can each fail for environmental reasons, and none of them should fail the worker start, + // whose assembly is already built. Only IO and access errors are caught; others are bugs. + try + { + Directory.CreateDirectory(Path.GetDirectoryName(cachedAssemblyPath)); + if (HasNonEmptyFile(cachedAssemblyPath)) + { + return CachePublishOutcome.AlreadyPresent; + } + + // An empty file is what a crashed Editor can leave; keeping it would hide the cache forever. + if (File.Exists(cachedAssemblyPath)) + { + File.Delete(cachedAssemblyPath); + } + + // Why copy then move: the cached file appears in one rename, so another Editor never + // reads a partly written assembly. + File.Copy(builtAssemblyPath, tempPath); + if (File.Exists(cachedAssemblyPath)) + { + return CachePublishOutcome.AlreadyPresent; + } + + File.Move(tempPath, cachedAssemblyPath); + return CachePublishOutcome.Published; + } + catch (Exception ex) when (IsCacheIoException(ex)) + { + // Another Editor can publish between the existence check and the move. + if (HasNonEmptyFile(cachedAssemblyPath)) + { + return CachePublishOutcome.AlreadyPresent; + } + + return CachePublishOutcome.Failed(ex.GetType().Name + ": " + ex.Message); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + private static bool HasNonEmptyFile(string path) + { + return File.Exists(path) && new FileInfo(path).Length > 0; + } + + private static bool IsCacheIoException(Exception ex) + { + return ex is IOException || ex is UnauthorizedAccessException; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyCache.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyCache.cs.meta new file mode 100644 index 0000000000..d2b4a69da4 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyCache.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 051f8f97a6848426dbeba0b2026320b5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs index f0cd66908a..953118790d 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs @@ -272,6 +272,11 @@ internal static Func SwapWorkerAssemblyCacheRootForTests(Func resolver) + { + return ServiceValue.SwapWorkerAssemblyCacheRootForTests(resolver); + } + private static void Shutdown() { ServiceValue.Shutdown(SharedRoslynCompilerWorkerHostProcess.GetWorkerDirectoryPath()); diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs index 7ee7ebbf13..b24481ef1a 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs @@ -66,9 +66,8 @@ internal static WorkerPaths CreateWorkerPaths(SharedRoslynCompilerWorkerSession Path.Combine(workerDirectoryPath, RoslynWorkerCompileResponseFileName)); } - internal static void SynchronizeWorkerSource(WorkerPaths workerPaths) + internal static void SynchronizeWorkerSource(WorkerPaths workerPaths, string workerSource) { - string workerSource = SharedRoslynCompilerWorkerProtocol.CreateProgramSource(); if (File.Exists(workerPaths.SourcePath) && File.ReadAllText(workerPaths.SourcePath) == workerSource) { return; @@ -135,19 +134,28 @@ internal static async Task EnsureWorkerReadyAsync( Stopwatch watch = Stopwatch.StartNew(); WorkerPaths workerPaths = CreateWorkerPaths(session); - SynchronizeWorkerSource(workerPaths); + // Why one string for both: the source written to disk and the source in the cache key + // must be the same text. + string workerSource = SharedRoslynCompilerWorkerProtocol.CreateProgramSource(); + SynchronizeWorkerSource(workerPaths, workerSource); long sourceSyncMs = watch.ElapsedMilliseconds; watch.Restart(); - WorkerStartupResult workerAssemblyResult = await EnsureWorkerAssemblyBuiltAsync( + WorkerAssemblyEnsureResult workerAssemblyResult = await EnsureWorkerAssemblyBuiltAsync( session, externalCompilerPaths, - workerPaths).ConfigureAwait(false); + workerPaths, + workerSource).ConfigureAwait(false); long assemblyEnsureMs = watch.ElapsedMilliseconds; - if (!workerAssemblyResult.IsReady) + if (!workerAssemblyResult.Result.IsReady) { - LogWorkerStarted(sourceSyncMs, assemblyEnsureMs, 0, workerAssemblyResult); - return workerAssemblyResult; + LogWorkerStarted( + sourceSyncMs, + assemblyEnsureMs, + 0, + workerAssemblyResult, + workerAssemblyResult.Result); + return workerAssemblyResult.Result; } watch.Restart(); @@ -157,7 +165,12 @@ internal static async Task EnsureWorkerReadyAsync( workerPaths, lifecycleGenerationAtStart); // Only the spawn: the worker's start-up until it answers lands in the first compile. - LogWorkerStarted(sourceSyncMs, assemblyEnsureMs, watch.ElapsedMilliseconds, startResult); + LogWorkerStarted( + sourceSyncMs, + assemblyEnsureMs, + watch.ElapsedMilliseconds, + workerAssemblyResult, + startResult); return startResult; } @@ -167,22 +180,47 @@ private static void LogWorkerStarted( long sourceSyncMs, long assemblyEnsureMs, long processStartMs, + WorkerAssemblyEnsureResult assemblyResult, WorkerStartupResult result) { VibeLogger.LogInfo( VibeLogSharedWorkerStarted, "Shared Roslyn compiler worker started.", - new { sourceSyncMs, assemblyEnsureMs, processStartMs, ready = result.IsReady }); + new + { + sourceSyncMs, + assemblyEnsureMs, + processStartMs, + ready = result.IsReady, + workerAssemblySource = assemblyResult.AssemblySource, + cachePublish = assemblyResult.Publish.ToVibeValue(), + cachePublishError = assemblyResult.Publish.Error + }); } - internal static async Task EnsureWorkerAssemblyBuiltAsync( + internal static async Task EnsureWorkerAssemblyBuiltAsync( SharedRoslynCompilerWorkerSession session, ExternalCompilerPaths externalCompilerPaths, - WorkerPaths workerPaths) + WorkerPaths workerPaths, + string workerSource) { + // Why never publish an existing assembly: nothing checked where it came from, and a csc + // run that timed out can leave a partly written assembly behind. if (File.Exists(workerPaths.AssemblyPath)) { - return WorkerStartupResult.Ready(); + return WorkerAssemblyEnsureResult.FromExistingAssembly(); + } + + string cachedAssemblyPath = SharedRoslynCompilerWorkerAssemblyCache.ResolveCachedAssemblyPath( + session.ResolveWorkerAssemblyCacheRoot(), + SharedRoslynCompilerWorkerAssemblyCache.ComputeCacheKey(workerSource, externalCompilerPaths)); + // Why a synchronous copy even on the main thread: the assembly is a few megabytes and + // copying takes milliseconds, against hundreds for a csc run. + if (SharedRoslynCompilerWorkerAssemblyCache.TryCopyCachedAssembly( + cachedAssemblyPath, + workerPaths.AssemblyPath)) + { + return WorkerAssemblyEnsureResult.FromCache(); } // Why outside state lock: worker DLL compile can take seconds; shutdown must still kill @@ -198,24 +236,28 @@ await session.CompileWorkerAssemblyAsync( workerPaths.CompileResponseFilePath).ConfigureAwait(false); if (!buildResult.StartedSuccessfully) { - return WorkerStartupResult.Failure( + return WorkerAssemblyEnsureResult.Failed(WorkerStartupResult.Failure( buildResult.FailureReason, - buildResult.FailureContext); + buildResult.FailureContext)); } - if (!HasErrors(buildResult.Messages)) + if (HasErrors(buildResult.Messages)) { - return WorkerStartupResult.Ready(); + SharedRoslynCompilerWorkerAssemblyBuilder.DeleteWorkerAssemblyIfPresent(workerPaths.AssemblyPath); + return WorkerAssemblyEnsureResult.Failed(WorkerStartupResult.Failure( + "worker_build_failed", + new + { + first_error = FindFirstErrorMessage(buildResult.Messages), + worker_source_path = workerPaths.SourcePath + })); } - SharedRoslynCompilerWorkerAssemblyBuilder.DeleteWorkerAssemblyIfPresent(workerPaths.AssemblyPath); - return WorkerStartupResult.Failure( - "worker_build_failed", - new - { - first_error = FindFirstErrorMessage(buildResult.Messages), - worker_source_path = workerPaths.SourcePath - }); + // Only an assembly this run's csc finished without errors goes to the cache. + CachePublishOutcome publish = SharedRoslynCompilerWorkerAssemblyCache.PublishBuiltAssembly( + workerPaths.AssemblyPath, + cachedAssemblyPath); + return WorkerAssemblyEnsureResult.Built(publish); } internal static WorkerStartupResult StartWorkerProcess( diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs index a4f2eba74d..efea041c37 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs @@ -1,4 +1,5 @@ using UnityEditor.Compilation; +using Debug = UnityEngine.Debug; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { @@ -130,4 +131,51 @@ public static WorkerStartupResult ClosedLifecycleFailure() new { reason = "lifecycle_generation_advanced" }); } } + + /// + /// Result of making sure the worker assembly exists: whether the worker can start, where its + /// assembly came from ("existing", "cache", "built", or empty on failure), and what happened + /// when a built assembly was offered to the cache. + /// + internal sealed class WorkerAssemblyEnsureResult + { + public WorkerStartupResult Result { get; } + + public string AssemblySource { get; } + + public CachePublishOutcome Publish { get; } + + private WorkerAssemblyEnsureResult( + WorkerStartupResult result, + string assemblySource, + CachePublishOutcome publish) + { + Result = result; + AssemblySource = assemblySource; + Publish = publish; + } + + public static WorkerAssemblyEnsureResult FromExistingAssembly() + { + return new WorkerAssemblyEnsureResult( + WorkerStartupResult.Ready(), "existing", CachePublishOutcome.NotAttempted); + } + + public static WorkerAssemblyEnsureResult FromCache() + { + return new WorkerAssemblyEnsureResult( + WorkerStartupResult.Ready(), "cache", CachePublishOutcome.NotAttempted); + } + + public static WorkerAssemblyEnsureResult Built(CachePublishOutcome publish) + { + return new WorkerAssemblyEnsureResult(WorkerStartupResult.Ready(), "built", publish); + } + + public static WorkerAssemblyEnsureResult Failed(WorkerStartupResult failure) + { + Debug.Assert(failure != null && !failure.IsReady, "failure must not be ready"); + return new WorkerAssemblyEnsureResult(failure, string.Empty, CachePublishOutcome.NotAttempted); + } + } } diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerSession.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerSession.cs index 2647bb1604..bf48791701 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerSession.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerSession.cs @@ -20,6 +20,7 @@ internal sealed class SharedRoslynCompilerWorkerSession { private readonly SharedRoslynCompilerWorkerSessionCoordination _coordination = new(); private Func _startProcess = ProcessStartHelper.TryStart; + private Func _resolveWorkerAssemblyCacheRoot = SharedRoslynCompilerWorkerAssemblyCache.ResolveCacheRoot; private Func _compileWorkerAssemblyForTests; private Process _workerProcess; @@ -321,6 +322,20 @@ internal Func SwapProcessStarterForTests( return previous; } + internal string ResolveWorkerAssemblyCacheRoot() + { + return _resolveWorkerAssemblyCacheRoot(); + } + + internal Func SwapWorkerAssemblyCacheRootForTests(Func resolver) + { + Debug.Assert(resolver != null, "resolver must not be null"); + + Func previous = _resolveWorkerAssemblyCacheRoot; + _resolveWorkerAssemblyCacheRoot = resolver; + return previous; + } + internal Func SwapWorkerAssemblyCompilerForTests( Func compiler) diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 41a93bb62a..68890adb80 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -53,7 +53,11 @@ command ran against: the backend kind. `dynamic_code_shared_worker_started` appears whenever no live shared Roslyn worker could serve the compile and one had to be started, with its source sync, worker assembly check and process spawn; `ready` is false when the start failed (no worker - assembly, or the process did not start). + assembly, or the process did not start). `workerAssemblySource` says where the worker + assembly came from: `existing` (already in this Editor's worker directory), `cache` (copied + from `/UnityCliLoopCompilation/RoslynWorkerCache//`, keyed by the worker source + and the compiler paths), or `built` (csc ran); `cachePublish` and `cachePublishError` say + whether a built assembly was added to that cache. - CLI side: only when the `ULOOP_DEBUG` environment variable is set to a value other than empty, `0`, or `false` (`cli/common/vibelog/cli_vibe.go`). - A missing line is evidence only when the define was set and the code path logs at all. From 4fa78e1b712df8843f234bfb82fb03f8e0622793 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 9 Oct 2026 02:11:14 +0900 Subject: [PATCH 23/29] perf: The first hot reload after a domain reload finds the edited assemblies' call sites, referenced-method sets and PDB documents already loaded (#3253) --- .../HotReloadCallSiteCacheE2ETests.cs | 1 + .../HotReloadCallSiteScannerTests.cs | 32 +- .../HotReloadCallSiteWarmUpItemTests.cs | 89 ++++++ .../HotReloadCallSiteWarmUpItemTests.cs.meta | 11 + .../HotReloadCompositionRootTests.cs | 6 +- .../HotReload/HotReloadDomainTestScope.cs | 24 +- .../HotReloadEditorTickPumpTestSafety.cs | 4 + .../HotReloadPatcherContractTests.cs | 3 +- .../Editor/HotReload/HotReloadPatcherTests.cs | 3 +- .../HotReloadPdbDocumentIndexTests.cs | 21 ++ .../HotReloadPdbDocumentWarmUpItemTests.cs | 80 +++++ ...otReloadPdbDocumentWarmUpItemTests.cs.meta | 11 + .../HotReloadReferencedMethodIndexTests.cs | 32 ++ ...eloadReferencedMethodSetWarmUpItemTests.cs | 94 ++++++ ...ReferencedMethodSetWarmUpItemTests.cs.meta | 11 + .../HotReload/HotReloadServicesTestScope.cs | 6 +- .../HotReloadWarmUpContextSourceTests.cs | 194 +++++++++++ .../HotReloadWarmUpContextSourceTests.cs.meta | 11 + .../HotReload/HotReloadWarmUpE2ETests.cs | 274 ++++++++++++++++ .../HotReload/HotReloadWarmUpE2ETests.cs.meta | 11 + .../HotReloadWarmUpEditorHooksTests.cs | 88 +++++ .../HotReloadWarmUpEditorHooksTests.cs.meta | 11 + .../HotReload/HotReloadWarmUpItemsTests.cs | 30 ++ .../HotReloadWarmUpItemsTests.cs.meta | 11 + .../HotReloadWarmUpTargetLedgerTests.cs | 153 +++++++++ .../HotReloadWarmUpTargetLedgerTests.cs.meta | 11 + .../HotReload/HotReloadWarmUpTestDoubles.cs | 173 ++++++++++ .../HotReloadWarmUpTestDoubles.cs.meta | 11 + .../Editor/HotReload/HotReloadWarmUpTests.cs | 287 +++++++++++++++++ .../HotReload/HotReloadWarmUpTests.cs.meta | 11 + .../HotReload/TransformWorkerHostTests.cs | 16 +- .../HotReload/HotReloadCallSiteScanner.cs | 34 ++ .../HotReload/HotReloadCallSiteWarmUpItem.cs | 42 +++ .../HotReloadCallSiteWarmUpItem.cs.meta | 11 + .../HotReload/HotReloadCompositionRoot.cs | 36 ++- .../HotReload/HotReloadEditorStartup.cs | 12 + .../HotReload/HotReloadOrchestrator.cs | 37 ++- .../HotReloadPdbDocumentWarmUpItem.cs | 38 +++ .../HotReloadPdbDocumentWarmUpItem.cs.meta | 11 + .../HotReloadReferencedMethodIndex.cs | 67 ++-- .../HotReloadReferencedMethodSetWarmUpItem.cs | 40 +++ ...eloadReferencedMethodSetWarmUpItem.cs.meta | 11 + .../HotReload/HotReloadServices.cs | 20 +- .../HotReload/HotReloadWarmUp.cs | 301 ++++++++++++++++++ .../HotReload/HotReloadWarmUp.cs.meta | 11 + .../HotReload/HotReloadWarmUpContext.cs | 24 ++ .../HotReload/HotReloadWarmUpContext.cs.meta | 11 + .../HotReload/HotReloadWarmUpContextSource.cs | 92 ++++++ .../HotReloadWarmUpContextSource.cs.meta | 11 + .../HotReload/HotReloadWarmUpEditorHooks.cs | 44 +++ .../HotReloadWarmUpEditorHooks.cs.meta | 11 + .../HotReload/HotReloadWarmUpItemOutcome.cs | 58 ++++ .../HotReloadWarmUpItemOutcome.cs.meta | 11 + .../HotReload/HotReloadWarmUpItems.cs | 27 ++ .../HotReload/HotReloadWarmUpItems.cs.meta | 11 + .../HotReload/HotReloadWarmUpTarget.cs | 38 +++ .../HotReload/HotReloadWarmUpTarget.cs.meta | 11 + .../HotReload/HotReloadWarmUpTargetLedger.cs | 160 ++++++++++ .../HotReloadWarmUpTargetLedger.cs.meta | 11 + .../IHotReloadWarmUpContextSource.cs | 45 +++ .../IHotReloadWarmUpContextSource.cs.meta | 11 + .../HotReload/IHotReloadWarmUpItem.cs | 22 ++ .../HotReload/IHotReloadWarmUpItem.cs.meta | 11 + .../HotReload/Shared/HotReloadConstants.cs | 17 + .../Shared/HotReloadPdbDocumentIndex.cs | 73 +++-- .../HotReload/Shared/TransformWorkerHost.cs | 9 +- .../Shared/TransformWorkerRequestTiming.cs | 12 + docs/hot-reload.md | 14 +- docs/vibe-logs.md | 18 +- 69 files changed, 3072 insertions(+), 71 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCallSiteWarmUpItemTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCallSiteWarmUpItemTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPdbDocumentWarmUpItemTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPdbDocumentWarmUpItemTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadReferencedMethodSetWarmUpItemTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadReferencedMethodSetWarmUpItemTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadWarmUpTargetLedgerTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadWarmUpTargetLedgerTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItems.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItems.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTarget.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTarget.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTargetLedger.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTargetLedger.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpContextSource.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpContextSource.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2ETests.cs index 7220607ed2..59e4e2d455 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2ETests.cs @@ -56,6 +56,7 @@ public async Task Run_SecondRunOnTheSameFile_ReadsNoCompiledAssemblyAgain() string source = File.ReadAllText(fixturePath); Assert.That(source, Does.Contain(ReadBody), "Precondition: the Read body anchor must exist."); int scannedAssemblyCount = CountScannedAssemblies(); + await _scope.InstalledWarmUpStopped; // Why clear: entries cached by earlier tests would let the first run read nothing, // which could not tell a cached dll from a scan that never ran. HotReloadCompiledCallSiteCache.Shared.Clear(); diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerTests.cs index 2b549965ab..82a6f513a8 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading.Tasks; using NUnit.Framework; @@ -549,8 +550,9 @@ public void FindCallSites_CrossAssemblyNestedTarget_ReportsCaller() /// lists the referencing assembly as skipped. /// [Test] - public void FindCallSites_TargetNoOtherAssemblyMentions_ReadsOnlyTheTargetAssembly() + public async Task FindCallSites_TargetNoOtherAssemblyMentions_ReadsOnlyTheTargetAssembly() { + await StopInstalledWarmUpAsync(); HotReloadCompiledCallSiteCache.Shared.Clear(); int before = HotReloadCompiledCallSiteCache.Shared.LoadCount; @@ -570,8 +572,9 @@ public void FindCallSites_TargetNoOtherAssemblyMentions_ReadsOnlyTheTargetAssemb /// it as skipped. /// [Test] - public void FindCallSites_CrossAssemblyCaller_ReadsTheCallerAssembly() + public async Task FindCallSites_CrossAssemblyCaller_ReadsTheCallerAssembly() { + await StopInstalledWarmUpAsync(); HotReloadCompiledCallSiteCache.Shared.Clear(); int before = HotReloadCompiledCallSiteCache.Shared.LoadCount; @@ -588,6 +591,31 @@ public void FindCallSites_CrossAssemblyCaller_ReadsTheCallerAssembly() Assert.That(result.Hits.Count, Is.EqualTo(1)); } + /// + /// What: the referencing dlls of the test assembly include the cross-assembly caller's dll + /// and never the test assembly's own dll. + /// + [Test] + public void CollectReferencingDllPaths_ReturnsTheCrossAssemblyCallerAndNotTheTargetItself() + { + string projectRoot = GetProjectRoot(); + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + + IReadOnlyList dllPaths = HotReloadCallSiteScanner.CollectReferencingDllPaths( + projectRoot, + GetTestAssemblyName()); + + Assert.That(dllPaths, Does.Contain(layout.DllPath(CrossAssemblyCallerAssemblyName))); + Assert.That(dllPaths, Does.Not.Contain(layout.DllPath(GetTestAssemblyName()))); + } + + // Why: the installed warm-up loads into the shared cache on a pool thread after a reload, + // and a read it makes between Clear and the scan would be counted as the scan's. + private static Task StopInstalledWarmUpAsync() + { + return HotReloadCompositionRoot.Services.WarmUp.Shutdown(HotReloadConstants.WarmUpShutdownTriggerTestScope); + } + private static List FindHits( string typeMetadataName, string methodName, diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallSiteWarmUpItemTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCallSiteWarmUpItemTests.cs new file mode 100644 index 0000000000..5d4617b0df --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCallSiteWarmUpItemTests.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.Threading; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of the call-site warm-up item: it loads each target's dll into the cache it is + /// given once, and throws before the next dll once cancelled. Works on a copy of the test assembly in a + /// private temp directory and a cache of its own. + /// + public sealed class HotReloadCallSiteWarmUpItemTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + + private string _tempDirectory; + private HotReloadCompiledCallSiteCache _cache; + + [SetUp] + public void SetUp() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), "uloop-call-site-warm-up-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDirectory); + _cache = new HotReloadCompiledCallSiteCache(long.MaxValue); + } + + [TearDown] + public void TearDown() + { + _cache.Clear(); + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + } + + /// + /// What: a target's dll is loaded once, and loading again into the same cache reads nothing. + /// + [Test] + public void LoadCallSites_LoadsTheDllOnce_AndAgainReadsNothing() + { + HotReloadWarmUpTarget[] targets = { CopyTarget() }; + + int first = HotReloadCallSiteWarmUpItem.LoadCallSites(targets, _cache, CancellationToken.None); + int afterFirst = _cache.LoadCount; + HotReloadCallSiteWarmUpItem.LoadCallSites(targets, _cache, CancellationToken.None); + + Assert.That(first, Is.EqualTo(1), "loaded targets"); + Assert.That(afterFirst, Is.EqualTo(1), "reads after the first load"); + Assert.That(_cache.LoadCount, Is.EqualTo(1), "reads after the second load"); + } + + /// + /// What: a cancelled token throws before the first dll, so the item is not reported done, and loads nothing. + /// + [Test] + public void LoadCallSites_WhenCancelled_ThrowsAndLoadsNothing() + { + HotReloadWarmUpTarget[] targets = { CopyTarget() }; + + Assert.Throws( + () => HotReloadCallSiteWarmUpItem.LoadCallSites(targets, _cache, new CancellationToken(true))); + + Assert.That(_cache.LoadCount, Is.EqualTo(0), "reads"); + } + + private HotReloadWarmUpTarget CopyTarget() + { + string source = Path.Combine( + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + HotReloadConstants.ScriptAssembliesRelativeDirectory, + TestAssemblyName + HotReloadConstants.CompiledAssemblyExtension); + string dllPath = Path.Combine(_tempDirectory, Path.GetFileName(source)); + File.Copy(source, dllPath); + return new HotReloadWarmUpTarget( + TestAssemblyName, + dllPath, + Path.ChangeExtension(dllPath, ".pdb"), + Array.Empty()); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallSiteWarmUpItemTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCallSiteWarmUpItemTests.cs.meta new file mode 100644 index 0000000000..204f9d621c --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCallSiteWarmUpItemTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 101c199c85b6a4f428b4e4086e98cf2f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs index f08dfee029..95f6e395f7 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs @@ -182,7 +182,8 @@ private static HotReloadServices CreateServicesSharing(HotReloadDomain domain) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => { })); + new HotReloadSourceSnapshotCapture(() => { }), + installed.WarmUp); } private static HotReloadServices CreateServicesOwning(HotReloadDomain domain) @@ -195,7 +196,8 @@ private static HotReloadServices CreateServicesOwning(HotReloadDomain domain) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => { })); + new HotReloadSourceSnapshotCapture(() => { }), + HotReloadWarmUpTestDoubles.CreateInert()); } private static HotReloadIntroducedTypeArtifact CreateArtifact(string metadataName) diff --git a/Assets/Tests/Editor/HotReload/HotReloadDomainTestScope.cs b/Assets/Tests/Editor/HotReload/HotReloadDomainTestScope.cs index 42a2eae47c..81d7a3eb8d 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadDomainTestScope.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadDomainTestScope.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using io.github.hatayama.UnityCliLoop.FirstPartyTools; @@ -22,8 +23,17 @@ internal sealed class HotReloadDomainTestScope : IDisposable private bool _disposed; internal HotReloadDomainTestScope() + : this(HotReloadCompositionRoot.CreateProductionServices()) { - HotReloadServices services = HotReloadCompositionRoot.CreateProductionServices(); + } + + private HotReloadDomainTestScope(HotReloadServices services) + { + // Why stop the installed warm-up first: it reads the shared caches on a pool thread, + // and a test that counts their reads must not race it. The scope's own run yields to + // the scope's warm-up, never to the installed one. + InstalledWarmUpStopped = HotReloadCompositionRoot.Services.WarmUp.Shutdown( + HotReloadConstants.WarmUpShutdownTriggerTestScope); // The capture is main-thread only, and a run inside the scope normalizes script paths // against these roots on the background threads it switches to. @@ -32,6 +42,18 @@ internal HotReloadDomainTestScope() _replacement = HotReloadCompositionRoot.BeginReplacement(services); } + /// + /// Completes once the warm-up that was installed before this scope has stopped. A test that + /// counts reads of a shared cache awaits it before it clears the cache. + /// + internal Task InstalledWarmUpStopped { get; } + + /// A scope whose services run instead of a production one. + internal static HotReloadDomainTestScope WithWarmUp(HotReloadWarmUp warmUp) + { + return new HotReloadDomainTestScope(HotReloadCompositionRoot.CreateProductionServicesWithWarmUp(warmUp)); + } + public void Dispose() { if (_disposed) diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorTickPumpTestSafety.cs b/Assets/Tests/Editor/HotReload/HotReloadEditorTickPumpTestSafety.cs index 95571aa2c0..96ca1ed480 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadEditorTickPumpTestSafety.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadEditorTickPumpTestSafety.cs @@ -2,6 +2,7 @@ using UnityEditor; +using io.github.hatayama.UnityCliLoop.FirstPartyTools; using io.github.hatayama.UnityCliLoop.InternalAPIBridge; namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload @@ -23,6 +24,9 @@ public sealed class HotReloadEditorTickPumpTestSafety [OneTimeSetUp] public void StartPump() { + // Why stop the installed warm-up once for the whole run: it reads the shared caches on + // a pool thread after a reload, and the tests that count those reads must not race it. + _ = HotReloadCompositionRoot.Services.WarmUp.Shutdown(HotReloadConstants.WarmUpShutdownTriggerTestRun); StopPump(); // EditorApplication.SignalTick is [ThreadSafe] in the Editor bindings, which is what // allows the timer thread to call it. diff --git a/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs index 177b03aacb..f7a1b892e4 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs @@ -216,7 +216,8 @@ private static IDisposable BeginReplacementWith(IHotReloadHarmony harmony) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => { }))); + new HotReloadSourceSnapshotCapture(() => { }), + HotReloadWarmUpTestDoubles.CreateInert())); } private static MethodInfo GetPatchTarget() diff --git a/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs index 0f9f3528cc..006c24873b 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs @@ -90,7 +90,8 @@ private static IDisposable BeginReplacementWith(IHotReloadHarmony harmony) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => { }))); + new HotReloadSourceSnapshotCapture(() => { }), + HotReloadWarmUpTestDoubles.CreateInert())); } /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs index 970fcbdda0..22ce00f397 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs @@ -148,6 +148,27 @@ public void TryFindDocument_SameFileTwice_ReadsThePdbOnce() Assert.That(_index.LoadCount, Is.EqualTo(1)); } + /// + /// What: a preload reads the dll and the PDB once, and a lookup afterwards finds the file + /// without reading them again. + /// + [Test] + public void Preload_WalksOnce_AndTryFindDocumentAfterwardsWalksNothing() + { + string dllPath = DllPath(TestAssemblyName); + + _index.Preload( + dllPath, + Path.ChangeExtension(dllPath, ".pdb"), + HotReloadSourceSnapshotter.ReadAssemblyMvid(dllPath)); + int afterPreload = _index.LoadCount; + bool found = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(afterPreload, Is.EqualTo(1), "after preload"); + Assert.That(found, Is.True, "found"); + Assert.That(_index.LoadCount, Is.EqualTo(1), "after lookup"); + } + /// /// What: a newer write time on the dll makes the next lookup read the files again. /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentWarmUpItemTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentWarmUpItemTests.cs new file mode 100644 index 0000000000..fce3f4e6b1 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentWarmUpItemTests.cs @@ -0,0 +1,80 @@ +using System; +using System.IO; +using System.Threading; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of the PDB document warm-up item: it preloads each target's document list into + /// the index it is given, once. Uses an index that persists into a private temp directory, + /// so the project's persisted lists are never read. + /// + public sealed class HotReloadPdbDocumentWarmUpItemTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + + private string _persistenceDirectory; + + [SetUp] + public void SetUp() + { + _persistenceDirectory = Path.Combine(Path.GetTempPath(), "uloop-pdb-warm-up-" + Guid.NewGuid().ToString("N")); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_persistenceDirectory)) + { + Directory.Delete(_persistenceDirectory, recursive: true); + } + } + + /// + /// What: a target's dll and PDB are read once, and preloading again reads nothing. + /// + [Test] + public void PreloadDocuments_ReadsThePdbOnce_AndAgainReadsNothing() + { + HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(_persistenceDirectory); + HotReloadWarmUpTarget[] targets = { CreateTarget() }; + + HotReloadPdbDocumentWarmUpItem.PreloadDocuments(targets, index, CancellationToken.None); + int afterFirst = index.LoadCount; + HotReloadPdbDocumentWarmUpItem.PreloadDocuments(targets, index, CancellationToken.None); + + Assert.That(afterFirst, Is.EqualTo(1), "reads after the first preload"); + Assert.That(index.LoadCount, Is.EqualTo(1), "reads after the second preload"); + } + + /// + /// What: a cancelled token throws before the first target, so the item is not reported done, and reads nothing. + /// + [Test] + public void PreloadDocuments_WhenCancelled_ThrowsAndReadsNothing() + { + HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(_persistenceDirectory); + HotReloadWarmUpTarget[] targets = { CreateTarget() }; + + Assert.Throws( + () => HotReloadPdbDocumentWarmUpItem.PreloadDocuments(targets, index, new CancellationToken(true))); + + Assert.That(index.LoadCount, Is.EqualTo(0), "reads"); + } + + private static HotReloadWarmUpTarget CreateTarget() + { + string dllPath = Path.Combine( + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + HotReloadConstants.ScriptAssembliesRelativeDirectory, + TestAssemblyName + HotReloadConstants.CompiledAssemblyExtension); + return new HotReloadWarmUpTarget(TestAssemblyName, dllPath, Path.ChangeExtension(dllPath, ".pdb"), Array.Empty()); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentWarmUpItemTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentWarmUpItemTests.cs.meta new file mode 100644 index 0000000000..cf71827621 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentWarmUpItemTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 20f7b0b69aca342069067926bc22a0fb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs b/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs index abd0d4ada2..7eea0118a3 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodIndexTests.cs @@ -159,6 +159,38 @@ public void MentionsAny_SameDllTwice_ReadsOnce() Assert.That(index.LoadCount, Is.EqualTo(1)); } + /// + /// What: a preload reads the dll once, and a question afterwards is answered without reading it again. + /// + [Test] + public void Preload_ReadsTheDllOnce_AndMentionsAnyAfterwardsReadsNothing() + { + HotReloadReferencedMethodIndex index = NewIndex(); + + index.Preload(CrossDll()); + int afterPreload = index.LoadCount; + bool mentioned = index.MentionsAny(CrossDll(), CalledKeys()); + + Assert.That(afterPreload, Is.EqualTo(1), "after preload"); + Assert.That(index.LoadCount, Is.EqualTo(1), "after MentionsAny"); + Assert.That(mentioned, Is.True, "mentioned"); + } + + /// + /// What: a preload with a persisted set on disk reads that file instead of the dll. + /// + [Test] + public void Preload_WithAPersistedSet_ReadsTheFileInsteadOfTheDll() + { + NewIndex().MentionsAny(CrossDll(), CalledKeys()); + HotReloadReferencedMethodIndex second = NewIndex(); + + second.Preload(CrossDll()); + + Assert.That(second.LoadCount, Is.EqualTo(0), "dll reads"); + Assert.That(second.PersistedLoadCount, Is.EqualTo(1), "file reads"); + } + /// /// What: a dll whose write time changed after it was read is read again. /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodSetWarmUpItemTests.cs b/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodSetWarmUpItemTests.cs new file mode 100644 index 0000000000..e0b14f9c37 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodSetWarmUpItemTests.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using System.Threading; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of the referenced-method set warm-up item: it preloads the set of every + /// referencing dll of every target into the index it is given, once. Uses an index that + /// persists into a private temp directory, so the project's persisted sets are never read. + /// + public sealed class HotReloadReferencedMethodSetWarmUpItemTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string CrossAssemblyCallerAssemblyName = "UnityCLILoop.Tests.Editor.HotReload.CallSiteCrossAssembly"; + + private string _persistenceDirectory; + + [SetUp] + public void SetUp() + { + _persistenceDirectory = Path.Combine(Path.GetTempPath(), "uloop-set-warm-up-" + Guid.NewGuid().ToString("N")); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_persistenceDirectory)) + { + Directory.Delete(_persistenceDirectory, recursive: true); + } + } + + /// + /// What: each referencing dll is read once, and preloading again reads none of them. + /// + [Test] + public void PreloadSets_ReadsEachReferencingDllOnce_AndAgainReadsNothing() + { + HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(_persistenceDirectory); + HotReloadWarmUpTarget[] targets = + { + new HotReloadWarmUpTarget( + TestAssemblyName, + DllPath(TestAssemblyName), + Path.ChangeExtension(DllPath(TestAssemblyName), ".pdb"), + new[] { DllPath(CrossAssemblyCallerAssemblyName), DllPath(TestAssemblyName) }) + }; + + HotReloadReferencedMethodSetWarmUpItem.PreloadSets(targets, index, CancellationToken.None); + int afterFirst = index.LoadCount; + HotReloadReferencedMethodSetWarmUpItem.PreloadSets(targets, index, CancellationToken.None); + + Assert.That(afterFirst, Is.EqualTo(2), "reads after the first preload"); + Assert.That(index.LoadCount, Is.EqualTo(2), "reads after the second preload"); + } + + /// + /// What: a cancelled token throws before the first referencing dll, so the item is not reported done, and reads nothing. + /// + [Test] + public void PreloadSets_WhenCancelled_ThrowsAndReadsNothing() + { + HotReloadReferencedMethodIndex index = new HotReloadReferencedMethodIndex(_persistenceDirectory); + HotReloadWarmUpTarget[] targets = + { + new HotReloadWarmUpTarget( + TestAssemblyName, + DllPath(TestAssemblyName), + Path.ChangeExtension(DllPath(TestAssemblyName), ".pdb"), + new[] { DllPath(CrossAssemblyCallerAssemblyName) }) + }; + + Assert.Throws( + () => HotReloadReferencedMethodSetWarmUpItem.PreloadSets(targets, index, new CancellationToken(true))); + + Assert.That(index.LoadCount, Is.EqualTo(0), "reads"); + } + + private static string DllPath(string assemblyName) + { + return Path.Combine( + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + HotReloadConstants.ScriptAssembliesRelativeDirectory, + assemblyName + HotReloadConstants.CompiledAssemblyExtension); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodSetWarmUpItemTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodSetWarmUpItemTests.cs.meta new file mode 100644 index 0000000000..5a59fd45a0 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadReferencedMethodSetWarmUpItemTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7439b012d9a744b79a8ccf011ac5eb5c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadServicesTestScope.cs b/Assets/Tests/Editor/HotReload/HotReloadServicesTestScope.cs index 9d6d042969..0caf21ffcc 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadServicesTestScope.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadServicesTestScope.cs @@ -103,6 +103,9 @@ internal static IDisposable BeginWith( // The capture is main-thread only, and a run inside the scope normalizes script paths // against these roots on the background threads it switches to. installed.PackageRootCapture.CaptureCurrent(); + // Why stop the installed warm-up: the scope's run yields to that same warm-up, but a + // test that counts cache reads must not race one already running on a pool thread. + _ = installed.WarmUp.Shutdown(HotReloadConstants.WarmUpShutdownTriggerTestScope); return HotReloadCompositionRoot.BeginReplacement( HotReloadCompositionRoot.CreateServices( installed.Domain, @@ -112,7 +115,8 @@ internal static IDisposable BeginWith( transformWorkerHost, buildDependencies, playModeQuery, - installed.SourceSnapshotCapture)); + installed.SourceSnapshotCapture, + installed.WarmUp)); } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs new file mode 100644 index 0000000000..092711dbc8 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of the warm-up's context source: when it skips and why, and which recorded + /// assemblies become targets. Works on a temp project root with empty dll and PDB files, + /// since the source only checks that they exist. + /// + public sealed class HotReloadWarmUpContextSourceTests + { + private string _projectRoot; + private HotReloadEditorStateSnapshot _state; + + [SetUp] + public void SetUp() + { + _projectRoot = Path.Combine(Path.GetTempPath(), "uloop-warm-up-source-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_projectRoot); + _state = new HotReloadEditorStateSnapshot(false, false, false); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_projectRoot)) + { + Directory.Delete(_projectRoot, true); + } + } + + /// + /// What: a project without a ledger skips with no_targets. + /// + [Test] + public void Capture_WithoutALedger_SkipsWithNoTargets() + { + Assert.That(Capture().SkipReason, Is.EqualTo(HotReloadWarmUpCapture.SkipReasonNoTargets)); + } + + /// + /// What: a compiling Editor skips with compiling even when the ledger has a compiled assembly. + /// + [Test] + public void Capture_WhileCompiling_SkipsWithCompiling() + { + RecordCompiled("A"); + _state = new HotReloadEditorStateSnapshot(true, false, false); + + HotReloadWarmUpCapture capture = Capture(); + + Assert.That(capture.SkipReason, Is.EqualTo(HotReloadWarmUpCapture.SkipReasonCompiling)); + Assert.That(capture.Context, Is.Null); + } + + /// + /// What: an importing Editor skips with updating. + /// + [Test] + public void Capture_WhileUpdating_SkipsWithUpdating() + { + RecordCompiled("A"); + _state = new HotReloadEditorStateSnapshot(false, true, false); + + Assert.That(Capture().SkipReason, Is.EqualTo(HotReloadWarmUpCapture.SkipReasonUpdating)); + } + + /// + /// What: a recorded assembly whose PDB is missing is left out of the targets. + /// + [Test] + public void Capture_WithoutThePdb_LeavesThatAssemblyOut() + { + RecordCompiled("A"); + WriteCompiledFile("B.dll"); + HotReloadWarmUpTargetLedger.Record(_projectRoot, new[] { "B", "A" }); + + HotReloadWarmUpCapture capture = Capture(); + + Assert.That(TargetNames(capture), Is.EqualTo(new[] { "A" })); + } + + /// + /// What: when no recorded assembly has its dll and PDB, the source skips with no_compiled_assembly. + /// + [Test] + public void Capture_WithNoCompiledAssembly_SkipsWithNoCompiledAssembly() + { + HotReloadWarmUpTargetLedger.Record(_projectRoot, new[] { "A", "B" }); + + Assert.That(Capture().SkipReason, Is.EqualTo(HotReloadWarmUpCapture.SkipReasonNoCompiledAssembly)); + } + + /// + /// What: of more recorded assemblies than the cap, the targets are the first ones in ledger order. + /// + [Test] + public void Capture_WithMoreThanTheCap_TakesTheMostRecentInLedgerOrder() + { + List names = new List(); + for (int index = 0; index < 10; index++) + { + string name = "Assembly" + index; + names.Add(name); + WriteCompiledFile(name + ".dll"); + WriteCompiledFile(name + ".pdb"); + } + + HotReloadWarmUpTargetLedger.Record(_projectRoot, names); + + Assert.That( + TargetNames(Capture()), + Is.EqualTo(names.GetRange(0, HotReloadWarmUpContextSource.MaxWarmedTargets))); + } + + /// + /// What: a target carries its dll and PDB paths and the referencing dlls the collector returns for it. + /// + [Test] + public void Capture_PutsThePathsAndTheReferencingDllsIntoTheTarget() + { + RecordCompiled("A"); + string[] referencing = { "referencing.dll" }; + HotReloadWarmUpContextSource source = new HotReloadWarmUpContextSource( + _projectRoot, + new HotReloadStubEditorStateSnapshotCapture(() => _state), + name => name == "A" ? referencing : Array.Empty()); + + HotReloadWarmUpTarget target = source.Capture().Context.Targets[0]; + + Assert.That(target.DllPath, Is.EqualTo(CompiledPath("A.dll")), "dll"); + Assert.That(target.PdbPath, Is.EqualTo(CompiledPath("A.pdb")), "pdb"); + Assert.That(target.ReferencingDllPaths, Is.EqualTo(referencing), "referencing"); + } + + /// + /// What: a failed last compile does not skip, because the loaded dlls are the last good build. + /// + [Test] + public void Capture_AfterAFailedCompile_StillReturnsTheTargets() + { + RecordCompiled("A"); + _state = new HotReloadEditorStateSnapshot(false, false, true); + + Assert.That(TargetNames(Capture()), Is.EqualTo(new[] { "A" })); + } + + private HotReloadWarmUpCapture Capture() + { + HotReloadWarmUpContextSource source = new HotReloadWarmUpContextSource( + _projectRoot, + new HotReloadStubEditorStateSnapshotCapture(() => _state), + name => Array.Empty()); + return source.Capture(); + } + + private void RecordCompiled(string name) + { + WriteCompiledFile(name + ".dll"); + WriteCompiledFile(name + ".pdb"); + HotReloadWarmUpTargetLedger.Record(_projectRoot, new[] { name }); + } + + private void WriteCompiledFile(string fileName) + { + string path = CompiledPath(fileName); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllBytes(path, Array.Empty()); + } + + private string CompiledPath(string fileName) + { + return Path.Combine(CompiledAssemblyLayout.Resolve(_projectRoot).CompiledAssembliesDirectory, fileName); + } + + private static List TargetNames(HotReloadWarmUpCapture capture) + { + Assert.That(capture.Context, Is.Not.Null, "skipped with " + capture.SkipReason); + List names = new List(); + foreach (HotReloadWarmUpTarget target in capture.Context.Targets) + { + names.Add(target.AssemblyName); + } + + return names; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs.meta new file mode 100644 index 0000000000..06a66f21f3 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7efe3cdc0bafb462598427e47c203632 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs new file mode 100644 index 0000000000..304c956597 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using Newtonsoft.Json.Linq; + +using NUnit.Framework; + +using UnityEditor.Compilation; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// End-to-end EditMode coverage of the warm-up and a real hot reload run: the run reuses what + /// the warm-up loaded, a run before the warm-up keeps it from starting, a run during it waits + /// for the item in flight, and every run records its assembly for the next domain's warm-up. + /// + public class HotReloadWarmUpE2ETests + { + private const string FixtureFileName = "HotReloadCallSiteCacheE2EFixture.cs"; + private const string FixtureProjectRelativePath = "Assets/Tests/Editor/HotReload/" + FixtureFileName; + private const string ReadBody = "return 1;"; + private const string LedgerSentinel = "WarmUpLedgerSentinel"; + + private HotReloadDomainTestScope _scope; + private List _ran; + private string _savedLedger; + + [SetUp] + public void SetUp() + { + _ran = new List(); + string ledgerPath = LedgerPath(); + _savedLedger = File.Exists(ledgerPath) ? File.ReadAllText(ledgerPath) : null; + VibeLogger.ClearMemoryLogs(); + } + + [TearDown] + public void TearDown() + { + _scope?.Dispose(); + _scope = null; + HotReloadAutoRefreshHold.SyncToActiveChanges(); + RestoreLedger(); + VibeLogger.ClearMemoryLogs(); + } + + /// + /// What: after the real warm-up finished, the run reads no compiled assembly the warm-up + /// already loaded for the edited assembly. + /// + [Test] + public async Task Run_AfterTheWarmUpFinished_ReadsNoCompiledAssemblyForTheEditedAssembly() + { + string testDll = TestAssemblyDllPath(); + HotReloadWarmUp warmUp = new HotReloadWarmUp( + new FixedContextSource(CreateTestAssemblyCapture(testDll)), + HotReloadWarmUpItems.CreateProduction()); + await BeginScope(warmUp); + // Why clear and require a read: the run loads the edited assembly's dll itself, and + // the entry outlives the run's hold, so "the run read nothing" alone would pass even + // when the warm-up loaded nothing. + HotReloadCompiledCallSiteCache.Shared.Clear(); + int before = HotReloadCompiledCallSiteCache.Shared.LoadCount; + + warmUp.Start(); + await warmUp.Completion; + int afterWarmUp = HotReloadCompiledCallSiteCache.Shared.LoadCount; + HotReloadCompiledCallSiteCache.Entry entry = HotReloadCompiledCallSiteCache.Shared.GetOrLoad(testDll); + + Assert.That(afterWarmUp - before, Is.GreaterThanOrEqualTo(1), "reads by the warm-up"); + Assert.That(HotReloadCompiledCallSiteCache.Shared.LoadCount, Is.EqualTo(afterWarmUp), "the warm-up loaded the test dll"); + Assert.That( + HotReloadWarmUpTestDoubles.ReadCompletedOutcomes(), + Is.EqualTo(new[] { "call_sites:done", "referenced_method_sets:done", "pdb_documents:done" })); + + await RunWithReadReturningAsync(2); + + Assert.That(HotReloadCompiledCallSiteCache.Shared.GetOrLoad(testDll), Is.SameAs(entry), "the run read the dll again"); + } + + /// + /// What: a run that comes before the warm-up's tick keeps the warm-up from starting. + /// + [Test] + public async Task Run_ThatStartedBeforeTheTick_LeavesTheWarmUpUnstarted() + { + HotReloadWarmUp warmUp = CreateWarmUp(new RecordingWarmUpItem("a", _ran)); + await BeginScope(warmUp); + + await RunWithReadReturningAsync(3); + warmUp.Start(); + + Assert.That(_ran, Is.Empty, "items run"); + Assert.That( + (string)HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogWarmUpSkipped)["reason"], + Is.EqualTo("run_started_first")); + } + + /// + /// What: a run that comes while an item is in flight does not start before that item + /// finishes, then applies and reports the wait as the warm_up_yield step. + /// + [Test] + public async Task Run_WhileAnItemIsInFlight_WaitsForItAndStartsOnlyAfterward() + { + PendingWarmUpItem pending = new PendingWarmUpItem("a", _ran); + HotReloadWarmUp warmUp = CreateWarmUp(pending); + await BeginScope(warmUp); + VibeLogger.ClearMemoryLogs(); + Task run; + try + { + warmUp.Start(); + run = RunWithReadReturningAsync(4); + + // Why this shows the wait: without it the run passes its first main-thread switch + // synchronously here and writes its file start before the task comes back. + Assert.That(ReadEntries(HotReloadConstants.VibeLogFileStart), Is.Empty, "file start before the item finished"); + } + finally + { + pending.Release.TrySetResult(true); + } + + await run; + + Assert.That(ReadEntries(HotReloadConstants.VibeLogFileStart), Is.Not.Empty, "file start after the item finished"); + JArray timingDetails = ReadEntries(HotReloadConstants.VibeLogTimingDetail); + Assert.That(timingDetails.Count, Is.EqualTo(1), "timing details"); + List steps = new List(); + foreach (JToken step in (JArray)timingDetails[0]["context"]["steps"]) + { + steps.Add((string)step["step"]); + } + + Assert.That(steps, Does.Contain(HotReloadConstants.TimingDetailStepWarmUpYield)); + } + + /// + /// What: a run puts the edited assembly first in the project's ledger and keeps the names + /// recorded before it. + /// + [Test] + public async Task Run_RecordsTheEditedAssemblyInTheLedger() + { + await BeginScope(HotReloadWarmUpTestDoubles.CreateInert()); + string root = ProjectRoot(); + // Why a sentinel first: every run in this project records the test assembly, so a + // ledger that already starts with it could not show this run's record. + HotReloadWarmUpTargetLedger.Record(root, new[] { LedgerSentinel }); + Assert.That(HotReloadWarmUpTargetLedger.Read(root)[0], Is.EqualTo(LedgerSentinel), "Precondition: the sentinel must lead."); + + await RunWithReadReturningAsync(5); + + IReadOnlyList names = HotReloadWarmUpTargetLedger.Read(root); + Assert.That(names[0], Is.EqualTo(TestAssemblyName()), "first"); + Assert.That(names[1], Is.EqualTo(LedgerSentinel), "second"); + } + + private async Task BeginScope(HotReloadWarmUp warmUp) + { + _scope = HotReloadDomainTestScope.WithWarmUp(warmUp); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + await _scope.InstalledWarmUpStopped; + } + + private HotReloadWarmUp CreateWarmUp(params IHotReloadWarmUpItem[] items) + { + return new HotReloadWarmUp(new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture()), items); + } + + private static HotReloadWarmUpCapture CreateTestAssemblyCapture(string testDll) + { + string root = ProjectRoot(); + return HotReloadWarmUpCapture.Ready( + new HotReloadWarmUpContext( + root, + new[] + { + new HotReloadWarmUpTarget( + TestAssemblyName(), + testDll, + Path.ChangeExtension(testDll, ".pdb"), + HotReloadCallSiteScanner.CollectReferencingDllPaths(root, TestAssemblyName())) + })); + } + + private static async Task RunWithReadReturningAsync(int value) + { + string fixturePath = FixturePath(); + string source = File.ReadAllText(fixturePath); + Assert.That(source, Does.Contain(ReadBody), "Precondition: the Read body anchor must exist."); + string editedPath = HotReloadTestSourceWriter.WriteEditedSource( + "WarmUpE2E.cs", + source.Replace(ReadBody, "return " + value + ";")); + HotReloadOrchestratorResult result = null; + try + { + result = await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { fixturePath }, + editedPath, + CancellationToken.None); + } + catch (OperationCanceledException exception) + { + // Why fail here: the test framework records an async test that ends canceled as + // passed, which would hide a run that never finished. + Assert.Fail("The run was canceled: " + exception.Message); + } + + Assert.That(new HotReloadCallSiteCacheE2EFixture().Read(), Is.EqualTo(value), "Precondition: the run must apply."); + return result; + } + + private static JArray ReadEntries(string operation) + { + return JArray.Parse(VibeLogger.GetLogsForAi(operation)); + } + + private void RestoreLedger() + { + string ledgerPath = LedgerPath(); + if (_savedLedger != null) + { + File.WriteAllText(ledgerPath, _savedLedger); + return; + } + + if (File.Exists(ledgerPath)) + { + File.Delete(ledgerPath); + } + } + + private static string LedgerPath() + { + return Path.Combine( + ProjectRoot(), + HotReloadConstants.WarmUpRelativeDirectory, + HotReloadConstants.WarmUpTargetsFileName); + } + + private static string TestAssemblyName() + { + return Path.GetFileNameWithoutExtension( + CompilationPipeline.GetAssemblyNameFromScriptPath(FixtureProjectRelativePath)); + } + + private static string TestAssemblyDllPath() + { + return CompiledAssemblyLayout.Resolve(ProjectRoot()).DllPath(TestAssemblyName()); + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + + private static string FixturePath() + { + string path = Path.GetFullPath( + Path.Combine(Application.dataPath, "Tests", "Editor", "HotReload", FixtureFileName)); + Assert.That(File.Exists(path), Is.True, "Fixture missing: " + path); + return path; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs.meta new file mode 100644 index 0000000000..91d0bc97d1 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4624db16f988b42c783a80c4f377f950 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs new file mode 100644 index 0000000000..eb5b6e30dc --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// The domain-reload and compile-start callbacks stop the installed warm-up and name + /// themselves as what stopped it. + /// + public sealed class HotReloadWarmUpEditorHooksTests + { + private Func _originalGetServices; + private List _ran; + private PendingWarmUpItem _pending; + private HotReloadWarmUp _warmUp; + private HotReloadDomainTestScope _scope; + + [SetUp] + public void SetUp() + { + _originalGetServices = HotReloadWarmUpEditorHooks.GetServices; + _ran = new List(); + _pending = new PendingWarmUpItem("a", _ran); + _warmUp = new HotReloadWarmUp( + new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture()), + new IHotReloadWarmUpItem[] { _pending, new RecordingWarmUpItem("b", _ran) }); + _scope = HotReloadDomainTestScope.WithWarmUp(_warmUp); + HotReloadWarmUpEditorHooks.GetServices = () => HotReloadCompositionRoot.Services; + VibeLogger.ClearMemoryLogs(); + } + + [TearDown] + public void TearDown() + { + _pending.Release.TrySetResult(true); + _scope.Dispose(); + HotReloadWarmUpEditorHooks.GetServices = _originalGetServices; + VibeLogger.ClearMemoryLogs(); + } + + /// + /// What: the reload callback stops a running warm-up and records beforeAssemblyReload. + /// + [Test] + public async Task ShutdownForReload_StopsTheRunningWarmUp() + { + await StopWith(HotReloadWarmUpEditorHooks.ShutdownForReload); + + AssertStoppedBy(HotReloadConstants.WarmUpShutdownTriggerBeforeAssemblyReload); + } + + /// + /// What: the compile-start callback stops a running warm-up and records compilationStarted. + /// + [Test] + public async Task ShutdownForCompile_StopsTheRunningWarmUp() + { + await StopWith(() => HotReloadWarmUpEditorHooks.ShutdownForCompile(null)); + + AssertStoppedBy(HotReloadConstants.WarmUpShutdownTriggerCompilationStarted); + } + + private async Task StopWith(Action hook) + { + _warmUp.Start(); + Assert.That(_warmUp.State, Is.EqualTo(HotReloadWarmUpState.Running), "Precondition: item a must be in flight."); + + hook(); + _pending.Release.TrySetResult(true); + await _warmUp.Completion; + } + + private void AssertStoppedBy(string trigger) + { + Assert.That(_ran, Is.EqualTo(new[] { "a" }), "items run"); + Assert.That(HotReloadWarmUpTestDoubles.ReadCompletedOutcomes(), Is.EqualTo(new[] { "a:done", "b:cancelled" })); + Assert.That( + (string)HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogWarmUpComplete)["cancelledBy"], + Is.EqualTo(trigger)); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs.meta new file mode 100644 index 0000000000..4bf426b643 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a4259cadf27d14839996f9101029e365 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs new file mode 100644 index 0000000000..f2123e5135 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Pins the order of the production warm-up items. + /// + public sealed class HotReloadWarmUpItemsTests + { + /// + /// What: the items run costliest first, with the PDB documents last because their lookup + /// can throw an exception that stops the items after it. + /// + [Test] + public void CreateProduction_ReturnsCallSitesThenReferencedMethodSetsThenPdbDocuments() + { + List names = new List(); + foreach (IHotReloadWarmUpItem item in HotReloadWarmUpItems.CreateProduction()) + { + names.Add(item.Name); + } + + Assert.That(names, Is.EqualTo(new[] { "call_sites", "referenced_method_sets", "pdb_documents" })); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs.meta new file mode 100644 index 0000000000..57f2c2951f --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a28abdb169feb45308e12ac958139b29 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpTargetLedgerTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTargetLedgerTests.cs new file mode 100644 index 0000000000..c062440d46 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTargetLedgerTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of the ledger of assemblies the hot reload runs of a project edited: most recent + /// first, no duplicates, capped, and written only when its content changes. + /// + public sealed class HotReloadWarmUpTargetLedgerTests + { + private const string Header = "uloop-hot-reload-warm-up-targets 1"; + + // Why a fixed past time: a write within the file system's timestamp resolution of the + // previous one could leave the write time unchanged and hide a rewrite. + private static readonly DateTime PinnedWriteTimeUtc = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private string _projectRoot; + + [SetUp] + public void SetUp() + { + _projectRoot = Path.Combine(Path.GetTempPath(), "uloop-warm-up-ledger-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_projectRoot); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_projectRoot)) + { + Directory.Delete(_projectRoot, true); + } + } + + /// + /// What: the first record creates the file with the header and the names in the given order. + /// + [Test] + public void Record_WithoutAFile_WritesTheHeaderAndTheNames() + { + HotReloadWarmUpTargetLedger.Record(_projectRoot, new[] { "A", "B" }); + + Assert.That(File.ReadAllText(LedgerPath(), Encoding.UTF8), Is.EqualTo(Header + "\nA\nB\n")); + } + + /// + /// What: a later record puts its names first and keeps the earlier names after them, once each. + /// + [Test] + public void Record_Again_PutsTheNewNamesFirstAndKeepsTheRestWithoutDuplicates() + { + HotReloadWarmUpTargetLedger.Record(_projectRoot, new[] { "A", "B", "C" }); + + HotReloadWarmUpTargetLedger.Record(_projectRoot, new[] { "C", "D", "C" }); + + Assert.That(HotReloadWarmUpTargetLedger.Read(_projectRoot), Is.EqualTo(new[] { "C", "D", "A", "B" })); + } + + /// + /// What: the ledger keeps at most the 32 most recent names. + /// + [Test] + public void Record_MoreThanTheCap_KeepsTheFirst32() + { + List names = new List(); + for (int index = 0; index < 40; index++) + { + names.Add("Assembly" + index); + } + + HotReloadWarmUpTargetLedger.Record(_projectRoot, names); + + Assert.That(HotReloadWarmUpTargetLedger.Read(_projectRoot), Is.EqualTo(names.GetRange(0, 32))); + } + + /// + /// What: a record that leaves the content as it is does not rewrite the file. + /// + [Test] + public void Record_WithTheSameContent_DoesNotRewriteTheFile() + { + HotReloadWarmUpTargetLedger.Record(_projectRoot, new[] { "A", "B" }); + File.SetLastWriteTimeUtc(LedgerPath(), PinnedWriteTimeUtc); + + HotReloadWarmUpTargetLedger.Record(_projectRoot, new[] { "A" }); + + Assert.That(File.GetLastWriteTimeUtc(LedgerPath()), Is.EqualTo(PinnedWriteTimeUtc)); + } + + /// + /// What: an empty record writes nothing. + /// + [Test] + public void Record_WithNoNames_WritesNothing() + { + HotReloadWarmUpTargetLedger.Record(_projectRoot, Array.Empty()); + + Assert.That(File.Exists(LedgerPath()), Is.False); + } + + /// + /// What: a project without a ledger reads as no names. + /// + [Test] + public void Read_WithoutAFile_ReturnsNoNames() + { + Assert.That(HotReloadWarmUpTargetLedger.Read(_projectRoot), Is.Empty); + } + + /// + /// What: a file with another header reads as no names. + /// + [Test] + public void Read_WithAnotherHeader_ReturnsNoNames() + { + WriteLedger("uloop-hot-reload-warm-up-targets 2\nA\n"); + + Assert.That(HotReloadWarmUpTargetLedger.Read(_projectRoot), Is.Empty); + } + + /// + /// What: blank lines, including CRLF leftovers, are skipped. + /// + [Test] + public void Read_SkipsBlankLines() + { + WriteLedger(Header + "\r\nA\r\n\n \nB\n"); + + Assert.That(HotReloadWarmUpTargetLedger.Read(_projectRoot), Is.EqualTo(new[] { "A", "B" })); + } + + private string LedgerPath() + { + return Path.Combine( + _projectRoot, + HotReloadConstants.WarmUpRelativeDirectory, + HotReloadConstants.WarmUpTargetsFileName); + } + + private void WriteLedger(string content) + { + Directory.CreateDirectory(Path.GetDirectoryName(LedgerPath())); + File.WriteAllText(LedgerPath(), content, new UTF8Encoding(false)); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpTargetLedgerTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTargetLedgerTests.cs.meta new file mode 100644 index 0000000000..230f2eecdc --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTargetLedgerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0ee574600fb5145128bb4b650d97707c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs new file mode 100644 index 0000000000..37c40e37bd --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Newtonsoft.Json.Linq; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Stand-ins for the warm-up's context source and items, shared by the warm-up tests and by + /// tests that build their own services graph. + /// + internal static class HotReloadWarmUpTestDoubles + { + internal const string TargetAssemblyName = "WarmUpTarget"; + + /// A warm-up whose context source always skips, so it never touches a cache. + internal static HotReloadWarmUp CreateInert() + { + return new HotReloadWarmUp( + new FixedContextSource(HotReloadWarmUpCapture.Skipped(HotReloadWarmUpCapture.SkipReasonNoTargets)), + Array.Empty()); + } + + internal static HotReloadWarmUpCapture CreateReadyCapture() + { + return HotReloadWarmUpCapture.Ready( + new HotReloadWarmUpContext( + "project-root", + new[] + { + new HotReloadWarmUpTarget( + TargetAssemblyName, + TargetAssemblyName + ".dll", + TargetAssemblyName + ".pdb", + Array.Empty()) + })); + } + + /// The context of the only entry of in the in-memory vibe log. + internal static JObject ReadSingleVibeContext(string operation) + { + JArray entries = JArray.Parse(VibeLogger.GetLogsForAi(operation)); + NUnit.Framework.Assert.That(entries.Count, NUnit.Framework.Is.EqualTo(1), "entries of " + operation); + return (JObject)entries[0]["context"]; + } + + /// The outcome names of the items of the only hot_reload_warm_up_complete entry, in order. + internal static List ReadCompletedOutcomes() + { + JObject context = ReadSingleVibeContext(HotReloadConstants.VibeLogWarmUpComplete); + List outcomes = new List(); + foreach (JToken item in (JArray)context["items"]) + { + outcomes.Add((string)item["name"] + ":" + (string)item["outcome"]); + } + + return outcomes; + } + } + + /// A context source that returns the same capture every time. + internal sealed class FixedContextSource : IHotReloadWarmUpContextSource + { + private readonly HotReloadWarmUpCapture _capture; + + internal FixedContextSource(HotReloadWarmUpCapture capture) + { + _capture = capture; + } + + internal int CaptureCount { get; private set; } + + public HotReloadWarmUpCapture Capture() + { + CaptureCount++; + return _capture; + } + } + + /// An item that appends its name to a shared list when it runs and then completes. + internal sealed class RecordingWarmUpItem : IHotReloadWarmUpItem + { + private readonly List _ran; + + internal RecordingWarmUpItem(string name, List ran) + { + Name = name; + _ran = ran; + } + + public string Name { get; } + + public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) + { + _ran.Add(Name); + return Task.CompletedTask; + } + } + + /// An item that appends its name when it runs and completes only when the test completes its source. + internal sealed class PendingWarmUpItem : IHotReloadWarmUpItem + { + private readonly List _ran; + + internal PendingWarmUpItem(string name, List ran) + { + Name = name; + _ran = ran; + } + + public string Name { get; } + + internal TaskCompletionSource Release { get; } = new TaskCompletionSource(); + + public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) + { + _ran.Add(Name); + return Release.Task; + } + } + + /// + /// An item of two units: it appends its name for the first, waits until the test completes + /// its source, checks the token between the units as production items do, and appends its + /// name with "#2" for the second. + /// + internal sealed class TwoUnitWarmUpItem : IHotReloadWarmUpItem + { + private readonly List _ran; + + internal TwoUnitWarmUpItem(string name, List ran) + { + Name = name; + _ran = ran; + } + + public string Name { get; } + + internal TaskCompletionSource BetweenUnits { get; } = new TaskCompletionSource(); + + public async Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) + { + _ran.Add(Name); + await BetweenUnits.Task; + ct.ThrowIfCancellationRequested(); + _ran.Add(Name + "#2"); + } + } + + /// An item whose task faults with the given exception. + internal sealed class ThrowingWarmUpItem : IHotReloadWarmUpItem + { + private readonly Exception _exception; + + internal ThrowingWarmUpItem(string name, Exception exception) + { + Name = name; + _exception = exception; + } + + public string Name { get; } + + public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) + { + return Task.FromException(_exception); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs.meta new file mode 100644 index 0000000000..eb96e6e8c4 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5eebbc4c802414a15bb933dae5e1baef +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs new file mode 100644 index 0000000000..6ef56bcd29 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +using Newtonsoft.Json.Linq; + +using NUnit.Framework; + +using UnityEngine; +using UnityEngine.TestTools; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of the warm-up facade: when it runs its items, how a run or a shutdown stops it, + /// and what it reports. Items are stand-ins; an item held by a TaskCompletionSource stands + /// for work in flight. + /// + public sealed class HotReloadWarmUpTests + { + private List _ran; + + [SetUp] + public void SetUp() + { + _ran = new List(); + VibeLogger.ClearMemoryLogs(); + } + + [TearDown] + public void TearDown() + { + VibeLogger.ClearMemoryLogs(); + } + + /// + /// What: a source that skips leaves every item unrun and the skip entry names its reason. + /// + [Test] + public void Start_WhenTheSourceSaysSkip_RunsNoItemAndLogsTheReason() + { + HotReloadWarmUp warmUp = new HotReloadWarmUp( + new FixedContextSource(HotReloadWarmUpCapture.Skipped(HotReloadWarmUpCapture.SkipReasonCompiling)), + new IHotReloadWarmUpItem[] { new RecordingWarmUpItem("a", _ran) }); + + warmUp.Start(); + + Assert.That(_ran, Is.Empty, "items run"); + Assert.That( + (string)HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogWarmUpSkipped)["reason"], + Is.EqualTo(HotReloadWarmUpCapture.SkipReasonCompiling)); + Assert.That(warmUp.State, Is.EqualTo(HotReloadWarmUpState.Finished), "state"); + } + + /// + /// What: with a context, every item runs once in order and the entry lists one done outcome per item. + /// + [Test] + public async Task Start_RunsTheItemsInOrderAndLogsOneDoneOutcomePerItem() + { + HotReloadWarmUp warmUp = CreateReady(new RecordingWarmUpItem("a", _ran), new RecordingWarmUpItem("b", _ran)); + + warmUp.Start(); + await warmUp.Completion; + + Assert.That(_ran, Is.EqualTo(new[] { "a", "b" }), "items run"); + Assert.That(HotReloadWarmUpTestDoubles.ReadCompletedOutcomes(), Is.EqualTo(new[] { "a:done", "b:done" })); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogWarmUpComplete); + Assert.That(context["targets"].ToObject(), Is.EqualTo(new[] { HotReloadWarmUpTestDoubles.TargetAssemblyName }), "targets"); + Assert.That(context["cancelledBy"].Type, Is.EqualTo(JTokenType.Null), "cancelledBy"); + } + + /// + /// What: a second Start runs nothing again. + /// + [Test] + public async Task Start_Twice_RunsTheItemsOnce() + { + HotReloadWarmUp warmUp = CreateReady(new RecordingWarmUpItem("a", _ran)); + + warmUp.Start(); + await warmUp.Completion; + warmUp.Start(); + await warmUp.Completion; + + Assert.That(_ran, Is.EqualTo(new[] { "a" })); + } + + /// + /// What: a run that comes before the warm-up starts does not wait, and the warm-up then never starts. + /// + [Test] + public void YieldToRunAsync_BeforeStart_CompletesAtOnce_AndStartAfterwardsRunsNothing() + { + HotReloadWarmUp warmUp = CreateReady(new RecordingWarmUpItem("a", _ran)); + + Task yield = warmUp.YieldToRunAsync(); + warmUp.Start(); + + Assert.That(yield.IsCompleted, Is.True, "yield completed"); + Assert.That(_ran, Is.Empty, "items run"); + Assert.That( + (string)HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogWarmUpSkipped)["reason"], + Is.EqualTo("run_started_first")); + } + + /// + /// What: a run that comes while an item is in flight waits for that item, and no later item starts. + /// + [Test] + public async Task YieldToRunAsync_WhileAnItemIsInFlight_WaitsForThatItemAndStartsNoLaterOne() + { + PendingWarmUpItem pending = new PendingWarmUpItem("b", _ran); + HotReloadWarmUp warmUp = CreateReady( + new RecordingWarmUpItem("a", _ran), + pending, + new RecordingWarmUpItem("c", _ran)); + try + { + warmUp.Start(); + Assert.That(_ran, Is.EqualTo(new[] { "a", "b" }), "Precondition: item b must be in flight."); + + Task yield = warmUp.YieldToRunAsync(); + Assert.That(yield.IsCompleted, Is.False, "the run must wait while item b is in flight"); + + pending.Release.TrySetResult(true); + await yield; + } + finally + { + pending.Release.TrySetResult(true); + } + + Assert.That(_ran, Is.EqualTo(new[] { "a", "b" }), "items run"); + Assert.That( + HotReloadWarmUpTestDoubles.ReadCompletedOutcomes(), + Is.EqualTo(new[] { "a:done", "b:done", "c:cancelled" })); + Assert.That( + (string)HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogWarmUpComplete)["cancelledBy"], + Is.EqualTo(HotReloadConstants.WarmUpCancelledByRun)); + } + + /// + /// What: a run that comes between two units of an item stops that item before its next + /// unit, and the entry reports it cancelled rather than done. + /// + [Test] + public async Task YieldToRunAsync_BetweenUnitsOfAnItem_StopsItAndReportsItCancelled() + { + TwoUnitWarmUpItem twoUnits = new TwoUnitWarmUpItem("a", _ran); + HotReloadWarmUp warmUp = CreateReady(twoUnits, new RecordingWarmUpItem("b", _ran)); + try + { + warmUp.Start(); + Assert.That(_ran, Is.EqualTo(new[] { "a" }), "Precondition: item a must be between its units."); + + Task yield = warmUp.YieldToRunAsync(); + twoUnits.BetweenUnits.TrySetResult(true); + await yield; + } + finally + { + twoUnits.BetweenUnits.TrySetResult(true); + } + + Assert.That(_ran, Is.EqualTo(new[] { "a" }), "units run"); + Assert.That( + HotReloadWarmUpTestDoubles.ReadCompletedOutcomes(), + Is.EqualTo(new[] { "a:cancelled", "b:cancelled" })); + Assert.That( + (string)HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogWarmUpComplete)["cancelledBy"], + Is.EqualTo(HotReloadConstants.WarmUpCancelledByRun)); + } + + /// + /// What: a run that comes after the warm-up finished does not wait. + /// + [Test] + public async Task YieldToRunAsync_AfterCompletion_CompletesAtOnce() + { + HotReloadWarmUp warmUp = CreateReady(new RecordingWarmUpItem("a", _ran)); + warmUp.Start(); + await warmUp.Completion; + + Task yield = warmUp.YieldToRunAsync(); + + Assert.That(yield.IsCompleted, Is.True); + } + + /// + /// What: an item that fails with an IO error is reported failed with a warning, and the next item still runs. + /// + [Test] + public async Task Start_WhenAnItemThrowsAnIoException_RecordsItFailedAndRunsTheNextItem() + { + HotReloadWarmUp warmUp = CreateReady( + new ThrowingWarmUpItem("a", new IOException("locked")), + new RecordingWarmUpItem("b", _ran)); + LogAssert.Expect(LogType.Warning, new Regex("warm-up item 'a' failed: locked")); + + warmUp.Start(); + await warmUp.Completion; + + Assert.That(_ran, Is.EqualTo(new[] { "b" }), "items run"); + Assert.That(HotReloadWarmUpTestDoubles.ReadCompletedOutcomes(), Is.EqualTo(new[] { "a:failed", "b:done" })); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogWarmUpComplete); + Assert.That((string)context["items"][0]["detail"], Is.EqualTo("IOException: locked"), "detail"); + } + + /// + /// What: an unexpected exception stops the remaining items, is logged, and leaves the + /// completion task unfaulted and the warm-up finished. + /// + [Test] + public async Task Start_WhenAnItemThrowsAnUnexpectedException_LogsItAndCompletionDoesNotFault() + { + HotReloadWarmUp warmUp = CreateReady( + new ThrowingWarmUpItem("a", new InvalidOperationException("unexpected")), + new RecordingWarmUpItem("b", _ran)); + LogAssert.Expect(LogType.Exception, new Regex("InvalidOperationException: unexpected")); + + warmUp.Start(); + await warmUp.Completion; + + Assert.That(_ran, Is.Empty, "items run"); + Assert.That(warmUp.Completion.IsFaulted, Is.False, "completion faulted"); + Assert.That(warmUp.State, Is.EqualTo(HotReloadWarmUpState.Finished), "state"); + } + + /// + /// What: a shutdown while an item is in flight waits for that item, cancels the rest, and + /// names its trigger in the entry. + /// + [Test] + public async Task Shutdown_WhileAnItemIsInFlight_CancelsTheRemainingItemsAndRecordsTheTrigger() + { + PendingWarmUpItem pending = new PendingWarmUpItem("a", _ran); + HotReloadWarmUp warmUp = CreateReady(pending, new RecordingWarmUpItem("b", _ran)); + try + { + warmUp.Start(); + + Task stopped = warmUp.Shutdown("t"); + Assert.That(stopped.IsCompleted, Is.False, "the shutdown must wait while item a is in flight"); + + pending.Release.TrySetResult(true); + await stopped; + } + finally + { + pending.Release.TrySetResult(true); + } + + Assert.That(_ran, Is.EqualTo(new[] { "a" }), "items run"); + Assert.That(HotReloadWarmUpTestDoubles.ReadCompletedOutcomes(), Is.EqualTo(new[] { "a:done", "b:cancelled" })); + Assert.That( + (string)HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogWarmUpComplete)["cancelledBy"], + Is.EqualTo("t")); + } + + /// + /// What: a shutdown before the warm-up starts does nothing, and a later Start still runs the items. + /// + [Test] + public async Task Shutdown_BeforeStart_DoesNothing_AndStartAfterwardsRuns() + { + HotReloadWarmUp warmUp = CreateReady(new RecordingWarmUpItem("a", _ran)); + + Task stopped = warmUp.Shutdown("t"); + warmUp.Start(); + await warmUp.Completion; + + Assert.That(stopped.IsCompleted, Is.True, "shutdown completed"); + Assert.That(_ran, Is.EqualTo(new[] { "a" }), "items run"); + } + + private static HotReloadWarmUp CreateReady(params IHotReloadWarmUpItem[] items) + { + return new HotReloadWarmUp(new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture()), items); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs.meta new file mode 100644 index 0000000000..2e060b81a8 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8a647c3883adf47fb85da4360f778f82 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerHostTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerHostTests.cs index 829041af0a..0bae66a3e9 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerHostTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerHostTests.cs @@ -110,6 +110,7 @@ public async Task RunAsync_FirstRequestStartsTheWorker_ReportsTheStartAndTheSeco Assert.That(StepNames(first), Does.Contain(TransformWorkerRequestTiming.ProcessStartStep), "first steps"); Assert.That(StepNames(first), Does.Contain(TransformWorkerRequestTiming.ResponseWaitStep), "first steps"); Assert.That(StepNames(first), Does.Contain(TransformWorkerRequestTiming.ReadOutputStep), "first steps"); + Assert.That(WorkerStepNames(first), Is.EqualTo(new[] { "read_input" }), "first worker steps"); Assert.That(second.Kind, Is.EqualTo(TransformWorkerHostResultKind.Completed), second.ErrorMessage); Assert.That(second.Timing.WorkerStarted, Is.False, "second WorkerStarted"); Assert.That(second.Timing.Attempts, Is.EqualTo(1), "second Attempts"); @@ -176,7 +177,7 @@ public async Task RunAsync_WorkerReportsFailure_IsFinalAndKeepsProcess() /// /// What: run-level parse errors in a valid output file are a final WorkerFailed result, not a - /// broken conversation, and the process is kept. + /// broken conversation, the process is kept, and the stages the worker timed are kept. /// [Test] public async Task RunAsync_OutputCarriesRunLevelParseErrors_IsWorkerFailed() @@ -187,6 +188,7 @@ public async Task RunAsync_OutputCarriesRunLevelParseErrors_IsWorkerFailed() Assert.That(result.Kind, Is.EqualTo(TransformWorkerHostResultKind.WorkerFailed)); Assert.That(result.ErrorMessage, Does.Contain("run-level problem")); + Assert.That(WorkerStepNames(result), Is.EqualTo(new[] { "read_input" }), "worker steps"); Assert.That(_host.LaunchCount, Is.EqualTo(1)); Assert.That(_host.CurrentProcessId, Is.Not.Null); } @@ -547,6 +549,11 @@ private static string[] StepNames(TransformWorkerHostResult result) { return result.Timing.Steps.Select(step => step.Key).ToArray(); } + + private static string[] WorkerStepNames(TransformWorkerHostResult result) + { + return result.Timing.WorkerSteps.Select(step => step.step).ToArray(); + } } /// @@ -823,7 +830,12 @@ private static void WriteOutput(string outputPath, string[] rowPaths, string[] p files[index] = new TransformWorkerFileOutputDto { projectRelativePath = rowPaths[index] }; } - TransformWorkerOutputDto output = new TransformWorkerOutputDto { files = files, parseErrors = parseErrors }; + TransformWorkerOutputDto output = new TransformWorkerOutputDto + { + files = files, + parseErrors = parseErrors, + timings = new[] { new TransformWorkerTimingStepDto { step = "read_input", ms = 3 } } + }; File.WriteAllText(outputPath, JsonConvert.SerializeObject(output)); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs index e9164b5722..ce1c439db0 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs @@ -205,6 +205,40 @@ private static HashSet CollectScanAssemblyNames( return scanNames; } + /// + /// The compiled dlls of the other assemblies that reference , + /// the ones a caller scan of its methods would consult the referenced-method index for. + /// Only dlls that exist are returned. Main thread only: it reads the compilation pipeline. + /// + internal static IReadOnlyList CollectReferencingDllPaths(string projectRoot, string targetAssemblyName) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(targetAssemblyName), "targetAssemblyName must not be null or empty."); + + HashSet targetDllFileNames = new HashSet(StringComparer.Ordinal) + { + targetAssemblyName + HotReloadConstants.CompiledAssemblyExtension + }; + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + List dllPaths = new List(); + foreach (KeyValuePair assembly in GetReferencedDllFileNamesByAssembly()) + { + if (string.Equals(assembly.Key, targetAssemblyName, StringComparison.Ordinal) + || !ReferencesAnyTargetDll(assembly.Value, targetDllFileNames)) + { + continue; + } + + string dllPath = layout.DllPath(assembly.Key); + if (File.Exists(dllPath)) + { + dllPaths.Add(dllPath); + } + } + + return dllPaths; + } + // Why cache per domain: CompilationPipeline.GetAssemblies() costs ~40 ms on a project with // ~100 assemblies, and the reference graph can only change through an asmdef or package // change, which recompiles and reloads the domain (resetting this field). diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs new file mode 100644 index 0000000000..6cc8610190 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Loads the compiled call sites of each target assembly into the call-site cache, the read a + /// run's caller notes would otherwise make cold. + /// + internal sealed class HotReloadCallSiteWarmUpItem : IHotReloadWarmUpItem + { + public string Name => "call_sites"; + + public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) + { + // Why taken here: the item is entered on the main thread, and the pool thread below + // must not be the first to touch a Shared instance. + HotReloadCompiledCallSiteCache cache = HotReloadCompiledCallSiteCache.Shared; + return Task.Run(() => LoadCallSites(context.Targets, cache, ct)); + } + + /// Loads each target's dll and returns how many it loaded; throws before the next dll once cancelled. + internal static int LoadCallSites( + IReadOnlyList targets, + HotReloadCompiledCallSiteCache cache, + CancellationToken ct) + { + int count = 0; + foreach (HotReloadWarmUpTarget target in targets) + { + // Why throw rather than stop: an item that returns normally is reported done, and + // one that skipped targets has not loaded them. + ct.ThrowIfCancellationRequested(); + cache.GetOrLoad(target.DllPath); + count++; + } + + return count; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs.meta new file mode 100644 index 0000000000..b5a630154f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0b3f9f63e9af54ad79c3b9803a385f21 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs index 5636fe9b2e..2a91aee01c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs @@ -48,6 +48,15 @@ public static void Initialize() /// Builds the services production runs on, without installing them. /// internal static HotReloadServices CreateProductionServices() + { + return CreateProductionServicesWithWarmUp(CreateProductionWarmUp()); + } + + /// + /// Builds the production services around , without installing + /// them. A test that drives the warm-up itself passes its own. + /// + internal static HotReloadServices CreateProductionServicesWithWarmUp(HotReloadWarmUp warmUp) { HotReloadHarmonyGateway harmony = new HotReloadHarmonyGateway(new Harmony(HotReloadConstants.HarmonyId)); @@ -59,7 +68,19 @@ internal static HotReloadServices CreateProductionServices() TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(HotReloadSourceSnapshotter.CaptureAfterDomainReload)); + new HotReloadSourceSnapshotCapture(HotReloadSourceSnapshotter.CaptureAfterDomainReload), + warmUp); + } + + private static HotReloadWarmUp CreateProductionWarmUp() + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + return new HotReloadWarmUp( + new HotReloadWarmUpContextSource( + projectRoot, + new HotReloadEditorStateSnapshotCapture(), + assemblyName => HotReloadCallSiteScanner.CollectReferencingDllPaths(projectRoot, assemblyName)), + HotReloadWarmUpItems.CreateProduction()); } /// @@ -90,7 +111,8 @@ internal static HotReloadServices CreateServices( Func buildDependencies, IHotReloadPlayModeQuery playMode, - HotReloadSourceSnapshotCapture sourceSnapshotCapture) + HotReloadSourceSnapshotCapture sourceSnapshotCapture, + HotReloadWarmUp warmUp) { // Built in dependency order, and every collaborator takes what it needs here: nothing // below may read the installed services, or a replacement scope would leave it bound @@ -174,7 +196,8 @@ internal static HotReloadServices CreateServices( new HotReloadDeferredInputClassifier(), new HotReloadSiblingRebindReporter(domain), packageRootCapture, - unityMessageForwarding), + unityMessageForwarding, + warmUp), new HotReloadStatusExecutor( domain, patcher, @@ -187,7 +210,8 @@ internal static HotReloadServices CreateServices( unityMessageForwarding, wiredValuePersistence, wiredValueRestoreRefresh, - sourceSnapshotCapture); + sourceSnapshotCapture, + warmUp); } /// @@ -278,6 +302,10 @@ internal static void UninstallInstalledServices() private static void UninstallServices(HotReloadServices services) { ClearWiring(); + // Why stop the warm-up: services replaced within a domain would otherwise leave the + // previous warm-up reading the shared caches on a pool thread. Not awaited: the + // caller is synchronous, and the item in flight ends on its own. + _ = services?.WarmUp.Shutdown(HotReloadConstants.WarmUpShutdownTriggerServicesReplaced); // Why the domain is disposed after the wiring is dropped: disposing unsubscribes its // resolver, and a bind arriving through a still-installed gateway would otherwise // reach a domain that can no longer answer. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs index a5b32748f4..07758e9097 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs @@ -36,6 +36,15 @@ void SweepArtifactsOnFirstUpdateTick() Path.GetFullPath(Path.Combine(Application.dataPath, ".."))); } + // Why after the capture and the sweep: the first run of this domain reads the snapshot + // the capture writes, and the warm-up only reads what a run would read; neither must + // wait for the other to be scheduled. + void StartWarmUpOnFirstUpdateTick() + { + EditorApplication.update -= StartWarmUpOnFirstUpdateTick; + HotReloadCompositionRoot.Services.WarmUp.Start(); + } + // The services are rebuilt here rather than on first use because the introduced type // resolver subscribes to AppDomain.AssemblyResolve when it is built, and that // subscription is lost on every domain reload. @@ -53,14 +62,17 @@ void SweepArtifactsOnFirstUpdateTick() // argument, so they have to read whichever services are installed when they fire. HotReloadAutoRefreshHold.GetServices = () => HotReloadCompositionRoot.Services; HotReloadPlayModeEntryDropRecorder.GetServices = () => HotReloadCompositionRoot.Services; + HotReloadWarmUpEditorHooks.GetServices = () => HotReloadCompositionRoot.Services; HotReloadUnityMessageForwardingEditorHooks.GetForwarding = () => HotReloadCompositionRoot.Services.UnityMessageForwarding; HotReloadWiredValueEditorHooks.GetPersistence = () => HotReloadCompositionRoot.Services.WiredValuePersistence; EditorApplication.update += CaptureOnFirstUpdateTick; EditorApplication.update += SweepArtifactsOnFirstUpdateTick; + EditorApplication.update += StartWarmUpOnFirstUpdateTick; HotReloadPlayModeEntryDropRecorder.Initialize(); HotReloadAutoRefreshHold.Initialize(); + HotReloadWarmUpEditorHooks.Initialize(); HotReloadUnityMessageForwardingEditorHooks.Initialize(); HotReloadWiredValueEditorHooks.Initialize(); TransformWorkerHostLifecycle.RegisterForEditorStartup(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs index d4769eace9..2676f637e1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs @@ -25,6 +25,7 @@ internal sealed class HotReloadOrchestrator : IHotReloadOrchestrator private readonly HotReloadDomain _domain; private readonly HotReloadPatcher _patcher; private readonly HotReloadUnityMessageForwarding _unityMessageForwarding; + private readonly HotReloadWarmUp _warmUp; internal HotReloadOrchestrator( HotReloadDomain domain, @@ -34,7 +35,8 @@ internal HotReloadOrchestrator( HotReloadDeferredInputClassifier deferredInputClassifier, HotReloadSiblingRebindReporter siblingRebindReporter, IHotReloadPackageRootCapture packageRootCapture, - HotReloadUnityMessageForwarding unityMessageForwarding) + HotReloadUnityMessageForwarding unityMessageForwarding, + HotReloadWarmUp warmUp) { Debug.Assert(domain != null, "domain must not be null."); Debug.Assert(patcher != null, "patcher must not be null."); @@ -45,6 +47,7 @@ internal HotReloadOrchestrator( Debug.Assert(packageRootCapture != null, "packageRootCapture must not be null."); Debug.Assert( unityMessageForwarding != null, "unityMessageForwarding must not be null."); + Debug.Assert(warmUp != null, "warmUp must not be null."); _domain = domain; _patcher = patcher; _groupProcessor = groupProcessor; @@ -53,6 +56,7 @@ internal HotReloadOrchestrator( _siblingRebindReporter = siblingRebindReporter; _packageRootCapture = packageRootCapture; _unityMessageForwarding = unityMessageForwarding; + _warmUp = warmUp; } /// @@ -83,6 +87,13 @@ public async Task RunAsync( using IDisposable callSiteCacheHold = HotReloadCompiledCallSiteCache.Shared.HoldEntriesForRun(); HotReloadRunTiming timing = new HotReloadRunTiming(); + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepWarmUpYield)) + { + // Why first: the warm-up may be reading a dll this run is about to read; waiting for + // that one item here is cheaper than contending for the cache lock inside the run. + await _warmUp.YieldToRunAsync().ConfigureAwait(false); + } + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepMainThreadSwitch)) { // CompilationPipeline / Application.dataPath require the Unity main thread, and the @@ -131,6 +142,14 @@ public async Task RunAsync( } } + using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepWarmUpTargets)) + { + // Why here: the assembly names are known once the inputs are resolved, and the next + // domain's warm-up reads them before any run of that domain. + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + HotReloadWarmUpTargetLedger.Record(projectRoot, CollectDistinctAssemblyNames(plannerInput)); + } + IReadOnlyList plans; HashSet pathsInRun = new HashSet( HotReloadSourcePathNormalizer.ProjectRelativePathComparer()); @@ -254,6 +273,22 @@ await ProcessPlannedGroupAsync( // Why on the inputs only: a re-applied sibling joins a group later and was never selected, // so it keeps the flag unset even in a default-selection run. + private static List CollectDistinctAssemblyNames( + List<(int InputIndex, string AssemblyName, string ProjectRelativePath)> plannerInput) + { + HashSet seen = new HashSet(StringComparer.Ordinal); + List names = new List(); + foreach ((int InputIndex, string AssemblyName, string ProjectRelativePath) input in plannerInput) + { + if (!string.IsNullOrEmpty(input.AssemblyName) && seen.Add(input.AssemblyName)) + { + names.Add(input.AssemblyName); + } + } + + return names; + } + private void MarkDefaultSelectedInputs(HotReloadInputResolutionSlot[] slots, bool isDefaultSelection) { foreach (HotReloadInputResolutionSlot slot in slots) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs new file mode 100644 index 0000000000..1d59feb3f3 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Loads the PDB document list of each target assembly, the list a run reads when it snapshots + /// the state of a group. + /// + internal sealed class HotReloadPdbDocumentWarmUpItem : IHotReloadWarmUpItem + { + public string Name => "pdb_documents"; + + public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) + { + // Why taken here: the Shared instance reads Application.dataPath when it is first + // touched, which only the main thread may do. + HotReloadPdbDocumentIndex index = HotReloadPdbDocumentIndex.Shared; + return Task.Run(() => PreloadDocuments(context.Targets, index, ct)); + } + + /// Preloads each target's document list with the MVID a run would read; throws before the next target once cancelled. + internal static void PreloadDocuments( + IReadOnlyList targets, + HotReloadPdbDocumentIndex index, + CancellationToken ct) + { + foreach (HotReloadWarmUpTarget target in targets) + { + // Why throw rather than stop: an item that returns normally is reported done. + ct.ThrowIfCancellationRequested(); + string moduleVersionId = HotReloadSourceSnapshotter.ReadAssemblyMvid(target.DllPath); + index.Preload(target.DllPath, target.PdbPath, moduleVersionId); + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs.meta new file mode 100644 index 0000000000..761966efd1 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f896f33417f6d49ee83a07b72aa91361 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs index 9147cc1cd4..421c4b1ecd 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodIndex.cs @@ -86,28 +86,7 @@ internal bool MentionsAny(string dllPath, IReadOnlyCollection keys) DllFingerprint fingerprint = HotReloadCompiledCallSiteCache.ReadFingerprint(fullPath); lock (_gate) { - if (!_entries.TryGetValue(fullPath, out Entry entry) || !entry.Fingerprint.Equals(fingerprint)) - { - // Why stored under the fingerprint read before the table: a dll replaced while - // it is read shows another fingerprint next time and is read again. - string persistedPath = PersistedSetPath(fullPath); - HashSet referenced = TryReadPersistedSet(persistedPath, fingerprint); - if (referenced != null) - { - _persistedLoadCount++; - } - else - { - // Why the read before any write: a read that throws leaves no entry and no file. - referenced = ReadReferencedMethodKeys(fullPath); - _loadCount++; - WritePersistedSet(persistedPath, fingerprint, referenced); - } - - entry = new Entry(fingerprint, referenced); - _entries[fullPath] = entry; - } - + Entry entry = EnsureEntryLocked(fullPath, fingerprint); foreach (string key in keys) { if (entry.Keys.Contains(key)) @@ -120,6 +99,50 @@ internal bool MentionsAny(string dllPath, IReadOnlyCollection keys) } } + /// + /// Reads the dll's set now so that a later MentionsAny in this domain reads nothing. The + /// file must exist. + /// + internal void Preload(string dllPath) + { + Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); + + string fullPath = Path.GetFullPath(dllPath); + DllFingerprint fingerprint = HotReloadCompiledCallSiteCache.ReadFingerprint(fullPath); + lock (_gate) + { + EnsureEntryLocked(fullPath, fingerprint); + } + } + + private Entry EnsureEntryLocked(string fullPath, DllFingerprint fingerprint) + { + if (_entries.TryGetValue(fullPath, out Entry entry) && entry.Fingerprint.Equals(fingerprint)) + { + return entry; + } + + // Why stored under the fingerprint read before the table: a dll replaced while + // it is read shows another fingerprint next time and is read again. + string persistedPath = PersistedSetPath(fullPath); + HashSet referenced = TryReadPersistedSet(persistedPath, fingerprint); + if (referenced != null) + { + _persistedLoadCount++; + } + else + { + // Why the read before any write: a read that throws leaves no entry and no file. + referenced = ReadReferencedMethodKeys(fullPath); + _loadCount++; + WritePersistedSet(persistedPath, fingerprint, referenced); + } + + entry = new Entry(fingerprint, referenced); + _entries[fullPath] = entry; + return entry; + } + private string PersistedSetPath(string fullDllPath) { return Path.Combine(_persistenceDirectory, Path.GetFileNameWithoutExtension(fullDllPath) + ".txt"); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs new file mode 100644 index 0000000000..ad7c62d178 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Loads the referenced-method set of every dll that references a target assembly, the sets a + /// run's caller scan consults before it decides which of those dlls to read. + /// + internal sealed class HotReloadReferencedMethodSetWarmUpItem : IHotReloadWarmUpItem + { + public string Name => "referenced_method_sets"; + + public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) + { + // Why taken here: the Shared instance reads Application.dataPath when it is first + // touched, which only the main thread may do. + HotReloadReferencedMethodIndex index = HotReloadReferencedMethodIndex.Shared; + return Task.Run(() => PreloadSets(context.Targets, index, ct)); + } + + /// Preloads each referencing dll's set; throws before the next dll once cancelled. + internal static void PreloadSets( + IReadOnlyList targets, + HotReloadReferencedMethodIndex index, + CancellationToken ct) + { + foreach (HotReloadWarmUpTarget target in targets) + { + foreach (string dllPath in target.ReferencingDllPaths) + { + // Why throw rather than stop: an item that returns normally is reported done. + ct.ThrowIfCancellationRequested(); + index.Preload(dllPath); + } + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs.meta new file mode 100644 index 0000000000..a6a5795c9a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 62397b4fc86334bed9fb20f6939abb5b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadServices.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadServices.cs index e0a8b48970..be3885fae9 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadServices.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadServices.cs @@ -26,7 +26,8 @@ internal HotReloadServices( HotReloadUnityMessageForwarding unityMessageForwarding, HotReloadWiredValuePersistence wiredValuePersistence, HotReloadWiredValueRestoreRefresh wiredValueRestoreRefresh, - HotReloadSourceSnapshotCapture sourceSnapshotCapture) + HotReloadSourceSnapshotCapture sourceSnapshotCapture, + HotReloadWarmUp warmUp) { Debug.Assert(domain != null, "domain must not be null."); Debug.Assert(harmony != null, "harmony must not be null."); @@ -50,6 +51,7 @@ internal HotReloadServices( Debug.Assert( wiredValueRestoreRefresh != null, "wiredValueRestoreRefresh must not be null."); Debug.Assert(sourceSnapshotCapture != null, "sourceSnapshotCapture must not be null."); + Debug.Assert(warmUp != null, "warmUp must not be null."); Domain = domain; Harmony = harmony; Patcher = patcher; @@ -68,6 +70,7 @@ internal HotReloadServices( WiredValuePersistence = wiredValuePersistence; WiredValueRestoreRefresh = wiredValueRestoreRefresh; SourceSnapshotCapture = sourceSnapshotCapture; + WarmUp = warmUp; } internal HotReloadDomain Domain { get; } @@ -123,6 +126,12 @@ internal HotReloadServices( /// internal HotReloadSourceSnapshotCapture SourceSnapshotCapture { get; } + /// + /// Loads, once per domain, what the first run would otherwise load cold. Shared by every + /// copy below, so the run that yields to it and the Editor hooks that stop it see one warm-up. + /// + internal HotReloadWarmUp WarmUp { get; } + /// /// A copy that runs instead of this one, sharing every /// other collaborator — including the domain, so installing the copy neither takes the @@ -148,7 +157,8 @@ internal HotReloadServices WithOrchestrator(IHotReloadOrchestrator orchestrator) UnityMessageForwarding, WiredValuePersistence, WiredValueRestoreRefresh, - SourceSnapshotCapture); + SourceSnapshotCapture, + WarmUp); } /// @@ -175,7 +185,8 @@ internal HotReloadServices WithChangeDetector(IHotReloadChangeDetector changeDet UnityMessageForwarding, WiredValuePersistence, WiredValueRestoreRefresh, - SourceSnapshotCapture); + SourceSnapshotCapture, + WarmUp); } /// @@ -203,7 +214,8 @@ internal HotReloadServices WithSourceSnapshotCapture( UnityMessageForwarding, WiredValuePersistence, WiredValueRestoreRefresh, - sourceSnapshotCapture); + sourceSnapshotCapture, + WarmUp); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs new file mode 100644 index 0000000000..0bc6f918b3 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +using Debug = UnityEngine.Debug; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Loads, once per domain and before the first hot reload run, what that run would otherwise + /// load cold: the call sites, referenced-method sets and PDB document lists of the assemblies + /// earlier runs of this project edited. Each kind of work is one item, run in order. + /// When a run comes while the warm-up is running, the run waits until the item in flight + /// finishes its current unit (one dll), whose result it then finds in the caches; that item is + /// reported cancelled when it had units left, and the remaining items are dropped. When a run comes + /// before the warm-up starts, the warm-up never starts. + /// Why no macOS activity is held while it runs: an activity is held only while a command is + /// handled, and the warm-up is not one. The Editor is not throttled for the first half minute + /// after a reload, and a run that comes takes an activity that speeds up the item in flight too. + /// + internal sealed class HotReloadWarmUp + { + private const string RunStartedFirstSkipReason = "run_started_first"; + + private readonly IHotReloadWarmUpContextSource _contextSource; + private readonly IReadOnlyList _items; + private readonly object _gate = new object(); + + private HotReloadWarmUpState _state = HotReloadWarmUpState.NotStarted; + private bool _runStartedFirst; + private CancellationTokenSource _cancellation; + private string _cancelledBy; + private Task _completion = Task.CompletedTask; + + internal HotReloadWarmUp(IHotReloadWarmUpContextSource contextSource, IReadOnlyList items) + { + Debug.Assert(contextSource != null, "contextSource must not be null."); + Debug.Assert(items != null, "items must not be null."); + _contextSource = contextSource; + _items = items; + } + + /// Completes when a started warm-up has finished; never faults. + internal Task Completion + { + get + { + lock (_gate) + { + return _completion; + } + } + } + + internal HotReloadWarmUpState State + { + get + { + lock (_gate) + { + return _state; + } + } + } + + /// + /// Starts the warm-up on the main thread. Does nothing after the first call, and skips when + /// a run came first. Never throws. + /// + internal void Start() + { + lock (_gate) + { + if (_state != HotReloadWarmUpState.NotStarted) + { + return; + } + + if (!_runStartedFirst) + { + _state = HotReloadWarmUpState.Running; + _cancellation = new CancellationTokenSource(); + _completion = RunAllAsync(_cancellation.Token); + return; + } + + _state = HotReloadWarmUpState.Finished; + } + + LogSkipped(RunStartedFirstSkipReason); + } + + /// + /// Called by a run before it reads anything. Keeps a warm-up that has not started from + /// starting, and stops a running one: the returned task completes once the item in flight + /// has finished. Completed at once when no warm-up runs. Callable from any thread. + /// + internal Task YieldToRunAsync() + { + lock (_gate) + { + _runStartedFirst = true; + return CancelRunningLocked(HotReloadConstants.WarmUpCancelledByRun); + } + } + + /// + /// Stops a running warm-up for ; the returned task completes once + /// the item in flight has finished. Completed at once when no warm-up runs. A warm-up that + /// has not started can still start afterwards. + /// + internal Task Shutdown(string trigger) + { + Debug.Assert(!string.IsNullOrEmpty(trigger), "trigger must not be null or empty."); + lock (_gate) + { + return CancelRunningLocked(trigger); + } + } + + private Task CancelRunningLocked(string cancelledBy) + { + if (_state != HotReloadWarmUpState.Running) + { + return Task.CompletedTask; + } + + if (_cancelledBy == null) + { + _cancelledBy = cancelledBy; + } + + _cancellation.Cancel(); + return _completion; + } + + // Starts synchronously on the main thread from Start(), so the capture reads Unity APIs there. + private async Task RunAllAsync(CancellationToken ct) + { + Stopwatch total = Stopwatch.StartNew(); + try + { + HotReloadWarmUpCapture capture = _contextSource.Capture(); + if (capture.Context == null) + { + LogSkipped(capture.SkipReason); + return; + } + + List outcomes = new List(); + foreach (IHotReloadWarmUpItem item in _items) + { + // Why every remaining item is listed as cancelled: the entry then shows which + // items never ran. + if (ct.IsCancellationRequested) + { + outcomes.Add(HotReloadWarmUpItemOutcome.Cancelled(item.Name, 0)); + continue; + } + + // Why ConfigureAwait(false): the next item switches to the main thread itself, + // and the entry after the last item must not wait for a main thread that a + // reload may never give back. + outcomes.Add(await RunItemAsync(item, capture.Context, ct).ConfigureAwait(false)); + } + + LogComplete(capture.Context, outcomes, total.ElapsedMilliseconds); + } + catch (Exception ex) + { + // Approved deviation: this is the boundary of work that started from an Editor + // update tick; nothing above it observes the task. + Debug.LogException(ex); + } + finally + { + lock (_gate) + { + _state = HotReloadWarmUpState.Finished; + } + } + } + + private static async Task RunItemAsync( + IHotReloadWarmUpItem item, + HotReloadWarmUpContext context, + CancellationToken ct) + { + Stopwatch watch = Stopwatch.StartNew(); + bool entered = false; + try + { + // Why each item enters on the main thread: after the previous item's + // ConfigureAwait(false) this runs on a pool thread, and an item that first touches + // a cache's Shared instance reads Application.dataPath. Why ct is passed: a switch + // waits for the next update tick, and one that ignored a cancel would start this + // item on the tick after the run that cancelled it had begun. + await MainThreadSwitcher.SwitchToMainThread(ct); + entered = true; + await item.RunAsync(context, ct).ConfigureAwait(false); + return HotReloadWarmUpItemOutcome.Done(item.Name, watch.ElapsedMilliseconds); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Why 0 before entering: a cancel during the tick wait must not look like an item + // stopped between its units. + return HotReloadWarmUpItemOutcome.Cancelled(item.Name, entered ? watch.ElapsedMilliseconds : 0); + } + catch (Exception ex) when (IsSkippableItemException(ex)) + { + // Approved deviation: best-effort background work. The run redoes this work and + // surfaces the real error. + Debug.LogWarning("[UnityCliLoop] Hot reload warm-up item '" + item.Name + "' failed: " + ex.Message); + return HotReloadWarmUpItemOutcome.Failed(item.Name, watch.ElapsedMilliseconds, ex); + } + } + + private static bool IsSkippableItemException(Exception ex) + { + return ex is IOException || ex is UnauthorizedAccessException || ex is BadImageFormatException; + } + + private void LogComplete( + HotReloadWarmUpContext context, + List outcomes, + long totalMs) + { + string[] targets = new string[context.Targets.Count]; + for (int index = 0; index < targets.Length; index++) + { + targets[index] = context.Targets[index].AssemblyName; + } + + object[] items = new object[outcomes.Count]; + for (int index = 0; index < items.Length; index++) + { + HotReloadWarmUpItemOutcome outcome = outcomes[index]; + items[index] = new + { + name = outcome.Name, + ms = outcome.Ms, + outcome = ToOutcomeName(outcome.Kind), + detail = outcome.Detail + }; + } + + string cancelledBy; + lock (_gate) + { + cancelledBy = _cancelledBy; + } + + VibeLogger.LogInfo( + HotReloadConstants.VibeLogWarmUpComplete, + "Hot reload warm-up finished.", + new + { + targetCount = targets.Length, + targets, + items, + totalMs, + cancelledBy + }); + } + + private static string ToOutcomeName(HotReloadWarmUpItemOutcomeKind kind) + { + switch (kind) + { + case HotReloadWarmUpItemOutcomeKind.Done: + return "done"; + case HotReloadWarmUpItemOutcomeKind.Cancelled: + return "cancelled"; + case HotReloadWarmUpItemOutcomeKind.Failed: + return "failed"; + default: + throw new ArgumentOutOfRangeException(nameof(kind), kind, null); + } + } + + private static void LogSkipped(string reason) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogWarmUpSkipped, + "Hot reload warm-up skipped.", + new { reason }); + } + } + + internal enum HotReloadWarmUpState + { + NotStarted, + Running, + Finished + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs.meta new file mode 100644 index 0000000000..78c401ed80 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0287dac9d380c4580a5ca441240f9db4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs new file mode 100644 index 0000000000..0db3c1a4fc --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// What one warm-up loads: the project root and at least one target assembly. + /// + internal sealed class HotReloadWarmUpContext + { + internal HotReloadWarmUpContext(string projectRoot, IReadOnlyList targets) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert(targets != null && targets.Count > 0, "targets must hold at least one target."); + ProjectRoot = projectRoot; + Targets = targets; + } + + internal string ProjectRoot { get; } + + internal IReadOnlyList Targets { get; } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs.meta new file mode 100644 index 0000000000..ef55e21364 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 81222583765984b2c834cfeff6914a96 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs new file mode 100644 index 0000000000..5ccdc81c58 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Chooses what the warm-up loads: the most recent assemblies in the project's ledger whose + /// compiled dll and PDB exist, with the dlls that reference each of them. + /// + internal sealed class HotReloadWarmUpContextSource : IHotReloadWarmUpContextSource + { + // Why only the most recent few: the assembly edited last is the one most likely edited + // next, and each target adds a full read of its dll to the warm-up. + internal const int MaxWarmedTargets = 8; + + private readonly string _projectRoot; + private readonly IHotReloadEditorStateSnapshotCapture _editorState; + private readonly Func> _collectReferencingDllPaths; + + internal HotReloadWarmUpContextSource( + string projectRoot, + IHotReloadEditorStateSnapshotCapture editorState, + Func> collectReferencingDllPaths) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert(editorState != null, "editorState must not be null."); + Debug.Assert(collectReferencingDllPaths != null, "collectReferencingDllPaths must not be null."); + _projectRoot = projectRoot; + _editorState = editorState; + _collectReferencingDllPaths = collectReferencingDllPaths; + } + + /// + /// Main thread only. Skips while the Editor compiles or imports: the dlls are being + /// rewritten then, and the compilation pipeline lists no assembly. A failed last compile + /// does not skip: the loaded dlls are the last good build, which a run reads too. + /// + public HotReloadWarmUpCapture Capture() + { + HotReloadEditorStateSnapshot state = _editorState.CaptureCurrent(); + if (state.IsCompiling) + { + return HotReloadWarmUpCapture.Skipped(HotReloadWarmUpCapture.SkipReasonCompiling); + } + + if (state.IsUpdating) + { + return HotReloadWarmUpCapture.Skipped(HotReloadWarmUpCapture.SkipReasonUpdating); + } + + IReadOnlyList names = HotReloadWarmUpTargetLedger.Read(_projectRoot); + if (names.Count == 0) + { + return HotReloadWarmUpCapture.Skipped(HotReloadWarmUpCapture.SkipReasonNoTargets); + } + + List targets = CollectTargets(names); + if (targets.Count == 0) + { + return HotReloadWarmUpCapture.Skipped(HotReloadWarmUpCapture.SkipReasonNoCompiledAssembly); + } + + return HotReloadWarmUpCapture.Ready(new HotReloadWarmUpContext(_projectRoot, targets)); + } + + private List CollectTargets(IReadOnlyList names) + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_projectRoot); + List targets = new List(); + int count = Math.Min(names.Count, MaxWarmedTargets); + for (int index = 0; index < count; index++) + { + string name = names[index]; + string dllPath = layout.DllPath(name); + string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + // Why skip rather than fail: an assembly renamed or removed since a run recorded it + // has nothing to load, and the ledger keeps the name harmlessly. + if (!File.Exists(dllPath) || !File.Exists(pdbPath)) + { + continue; + } + + targets.Add(new HotReloadWarmUpTarget(name, dllPath, pdbPath, _collectReferencingDllPaths(name))); + } + + return targets; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs.meta new file mode 100644 index 0000000000..e2ade2eec0 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2cf205ba0319f46a9ac5ea417da61d22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs new file mode 100644 index 0000000000..a69f18efe2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs @@ -0,0 +1,44 @@ +using System; + +using UnityEditor; +using UnityEditor.Compilation; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Stops the installed warm-up when the Editor is about to reload the domain or starts a + /// compile: the warm-up would read dlls that are being rewritten, or work for a domain that + /// is going away. + /// + internal static class HotReloadWarmUpEditorHooks + { + /// + /// Reads the installed services when a hook fires. + /// + /// + /// Why a provider and not a captured value: the hooks take no services argument, and a + /// replacement scope installs other services while the hooks stay registered. + /// + internal static Func GetServices { get; set; } + + internal static void Initialize() + { + // Why unsubscribe first: Initialize runs once per domain, but a repeated registration + // during tests must not stack handlers. + AssemblyReloadEvents.beforeAssemblyReload -= ShutdownForReload; + AssemblyReloadEvents.beforeAssemblyReload += ShutdownForReload; + CompilationPipeline.compilationStarted -= ShutdownForCompile; + CompilationPipeline.compilationStarted += ShutdownForCompile; + } + + internal static void ShutdownForReload() + { + _ = GetServices().WarmUp.Shutdown(HotReloadConstants.WarmUpShutdownTriggerBeforeAssemblyReload); + } + + internal static void ShutdownForCompile(object context) + { + _ = GetServices().WarmUp.Shutdown(HotReloadConstants.WarmUpShutdownTriggerCompilationStarted); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs.meta new file mode 100644 index 0000000000..c475b32aef --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 083bfdf71f4874da69f0efe524cabb7b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs new file mode 100644 index 0000000000..a16f7e6be5 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs @@ -0,0 +1,58 @@ +using System; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// How one warm-up item ended, as reported in the hot_reload_warm_up_complete entry. + /// + internal readonly struct HotReloadWarmUpItemOutcome + { + private HotReloadWarmUpItemOutcome(string name, long ms, HotReloadWarmUpItemOutcomeKind kind, string detail) + { + Debug.Assert(!string.IsNullOrEmpty(name), "name must not be null or empty."); + Name = name; + Ms = ms; + Kind = kind; + Detail = detail; + } + + internal string Name { get; } + + internal long Ms { get; } + + internal HotReloadWarmUpItemOutcomeKind Kind { get; } + + /// The exception's type and message for a failed item; empty otherwise. + internal string Detail { get; } + + internal static HotReloadWarmUpItemOutcome Done(string name, long ms) + { + return new HotReloadWarmUpItemOutcome(name, ms, HotReloadWarmUpItemOutcomeKind.Done, string.Empty); + } + + /// An item that never started ( 0) or stopped between its units. + internal static HotReloadWarmUpItemOutcome Cancelled(string name, long ms) + { + return new HotReloadWarmUpItemOutcome(name, ms, HotReloadWarmUpItemOutcomeKind.Cancelled, string.Empty); + } + + internal static HotReloadWarmUpItemOutcome Failed(string name, long ms, Exception exception) + { + Debug.Assert(exception != null, "exception must not be null."); + return new HotReloadWarmUpItemOutcome( + name, + ms, + HotReloadWarmUpItemOutcomeKind.Failed, + exception.GetType().Name + ": " + exception.Message); + } + } + + internal enum HotReloadWarmUpItemOutcomeKind + { + Done, + Cancelled, + Failed + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs.meta new file mode 100644 index 0000000000..51763d5160 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2f55ecad837a944af9ab58323c20fdc9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItems.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItems.cs new file mode 100644 index 0000000000..2f755a7485 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItems.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The items the production warm-up runs, in order. + /// + internal static class HotReloadWarmUpItems + { + /// + /// Why costliest first: a run that comes mid-way waits for one item and reuses its result, + /// so the order decides only what is lost when the warm-up does not finish, and the + /// costliest cold read is the most worth having. Why the PDB documents last as well: their + /// lookup throws an exception no item catches when the PDB is from another build than the + /// dll, which stops the remaining items, and last there are none left to lose. + /// + internal static IReadOnlyList CreateProduction() + { + return new IHotReloadWarmUpItem[] + { + new HotReloadCallSiteWarmUpItem(), + new HotReloadReferencedMethodSetWarmUpItem(), + new HotReloadPdbDocumentWarmUpItem() + }; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItems.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItems.cs.meta new file mode 100644 index 0000000000..b54c64d170 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItems.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 43fdf5b46449040ccbb7b20f21a623bd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTarget.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTarget.cs new file mode 100644 index 0000000000..d32702555b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTarget.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One assembly the warm-up loads ahead of the first run: its compiled dll and PDB, and the + /// compiled dlls of the assemblies that reference it. + /// + internal sealed class HotReloadWarmUpTarget + { + internal HotReloadWarmUpTarget( + string assemblyName, + string dllPath, + string pdbPath, + IReadOnlyList referencingDllPaths) + { + Debug.Assert(!string.IsNullOrEmpty(assemblyName), "assemblyName must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); + Debug.Assert(referencingDllPaths != null, "referencingDllPaths must not be null."); + AssemblyName = assemblyName; + DllPath = dllPath; + PdbPath = pdbPath; + ReferencingDllPaths = referencingDllPaths; + } + + internal string AssemblyName { get; } + + internal string DllPath { get; } + + internal string PdbPath { get; } + + /// The compiled dlls of the other assemblies that reference this one; may be empty. + internal IReadOnlyList ReferencingDllPaths { get; } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTarget.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTarget.cs.meta new file mode 100644 index 0000000000..694586d842 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTarget.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 508930c81c8c7451ea15e02beabd04a4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTargetLedger.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTargetLedger.cs new file mode 100644 index 0000000000..53240f278f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTargetLedger.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The names of the assemblies the hot reload runs of a project edited, most recent first. + /// Runs record into it; the warm-up after a domain reload reads it to choose what to load + /// before the first run of that domain. Kept in a file under Library so it survives reloads. + /// + internal static class HotReloadWarmUpTargetLedger + { + internal const int MaxRecordedTargets = 32; + + private const string FormatHeader = "uloop-hot-reload-warm-up-targets 1"; + + /// + /// The recorded names, most recent first. Empty when there is no ledger or it has another header. + /// + internal static IReadOnlyList Read(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + + string path = LedgerPath(projectRoot); + if (!File.Exists(path)) + { + return Array.Empty(); + } + + string[] lines = File.ReadAllText(path, Encoding.UTF8).Split('\n'); + if (!string.Equals(lines[0].TrimEnd('\r'), FormatHeader, StringComparison.Ordinal)) + { + return Array.Empty(); + } + + List names = new List(); + for (int index = 1; index < lines.Length; index++) + { + string name = lines[index].Trim(); + if (name.Length != 0) + { + names.Add(name); + } + } + + return names; + } + + /// + /// Puts first, keeps the earlier names after them once each, + /// and keeps the most recent . Leaves the file untouched when + /// that changes nothing, so a run that edits the same assembly again writes nothing. + /// + internal static void Record(string projectRoot, IReadOnlyList assemblyNames) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert(assemblyNames != null, "assemblyNames must not be null."); + if (assemblyNames.Count == 0) + { + return; + } + + IReadOnlyList existing = Read(projectRoot); + List merged = Merge(assemblyNames, existing); + if (SameNames(merged, existing)) + { + return; + } + + Write(LedgerPath(projectRoot), merged); + } + + private static List Merge(IReadOnlyList recent, IReadOnlyList existing) + { + HashSet seen = new HashSet(StringComparer.Ordinal); + List merged = new List(); + AppendUnseen(recent, seen, merged); + AppendUnseen(existing, seen, merged); + if (merged.Count > MaxRecordedTargets) + { + merged.RemoveRange(MaxRecordedTargets, merged.Count - MaxRecordedTargets); + } + + return merged; + } + + private static void AppendUnseen(IReadOnlyList names, HashSet seen, List merged) + { + foreach (string name in names) + { + if (!string.IsNullOrEmpty(name) && seen.Add(name)) + { + merged.Add(name); + } + } + } + + private static bool SameNames(List merged, IReadOnlyList existing) + { + if (merged.Count != existing.Count) + { + return false; + } + + for (int index = 0; index < merged.Count; index++) + { + if (!string.Equals(merged[index], existing[index], StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + // File format: UTF-8 without a BOM, every line ends with "\n"; the header, then one name per line. + private static void Write(string path, List names) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + StringBuilder text = new StringBuilder(); + text.Append(FormatHeader).Append('\n'); + foreach (string name in names) + { + text.Append(name).Append('\n'); + } + + // Why a temp file and a move: a warm-up in another domain never reads a half-written ledger. + string tempPath = path + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + File.WriteAllText(tempPath, text.ToString(), new UTF8Encoding(false)); + if (File.Exists(path)) + { + File.Delete(path); + } + + File.Move(tempPath, path); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + private static string LedgerPath(string projectRoot) + { + return Path.Combine( + projectRoot, + HotReloadConstants.WarmUpRelativeDirectory, + HotReloadConstants.WarmUpTargetsFileName); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTargetLedger.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTargetLedger.cs.meta new file mode 100644 index 0000000000..2a5f6fabaa --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpTargetLedger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1a2f0edf33f9e4be89badeb9340c266c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpContextSource.cs b/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpContextSource.cs new file mode 100644 index 0000000000..a91f77ac70 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpContextSource.cs @@ -0,0 +1,45 @@ +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Decides on the main thread what the warm-up loads, or why it loads nothing. + /// + internal interface IHotReloadWarmUpContextSource + { + HotReloadWarmUpCapture Capture(); + } + + /// + /// Either a context to warm up or the reason there is none; exactly one of the two is set. + /// + internal sealed class HotReloadWarmUpCapture + { + internal const string SkipReasonNoTargets = "no_targets"; + internal const string SkipReasonCompiling = "compiling"; + internal const string SkipReasonUpdating = "updating"; + internal const string SkipReasonNoCompiledAssembly = "no_compiled_assembly"; + + private HotReloadWarmUpCapture(HotReloadWarmUpContext context, string skipReason) + { + Context = context; + SkipReason = skipReason; + } + + internal HotReloadWarmUpContext Context { get; } + + internal string SkipReason { get; } + + internal static HotReloadWarmUpCapture Ready(HotReloadWarmUpContext context) + { + Debug.Assert(context != null, "context must not be null."); + return new HotReloadWarmUpCapture(context, null); + } + + internal static HotReloadWarmUpCapture Skipped(string reason) + { + Debug.Assert(!string.IsNullOrEmpty(reason), "reason must not be null or empty."); + return new HotReloadWarmUpCapture(null, reason); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpContextSource.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpContextSource.cs.meta new file mode 100644 index 0000000000..39b3b04661 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpContextSource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 75cbb4b6b99dd414e85474b96d205344 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs new file mode 100644 index 0000000000..15a23f9996 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One kind of work the warm-up does ahead of the first run of a domain, such as loading the + /// call sites of the target assemblies. + /// + internal interface IHotReloadWarmUpItem + { + /// Name as it appears in the hot_reload_warm_up_complete entry (snake_case). + string Name { get; } + + /// + /// Entered on the main thread. Throws OperationCanceledException before the next unit of work + /// (one dll, one process start) once ct is cancelled, so an item that stopped early is never + /// reported done; never observes ct in the middle of a unit. + /// + Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct); + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs.meta new file mode 100644 index 0000000000..3c31e6bb39 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0cad5c86c87ed440f869140248501093 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index f82f4724a8..6ebff323d5 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -66,6 +66,11 @@ internal static class HotReloadConstants // "fmt1" = generation of the file format, bumped the same way as PublicizedRefs. public const string ReferencedMethodsRelativeDirectory = "Library/UloopHotReload/ReferencedMethods/fmt1"; + // The assemblies the hot reload runs of this project edited, most recent first. The + // warm-up after a domain reload reads it to choose what to load before the first run. + public const string WarmUpRelativeDirectory = "Library/UloopHotReload/WarmUp"; + public const string WarmUpTargetsFileName = "targets.txt"; + // Package-relative directory of the out-of-process transform worker sources (tilde dir = Unity-ignored). public const string WorkerSourcePackageRelativePath = "Editor/FirstPartyTools/HotReload/TransformWorker~"; @@ -674,12 +679,24 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string VibeLogApplySummary = "hot_reload_apply_summary"; public const string VibeLogTimingDetail = "hot_reload_timing_detail"; public const string VibeLogCallSiteCacheEvicted = "hot_reload_call_site_cache_evicted"; + public const string VibeLogWarmUpComplete = "hot_reload_warm_up_complete"; + public const string VibeLogWarmUpSkipped = "hot_reload_warm_up_skipped"; + + // What stopped a running warm-up, as named in the cancelledBy field of hot_reload_warm_up_complete. + public const string WarmUpCancelledByRun = "run"; + public const string WarmUpShutdownTriggerBeforeAssemblyReload = "beforeAssemblyReload"; + public const string WarmUpShutdownTriggerCompilationStarted = "compilationStarted"; + public const string WarmUpShutdownTriggerServicesReplaced = "services_replaced"; + public const string WarmUpShutdownTriggerTestScope = "test_scope"; + public const string WarmUpShutdownTriggerTestRun = "test_run"; // Steps of an apply run outside the response's Timing phases, as named in the // hot_reload_timing_detail vibe entry. Steps that run once per group add up. + public const string TimingDetailStepWarmUpYield = "warm_up_yield"; public const string TimingDetailStepMainThreadSwitch = "main_thread_switch"; public const string TimingDetailStepPackageRoots = "package_roots"; public const string TimingDetailStepResolveInputs = "resolve_inputs"; + public const string TimingDetailStepWarmUpTargets = "warm_up_targets"; public const string TimingDetailStepPlan = "plan"; public const string TimingDetailStepActiveSiblings = "active_siblings"; public const string TimingDetailStepMembershipValidate = "membership_validate"; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs index fc03061f07..f17ab12805 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs @@ -177,35 +177,60 @@ internal bool TryFindDocument( FileStamp stamp = ReadStamp(fullDllPath, pdbPath, moduleVersionId); lock (_gate) { - if (!_entries.TryGetValue(fullDllPath, out Entry entry) || !entry.Stamp.Equals(stamp)) - { - string persistedPath = PersistedListPath(fullDllPath); - List documents = TryReadPersistedList(persistedPath, stamp); - if (documents != null) - { - _persistedLoadCount++; - } - else - { - // Why the walk before any write: a walk that throws leaves no entry and no - // file, so the next lookup reads the files again instead of answering from - // a list of other files. - documents = ReadDocuments(fullDllPath, pdbPath); - _loadCount++; - WritePersistedList(persistedPath, stamp, documents); - } + Entry entry = EnsureEntryLocked(fullDllPath, pdbPath, stamp); + return TryFind(entry.Documents, projectRelativePath, out document); + } + } - // Why stored under the stamp read before the walk: if a file is replaced while - // it is being read, the next lookup sees another stamp and reads again, so a - // list is never served for files it was not read from. - entry = new Entry(stamp, documents); - _entries[fullDllPath] = entry; - } + /// + /// Reads the dll's document list now so that a later TryFindDocument in this domain reads + /// nothing. Takes the same paths and MVID as TryFindDocument. + /// + internal void Preload(string dllPath, string pdbPath, string moduleVersionId) + { + Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(moduleVersionId), "moduleVersionId must not be null or empty."); - return TryFind(entry.Documents, projectRelativePath, out document); + string fullDllPath = Path.GetFullPath(dllPath); + FileStamp stamp = ReadStamp(fullDllPath, pdbPath, moduleVersionId); + lock (_gate) + { + EnsureEntryLocked(fullDllPath, pdbPath, stamp); } } + private Entry EnsureEntryLocked(string fullDllPath, string pdbPath, FileStamp stamp) + { + if (_entries.TryGetValue(fullDllPath, out Entry entry) && entry.Stamp.Equals(stamp)) + { + return entry; + } + + string persistedPath = PersistedListPath(fullDllPath); + List documents = TryReadPersistedList(persistedPath, stamp); + if (documents != null) + { + _persistedLoadCount++; + } + else + { + // Why the walk before any write: a walk that throws leaves no entry and no + // file, so the next lookup reads the files again instead of answering from + // a list of other files. + documents = ReadDocuments(fullDllPath, pdbPath); + _loadCount++; + WritePersistedList(persistedPath, stamp, documents); + } + + // Why stored under the stamp read before the walk: if a file is replaced while + // it is being read, the next lookup sees another stamp and reads again, so a + // list is never served for files it was not read from. + entry = new Entry(stamp, documents); + _entries[fullDllPath] = entry; + return entry; + } + private string PersistedListPath(string fullDllPath) { return Path.Combine(_persistenceDirectory, Path.GetFileNameWithoutExtension(fullDllPath) + ".txt"); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerHost.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerHost.cs index 90c28a08b7..b1c4a2ba73 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerHost.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerHost.cs @@ -372,6 +372,9 @@ private TransformWorkerConversationOutcome InterpretResponse( if (HasRunLevelParseErrors(output)) { + // Why kept on this failure: the worker ran its stages and wrote their times, and the + // failed result carries no output for the timing entry to read them from. + timing.RecordWorkerSteps(output.timings); return TransformWorkerConversationOutcome.Final(TransformWorkerHostResult.Failure( TransformWorkerHostResultKind.WorkerFailed, string.Join("\n", output.parseErrors))); @@ -390,6 +393,7 @@ private TransformWorkerConversationOutcome InterpretResponse( "worker output carried " + fileRowCount + " file rows for " + expectedFileCount + " sources"); } + timing.RecordWorkerSteps(output.timings); return TransformWorkerConversationOutcome.Final(TransformWorkerHostResult.Completed(output)); } @@ -521,8 +525,9 @@ private void LogRequestTiming( steps.Add(new { step = step.Key, ms = step.Value }); } - // A failed request carries no output, and an older worker writes no timings. - TransformWorkerTimingStepDto[] workerSteps = result.Output?.timings ?? Array.Empty(); + // Empty when no output decided the request, such as a non-zero exit code, and when an + // older worker writes no timings. + IReadOnlyList workerSteps = timing.WorkerSteps; VibeLogger.LogInfo( HotReloadConstants.VibeLogWorkerRequestTiming, "Resident transform worker request timing.", diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerRequestTiming.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerRequestTiming.cs index e11d574ec2..73284a2f92 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerRequestTiming.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerRequestTiming.cs @@ -33,6 +33,13 @@ internal sealed class TransformWorkerRequestTiming /// Number of conversations this request began with a worker process. public int Attempts { get; private set; } + /// + /// The stages the worker timed itself, from the output that decided the request; empty when + /// no output decided it or the worker wrote none. + /// + public IReadOnlyList WorkerSteps { get; private set; } = + Array.Empty(); + public void AddStep(string step, long milliseconds) { if (string.IsNullOrEmpty(step)) @@ -73,6 +80,11 @@ public void MarkAttempt() Attempts++; } + public void RecordWorkerSteps(TransformWorkerTimingStepDto[] steps) + { + WorkerSteps = steps ?? Array.Empty(); + } + private sealed class StepScope : IDisposable { private readonly TransformWorkerRequestTiming _timing; diff --git a/docs/hot-reload.md b/docs/hot-reload.md index e1b3ef3887..68d50b7727 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -82,7 +82,19 @@ first run after a domain reload does not walk the PDB again while they still mat `Library/UloopHotReload/ReferencedMethods/fmt1/.txt` (the methods of other assemblies the dll's MemberRef table names, stamped with the dll's length, write time and MVID, so the first run after a domain reload does not read an assembly that names none of the edited -methods). +methods), and `Library/UloopHotReload/WarmUp/targets.txt` (the assemblies the runs of this project +edited, most recent first, read by the warm-up after a domain reload). + +Warm-up after a domain reload: on the first Editor update tick after a reload, hot reload loads in +the background what the first run would otherwise load cold, for the most recent assemblies in +`targets.txt` (up to eight) whose dll and PDB exist: their compiled call sites, the +referenced-method sets of the assemblies that reference them, and their PDB document lists. It +does not start while the Editor compiles or imports, and a domain reload or a compile start stops +it. A run that arrives while it works waits only for the unit (one dll) in flight, whose result +it reuses; that item and the remaining ones are dropped; a run that arrives before the tick keeps it from starting. +`hot_reload_warm_up_complete` and `hot_reload_warm_up_skipped` show what it did, and the run's +`warm_up_yield` step shows how long the run waited (see `docs/vibe-logs.md`). The transform +worker, the shared Roslyn worker and the publicized copies are not warmed up yet. The shim compile references a fully publicized copy of the edited assembly, and of every other project assembly that grants the edited one its internals through `InternalsVisibleTo`. Any other project assembly is referenced through a `PublicizedExternalRefs` copy that keeps its top-level diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 68890adb80..687cb5c08c 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -43,9 +43,13 @@ command ran against: `steps`, so `unaccountedMs` keeps its meaning. `hot_reload_worker_request_timing` (one per request to the transform worker, prepare and transform) has the gate wait, launch target resolution, input write, process spawn, response wait and output read, whether the request - started the worker, and the stages the worker timed itself (`workerSteps`). - `hot_reload_signature_gate_timing` and `hot_reload_shim_first_compile_timing` (one per group) - have the main-thread switches, the signature gate, the membership check and the shim compile. + started the worker, and the stages the worker timed itself (`workerSteps`; empty when no + output decided the request, such as a non-zero exit code, but kept for a request that fails on + parse errors). `hot_reload_signature_gate_timing` (one per group that reaches the signature + gate) has the main-thread switch and the gate. `hot_reload_shim_first_compile_timing` (one per + group whose first-pass shim is compiled; none when the gate's retry already compiled it, when + the membership check fails, or when there is no method to compile) has the main-thread switch, + the membership check and the shim compile. `hot_reload_shim_references_timing` (one per shim compile) has the resolver directories, the target's publicized copy, the artifact copies and the reference copies with their counts. `hot_reload_publicized_copy_written` appears only when a rewritten copy was written, with @@ -58,6 +62,14 @@ command ran against: from `/UnityCliLoopCompilation/RoslynWorkerCache//`, keyed by the worker source and the compiler paths), or `built` (csc ran); `cachePublish` and `cachePublishError` say whether a built assembly was added to that cache. + After a domain reload, the warm-up writes one `hot_reload_warm_up_complete` with the target + assemblies, each item's `outcome` (`done`, `cancelled` or `failed`) and time, and `cancelledBy` + (`run`, `beforeAssemblyReload`, `compilationStarted`, or null), or one + `hot_reload_warm_up_skipped` with its `reason` (`no_targets`, `compiling`, `updating`, + `no_compiled_assembly`, `run_started_first`). `cancelled` covers both an item that never started + (`ms` 0) and one stopped between its units (one dll each) with units left, with the time it ran. + A run's `steps` have `warm_up_yield`, the time it waited for the unit in flight, and `warm_up_targets`, the time it took to record its + assemblies for the next warm-up. - CLI side: only when the `ULOOP_DEBUG` environment variable is set to a value other than empty, `0`, or `false` (`cli/common/vibelog/cli_vibe.go`). - A missing line is evidence only when the define was set and the code path logs at all. From b200c1ab68d565f9056265a8234df3baf0bb35f8 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 9 Oct 2026 12:54:30 +0900 Subject: [PATCH 24/29] perf: The first hot reload after a compile no longer spends most of a second rewriting the edited assembly's reference copy (#3254) --- ...otReloadPublicizedTargetWarmUpItemTests.cs | 249 ++++++++++++++++++ ...oadPublicizedTargetWarmUpItemTests.cs.meta | 11 + .../HotReload/HotReloadWarmUpE2ETests.cs | 30 ++- .../HotReload/HotReloadWarmUpItemsTests.cs | 9 +- .../HotReload/PublicizedCopyTestCache.cs | 14 + .../HotReload/ReferencePublicizerTests.cs | 16 ++ .../HotReloadPublicizedTargetWarmUpItem.cs | 83 ++++++ ...otReloadPublicizedTargetWarmUpItem.cs.meta | 11 + .../HotReload/HotReloadWarmUp.cs | 5 +- .../HotReload/HotReloadWarmUpItems.cs | 7 +- .../HotReload/Patching/ReferencePublicizer.cs | 105 ++++---- docs/hot-reload.md | 7 +- docs/vibe-logs.md | 5 +- 13 files changed, 492 insertions(+), 60 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs new file mode 100644 index 0000000000..49480d1d7f --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// EditMode coverage for : the copy it writes is + /// the one a run after the reload reuses. + /// + public class HotReloadPublicizedTargetWarmUpItemTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + + private const string MissingAssemblyName = "Uloop.NoSuchAssembly"; + + private static readonly DateTime MarkerWriteTimeUtc = new DateTime(2001, 2, 3, 4, 5, 6, DateTimeKind.Utc); + + // Why wait: the installed warm-up may be writing the test assembly's copy on a pool + // thread, which would write back a copy a test deleted, or write first the copy a test + // wants the code under test to write. + [UnitySetUp] + public IEnumerator WaitForTheInstalledWarmUpToStop() + { + Task stopped = PublicizedCopyTestCache.StopInstalledWarmUpAsync(); + while (!stopped.IsCompleted) + { + yield return null; + } + } + + /// + /// What: for a target without a copy, the item writes the publicized copy, and the run's + /// entry point returns that file without writing it again. + /// + [Test] + public async Task RunAsync_ForATargetWithoutACopy_WritesTheCopyTheRunReuses() + { + PublicizedCopyTestCache.DeleteCopiesOf(TestAssemblyName, HotReloadConstants.PublicizedRefsRelativeDirectory); + string expected = ExpectedCopyPath(); + Assert.That(File.Exists(expected), Is.False, "Precondition: the copy must be deleted."); + + await new HotReloadPublicizedTargetWarmUpItem().RunAsync(ContextFor(TestAssemblyName), CancellationToken.None); + + Assert.That(File.Exists(expected), Is.True, "copy written by the warm-up"); + // Why plant the marker between the item and the run's entry point: a run that wrote + // the copy again would replace it, while calling the entry point twice after the item + // would pass even when the first call wrote the copy. + File.SetLastWriteTimeUtc(expected, MarkerWriteTimeUtc); + string reused = ReferencePublicizer.GetOrCreatePublicizedCopy( + RunHome(), + PublicizerTestSearchDirectories.ForHotReloadTestAssembly()); + + Assert.That(reused, Is.EqualTo(expected), "copy the run uses"); + Assert.That(File.GetLastWriteTimeUtc(expected), Is.EqualTo(MarkerWriteTimeUtc), "the run wrote the copy again"); + } + + /// + /// What: when the copy already exists, the item leaves it as it is. + /// + [Test] + public async Task RunAsync_WhenTheCopyExists_LeavesItUntouched() + { + string existing = ReferencePublicizer.GetOrCreatePublicizedCopy( + RunHome(), + PublicizerTestSearchDirectories.ForHotReloadTestAssembly()); + File.SetLastWriteTimeUtc(existing, MarkerWriteTimeUtc); + + await new HotReloadPublicizedTargetWarmUpItem().RunAsync(ContextFor(TestAssemblyName), CancellationToken.None); + + Assert.That(File.GetLastWriteTimeUtc(existing), Is.EqualTo(MarkerWriteTimeUtc)); + } + + /// + /// What: a ledger name that no compilation assembly has is skipped without an exception + /// and without writing a copy. + /// + [Test] + public async Task RunAsync_ForATargetThatIsNotACompilationAssembly_CompletesWithoutWriting() + { + PublicizedCopyTestCache.DeleteCopiesOf(TestAssemblyName, HotReloadConstants.PublicizedRefsRelativeDirectory); + + await new HotReloadPublicizedTargetWarmUpItem().RunAsync(ContextFor(MissingAssemblyName), CancellationToken.None); + + Assert.That(File.Exists(ExpectedCopyPath()), Is.False); + } + + /// + /// What: a cancellation seen before the first unit makes the item throw, through the token + /// it hands to its pool work, and no copy is written. + /// + [Test] + public async Task RunAsync_WhenCancelledBeforeTheFirstUnit_ThrowsAndWritesNothing() + { + PublicizedCopyTestCache.DeleteCopiesOf(TestAssemblyName, HotReloadConstants.PublicizedRefsRelativeDirectory); + using CancellationTokenSource cts = new CancellationTokenSource(); + cts.Cancel(); + + // Why try/catch rather than Assert.ThrowsAsync: that blocks the main thread in this + // NUnit, and the test framework reports an async test that ends canceled as passed. + bool cancelled = false; + try + { + await new HotReloadPublicizedTargetWarmUpItem().RunAsync(ContextFor(TestAssemblyName), cts.Token); + } + catch (OperationCanceledException) + { + cancelled = true; + } + + Assert.That(cancelled, Is.True, "the item threw OperationCanceledException"); + Assert.That(File.Exists(ExpectedCopyPath()), Is.False, "copy written"); + } + + /// + /// What: a cancellation that comes after the first unit throws before the next unit, after + /// the first copy was written. + /// + [Test] + public void WritePublicizedCopies_WhenCancelledBetweenUnits_ThrowsBeforeTheNextUnit() + { + PublicizedCopyTestCache.DeleteCopiesOf(TestAssemblyName, HotReloadConstants.PublicizedRefsRelativeDirectory); + HotReloadPublicizedTargetRequest first = new HotReloadPublicizedTargetRequest( + RunHome(), + PublicizerTestSearchDirectories.ForHotReloadTestAssembly()); + // A dll that does not exist: a unit run for it would fail the test. + HotReloadPublicizedTargetRequest second = new HotReloadPublicizedTargetRequest( + HotReloadTypeHome.ScriptAssemblies( + MissingAssemblyName, + CompiledAssemblyLayout.Resolve(ProjectRoot()).DllPath(MissingAssemblyName)), + Array.Empty()); + using CancellationTokenSource cts = new CancellationTokenSource(); + + Assert.Throws( + () => HotReloadPublicizedTargetWarmUpItem.WritePublicizedCopies( + new CancelBeforeTheSecondRequest(first, second, cts), + cts.Token)); + + Assert.That(File.Exists(ExpectedCopyPath()), Is.True, "copy written by the first unit"); + } + + /// + /// What: Cecil's resolution failure is an IOException, so the warm-up records a failed copy + /// as a failed item and runs the items after it. A Cecil version that breaks this would let + /// the failure stop the later items. + /// + [Test] + public void AssemblyResolutionException_IsAnIOException_SoAFailedCopyLetsTheLaterItemsRun() + { + Assert.That(typeof(Mono.Cecil.AssemblyResolutionException).IsSubclassOf(typeof(IOException)), Is.True); + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + + private static string TestAssemblyDllPath() + { + return CompiledAssemblyLayout.Resolve(ProjectRoot()).DllPath(TestAssemblyName); + } + + private static string ExpectedCopyPath() + { + return Path.Combine( + ProjectRoot(), + HotReloadConstants.PublicizedRefsRelativeDirectory, + TestAssemblyName + "-" + HotReloadSourceSnapshotter.ReadAssemblyMvid(TestAssemblyDllPath()) + + HotReloadConstants.CompiledAssemblyExtension); + } + + private static HotReloadWarmUpContext ContextFor(string assemblyName) + { + string dllPath = TestAssemblyDllPath(); + return new HotReloadWarmUpContext( + ProjectRoot(), + new[] + { + new HotReloadWarmUpTarget( + assemblyName, + dllPath, + Path.ChangeExtension(dllPath, ".pdb"), + Array.Empty()) + }); + } + + // The home a run uses for the target right after a reload. + private static HotReloadTypeHome RunHome() + { + return HotReloadTypeHome.ScriptAssembliesUnderProject(ProjectRoot(), TestAssemblyName); + } + + // Hands out the first request, then cancels before it hands out the second, the way a + // run's yield lands between two units. The indexer cancels too, so a for loop over the + // requests sees the same order. + private sealed class CancelBeforeTheSecondRequest : IReadOnlyList + { + private readonly HotReloadPublicizedTargetRequest _first; + private readonly HotReloadPublicizedTargetRequest _second; + private readonly CancellationTokenSource _cts; + + internal CancelBeforeTheSecondRequest( + HotReloadPublicizedTargetRequest first, + HotReloadPublicizedTargetRequest second, + CancellationTokenSource cts) + { + _first = first; + _second = second; + _cts = cts; + } + + public int Count => 2; + + public HotReloadPublicizedTargetRequest this[int index] + { + get + { + if (index > 0) + { + _cts.Cancel(); + } + + return index == 0 ? _first : _second; + } + } + + public IEnumerator GetEnumerator() + { + yield return _first; + _cts.Cancel(); + yield return _second; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs.meta new file mode 100644 index 0000000000..697e41f3f9 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4c09efaefd8ad4e33b74bb833d12fd6c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs index 304c956597..db5a41c3b1 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs @@ -63,6 +63,12 @@ public async Task Run_AfterTheWarmUpFinished_ReadsNoCompiledAssemblyForTheEdited new FixedContextSource(CreateTestAssemblyCapture(testDll)), HotReloadWarmUpItems.CreateProduction()); await BeginScope(warmUp); + // Why clear again once the installed warm-up stopped: its unit in flight can log a copy + // written and its completion after the clear in SetUp. + VibeLogger.ClearMemoryLogs(); + // Why delete: a copy left by an earlier test would make the run hit the cache even + // when the warm-up wrote nothing. + PublicizedCopyTestCache.DeleteCopiesOf(TestAssemblyName(), HotReloadConstants.PublicizedRefsRelativeDirectory); // Why clear and require a read: the run loads the edited assembly's dll itself, and // the entry outlives the run's hold, so "the run read nothing" alone would pass even // when the warm-up loaded nothing. @@ -78,10 +84,14 @@ public async Task Run_AfterTheWarmUpFinished_ReadsNoCompiledAssemblyForTheEdited Assert.That(HotReloadCompiledCallSiteCache.Shared.LoadCount, Is.EqualTo(afterWarmUp), "the warm-up loaded the test dll"); Assert.That( HotReloadWarmUpTestDoubles.ReadCompletedOutcomes(), - Is.EqualTo(new[] { "call_sites:done", "referenced_method_sets:done", "pdb_documents:done" })); + Is.EqualTo(new[] { "publicized_targets:done", "call_sites:done", "referenced_method_sets:done", "pdb_documents:done" })); + Assert.That(CountTargetCopiesWritten(), Is.EqualTo(1), "copies the warm-up wrote for the edited assembly"); + VibeLogger.ClearMemoryLogs(); await RunWithReadReturningAsync(2); + Assert.That(CountTargetCopiesWritten(), Is.EqualTo(0), "copies the run wrote again for the edited assembly"); + Assert.That(HotReloadCompiledCallSiteCache.Shared.GetOrLoad(testDll), Is.SameAs(entry), "the run read the dll again"); } @@ -219,6 +229,24 @@ private static async Task RunWithReadReturningAsync return result; } + // The publicized copies of the edited assembly written since the logs were last cleared. + private static int CountTargetCopiesWritten() + { + JArray entries = ReadEntries(HotReloadConstants.VibeLogPublicizedCopyWritten); + int count = 0; + for (int i = 0; i < entries.Count; i++) + { + JToken context = entries[i]["context"]; + if ((string)context["assemblyName"] == TestAssemblyName() + && (string)context["variant"] == HotReloadConstants.PublicizedRefsRelativeDirectory) + { + count++; + } + } + + return count; + } + private static JArray ReadEntries(string operation) { return JArray.Parse(VibeLogger.GetLogsForAi(operation)); diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs index f2123e5135..82f96cc372 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs @@ -12,11 +12,12 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload public sealed class HotReloadWarmUpItemsTests { /// - /// What: the items run costliest first, with the PDB documents last because their lookup - /// can throw an exception that stops the items after it. + /// What: the items run costliest first, and writing the publicized copy costs the most. The + /// PDB documents come last because their lookup can throw an exception that stops the items + /// after it. /// [Test] - public void CreateProduction_ReturnsCallSitesThenReferencedMethodSetsThenPdbDocuments() + public void CreateProduction_ReturnsPublicizedTargetsThenCallSitesThenReferencedMethodSetsThenPdbDocuments() { List names = new List(); foreach (IHotReloadWarmUpItem item in HotReloadWarmUpItems.CreateProduction()) @@ -24,7 +25,7 @@ public void CreateProduction_ReturnsCallSitesThenReferencedMethodSetsThenPdbDocu names.Add(item.Name); } - Assert.That(names, Is.EqualTo(new[] { "call_sites", "referenced_method_sets", "pdb_documents" })); + Assert.That(names, Is.EqualTo(new[] { "publicized_targets", "call_sites", "referenced_method_sets", "pdb_documents" })); } } } diff --git a/Assets/Tests/Editor/HotReload/PublicizedCopyTestCache.cs b/Assets/Tests/Editor/HotReload/PublicizedCopyTestCache.cs index fe36270691..e232534519 100644 --- a/Assets/Tests/Editor/HotReload/PublicizedCopyTestCache.cs +++ b/Assets/Tests/Editor/HotReload/PublicizedCopyTestCache.cs @@ -1,17 +1,31 @@ using System; using System.IO; +using System.Threading.Tasks; using UnityEngine; +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// /// Deletes cached rewritten copies of one assembly image, so the next request for a copy /// rewrites the image under the current rule. A copy is keyed by the image's name and Mvid only, /// so a copy written before a rule change would otherwise be returned without the rule running. + /// Also stops the installed warm-up from writing copies while a test deletes or writes them. /// internal static class PublicizedCopyTestCache { + /// + /// Stops the warm-up that started at the domain load. The warm-up writes the copy of the + /// assembly the previous run edited on a pool thread, so a test that deletes copies or has + /// one written waits for this to complete. It completes once the item's unit in flight ends. + /// + internal static Task StopInstalledWarmUpAsync() + { + return HotReloadCompositionRoot.Services.WarmUp.Shutdown(HotReloadConstants.WarmUpShutdownTriggerTestScope); + } + /// /// Deletes the "<assemblyName>-<Mvid>.dll" copies under the project-relative /// . Copies of an assembly whose name only starts with diff --git a/Assets/Tests/Editor/HotReload/ReferencePublicizerTests.cs b/Assets/Tests/Editor/HotReload/ReferencePublicizerTests.cs index 16900db67f..26f0f5fc52 100644 --- a/Assets/Tests/Editor/HotReload/ReferencePublicizerTests.cs +++ b/Assets/Tests/Editor/HotReload/ReferencePublicizerTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; @@ -9,6 +10,7 @@ using NUnit.Framework; using UnityEditor.Compilation; using UnityEngine; +using UnityEngine.TestTools; using io.github.hatayama.UnityCliLoop.FirstPartyTools; @@ -67,6 +69,20 @@ private int Secret() } "; + // Why wait: GetOrCreatePublicizedCopy_DoesNotDeleteHyphenatedSiblingAssemblyCaches, for + // one, deletes the copy and then checks that this call writes it and deletes the stale + // ones. If the installed warm-up writes the copy in between, this call is a cache hit and + // the check cannot be made. + [UnitySetUp] + public IEnumerator WaitForTheInstalledWarmUpToStop() + { + Task stopped = PublicizedCopyTestCache.StopInstalledWarmUpAsync(); + while (!stopped.IsCompleted) + { + yield return null; + } + } + /// /// What: publicizing the test assembly exposes private fields/methods and reuses the /// cached copy on a second call (same path; last-write time stays at a planted marker). diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs new file mode 100644 index 0000000000..dd24d885ed --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using UnityEngine; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Writes the publicized copy of each target assembly. It makes the same copy a run asks + /// for after the reload, so the run + /// reuses it. + /// + internal sealed class HotReloadPublicizedTargetWarmUpItem : IHotReloadWarmUpItem + { + public string Name => "publicized_targets"; + + public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) + { + // Why gather every target here first: the compilation assembly lookup may only run on + // the main thread, and the search directories are collected where the run collects + // them. Only the rewrite and the write, the costly part, go to the pool. + List requests = new List(); + foreach (HotReloadWarmUpTarget target in context.Targets) + { + UnityCompilationAssembly assembly = HotReloadCompilationAssemblies.FindByName(target.AssemblyName); + // A name left in the ledger from an older layout, or an empty list during a + // compile. A run does not ask for a copy of that name either. + if (assembly == null) + { + continue; + } + + IReadOnlyCollection resolverSearchDirectories = + HotReloadResolverSearchDirectories.Collect(context.ProjectRoot, assembly); + requests.Add(new HotReloadPublicizedTargetRequest( + HotReloadTypeHome.ScriptAssemblies(target.AssemblyName, target.DllPath), + resolverSearchDirectories)); + } + + return Task.Run(() => WritePublicizedCopies(requests, ct)); + } + + /// + /// Writes the publicized copy of each request, or reuses the one in place; throws before the + /// next request once cancelled. Runs on a pool thread: the publicizer reads only + /// Application.dataPath and Application.platform, which Unity reads thread-safely. + /// + internal static void WritePublicizedCopies( + IReadOnlyList requests, + CancellationToken ct) + { + foreach (HotReloadPublicizedTargetRequest request in requests) + { + // Why throw rather than stop: an item that returns normally is reported done. + ct.ThrowIfCancellationRequested(); + ReferencePublicizer.GetOrCreatePublicizedCopy(request.Home, request.ResolverSearchDirectories); + } + } + } + + /// + /// One target's input to the publicizer, gathered on the main thread. + /// + internal sealed class HotReloadPublicizedTargetRequest + { + internal HotReloadPublicizedTargetRequest( + HotReloadTypeHome home, + IReadOnlyCollection resolverSearchDirectories) + { + Debug.Assert(home != null, "home must not be null."); + Debug.Assert(resolverSearchDirectories != null, "resolverSearchDirectories must not be null."); + Home = home; + ResolverSearchDirectories = resolverSearchDirectories; + } + + internal HotReloadTypeHome Home { get; } + + internal IReadOnlyCollection ResolverSearchDirectories { get; } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs.meta new file mode 100644 index 0000000000..6559dc48f9 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fccbfb4d5f43a426dbca3c81934ffdef +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs index 0bc6f918b3..bcc630f760 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs @@ -13,8 +13,9 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// /// Loads, once per domain and before the first hot reload run, what that run would otherwise - /// load cold: the call sites, referenced-method sets and PDB document lists of the assemblies - /// earlier runs of this project edited. Each kind of work is one item, run in order. + /// load cold: the publicized copies, call sites, referenced-method sets and PDB document lists + /// of the assemblies earlier runs of this project edited. Each kind of work is one item, run in + /// order. /// When a run comes while the warm-up is running, the run waits until the item in flight /// finishes its current unit (one dll), whose result it then finds in the caches; that item is /// reported cancelled when it had units left, and the remaining items are dropped. When a run comes diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItems.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItems.cs index 2f755a7485..0783ac0b9d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItems.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItems.cs @@ -12,12 +12,17 @@ internal static class HotReloadWarmUpItems /// so the order decides only what is lost when the warm-up does not finish, and the /// costliest cold read is the most worth having. Why the PDB documents last as well: their /// lookup throws an exception no item catches when the PDB is from another build than the - /// dll, which stops the remaining items, and last there are none left to lose. + /// dll, which stops the remaining items, and last there are none left to lose. Why the + /// publicized copies first: rewriting and writing the image of an edited assembly is the + /// costliest step of a cold run. Cecil's resolution and IO exceptions count as a failed item + /// and do not stop the items after it; any other exception does, but a run with the same + /// input makes the same call and fails the same way. /// internal static IReadOnlyList CreateProduction() { return new IHotReloadWarmUpItem[] { + new HotReloadPublicizedTargetWarmUpItem(), new HotReloadCallSiteWarmUpItem(), new HotReloadReferencedMethodSetWarmUpItem(), new HotReloadPdbDocumentWarmUpItem() diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs index fc1a0fc5be..b0c8e13e87 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs @@ -25,6 +25,12 @@ internal static class ReferencePublicizer private const string InternalsVisibleToAttributeFullName = "System.Runtime.CompilerServices.InternalsVisibleToAttribute"; + // Why one gate for every rewritten copy: two writers of the same copy both miss the cache, + // and the later File.Move throws because the earlier one already put the file there. A run + // never overlaps the warm-up, which it waits for, but a test run starts while the warm-up + // installed at the domain load may still be writing, and tests call this class directly. + private static readonly object CopyWriteGate = new object(); + /// /// Collects distinct directory paths of existing DLL references for Cecil /// search. Null @@ -176,66 +182,69 @@ private static string WriteOrReuseRewrittenCopy( string assemblyName = assemblyDefinition.Name.Name; string mvid = assemblyDefinition.MainModule.Mvid.ToString("N"); string outputDirectory = ResolveOutputDirectory(outputRelativeDirectory); - Directory.CreateDirectory(outputDirectory); - - string outputDllPath = Path.Combine( - outputDirectory, - assemblyName + "-" + mvid + HotReloadConstants.CompiledAssemblyExtension); - if (File.Exists(outputDllPath) && new FileInfo(outputDllPath).Length > 0) + lock (CopyWriteGate) { - return outputDllPath; - } + Directory.CreateDirectory(outputDirectory); - // Why: older versions wrote the publicized DLL in place; a failed Write could leave - // a 0-byte file that File.Exists alone treated as a valid cache hit. Delete it so - // this call regenerates instead of poisoning later shim compiles. - if (File.Exists(outputDllPath)) - { - File.Delete(outputDllPath); - } + string outputDllPath = Path.Combine( + outputDirectory, + assemblyName + "-" + mvid + HotReloadConstants.CompiledAssemblyExtension); + if (File.Exists(outputDllPath) && new FileInfo(outputDllPath).Length > 0) + { + return outputDllPath; + } + + // Why: older versions wrote the publicized DLL in place; a failed Write could leave + // a 0-byte file that File.Exists alone treated as a valid cache hit. Delete it so + // this call regenerates instead of poisoning later shim compiles. + if (File.Exists(outputDllPath)) + { + File.Delete(outputDllPath); + } - // An Mvid change means the assembly already reloaded; no in-flight compile can still - // need the previous publicized copy, so drop stale siblings before writing the new one. - DeleteStaleCopies(outputDirectory, assemblyName, outputDllPath); + // An Mvid change means the assembly already reloaded; no in-flight compile can still + // need the previous publicized copy, so drop stale siblings before writing the new one. + DeleteStaleCopies(outputDirectory, assemblyName, outputDllPath); - Stopwatch watch = Stopwatch.StartNew(); - foreach (ModuleDefinition module in assemblyDefinition.Modules) - { - foreach (TypeDefinition type in module.GetTypes()) + Stopwatch watch = Stopwatch.StartNew(); + foreach (ModuleDefinition module in assemblyDefinition.Modules) { - // is a metadata artifact; rewriting its visibility breaks the module. - if (type.Name == "") + foreach (TypeDefinition type in module.GetTypes()) { - continue; - } + // is a metadata artifact; rewriting its visibility breaks the module. + if (type.Name == "") + { + continue; + } - rewriteType(type); + rewriteType(type); + } } - } - long rewriteMs = watch.ElapsedMilliseconds; - watch.Restart(); - // Write to a temp path then Move so a thrown Write cannot leave a 0-byte final cache. - string tempDllPath = outputDllPath + ".tmp-" + Guid.NewGuid().ToString("N"); - try - { - assemblyDefinition.Write(tempDllPath); - File.Move(tempDllPath, outputDllPath); - } - finally - { - if (File.Exists(tempDllPath)) + long rewriteMs = watch.ElapsedMilliseconds; + watch.Restart(); + // Write to a temp path then Move so a thrown Write cannot leave a 0-byte final cache. + string tempDllPath = outputDllPath + ".tmp-" + Guid.NewGuid().ToString("N"); + try { - File.Delete(tempDllPath); + assemblyDefinition.Write(tempDllPath); + File.Move(tempDllPath, outputDllPath); + } + finally + { + if (File.Exists(tempDllPath)) + { + File.Delete(tempDllPath); + } } - } - // Only on a miss: a copy written is the cold cost of a rebuilt assembly. - VibeLogger.LogInfo( - HotReloadConstants.VibeLogPublicizedCopyWritten, - "Hot reload wrote a rewritten reference copy.", - new { assemblyName, variant = outputRelativeDirectory, rewriteMs, writeMs = watch.ElapsedMilliseconds }); - return outputDllPath; + // Only on a miss: a copy written is the cold cost of a rebuilt assembly. + VibeLogger.LogInfo( + HotReloadConstants.VibeLogPublicizedCopyWritten, + "Hot reload wrote a rewritten reference copy.", + new { assemblyName, variant = outputRelativeDirectory, rewriteMs, writeMs = watch.ElapsedMilliseconds }); + return outputDllPath; + } } private static void DeleteStaleCopies( diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 68d50b7727..b6b630f3be 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -87,14 +87,15 @@ edited, most recent first, read by the warm-up after a domain reload). Warm-up after a domain reload: on the first Editor update tick after a reload, hot reload loads in the background what the first run would otherwise load cold, for the most recent assemblies in -`targets.txt` (up to eight) whose dll and PDB exist: their compiled call sites, the -referenced-method sets of the assemblies that reference them, and their PDB document lists. It +`targets.txt` (up to eight) whose dll and PDB exist: the publicized copy their shim compile +references, their compiled call sites, the referenced-method sets of the assemblies that reference them, and their PDB document lists. It does not start while the Editor compiles or imports, and a domain reload or a compile start stops it. A run that arrives while it works waits only for the unit (one dll) in flight, whose result it reuses; that item and the remaining ones are dropped; a run that arrives before the tick keeps it from starting. `hot_reload_warm_up_complete` and `hot_reload_warm_up_skipped` show what it did, and the run's `warm_up_yield` step shows how long the run waited (see `docs/vibe-logs.md`). The transform -worker, the shared Roslyn worker and the publicized copies are not warmed up yet. +worker, the shared Roslyn worker and the copies of the other project assemblies a shim compile +references are not warmed up yet. The shim compile references a fully publicized copy of the edited assembly, and of every other project assembly that grants the edited one its internals through `InternalsVisibleTo`. Any other project assembly is referenced through a `PublicizedExternalRefs` copy that keeps its top-level diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 687cb5c08c..f33ff6792e 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -53,7 +53,8 @@ command ran against: `hot_reload_shim_references_timing` (one per shim compile) has the resolver directories, the target's publicized copy, the artifact copies and the reference copies with their counts. `hot_reload_publicized_copy_written` appears only when a rewritten copy was written, with - its rewrite and write time. `hot_reload_shim_compiler_timing` has the compile, the load and + its rewrite and write time. The warm-up's `publicized_targets` item writes it too, before any + run. `hot_reload_shim_compiler_timing` has the compile, the load and the backend kind. `dynamic_code_shared_worker_started` appears whenever no live shared Roslyn worker could serve the compile and one had to be started, with its source sync, worker assembly check and process spawn; `ready` is false when the start failed (no worker @@ -68,6 +69,8 @@ command ran against: `hot_reload_warm_up_skipped` with its `reason` (`no_targets`, `compiling`, `updating`, `no_compiled_assembly`, `run_started_first`). `cancelled` covers both an item that never started (`ms` 0) and one stopped between its units (one dll each) with units left, with the time it ran. + The items run in the order `publicized_targets`, `call_sites`, `referenced_method_sets`, + `pdb_documents`. A run's `steps` have `warm_up_yield`, the time it waited for the unit in flight, and `warm_up_targets`, the time it took to record its assemblies for the next warm-up. - CLI side: only when the `ULOOP_DEBUG` environment variable is set to a value other than empty, From 19f7381d23aa909b05facb07d710a7c6194ca67c Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 9 Oct 2026 14:07:37 +0900 Subject: [PATCH 25/29] fix: Hot reload no longer mistakes an edit made right after compile for compiled source (#3255) --- .../HotReloadCompositionRootTests.cs | 4 +- .../HotReload/HotReloadDefaultFilesTests.cs | 6 +- .../HotReload/HotReloadEditorStartupTests.cs | 74 +++++++++++++ .../HotReloadEditorStartupTests.cs.meta | 11 ++ .../HotReloadPatcherContractTests.cs | 2 +- .../Editor/HotReload/HotReloadPatcherTests.cs | 2 +- .../HotReloadSourceSnapshotCaptureTests.cs | 102 ++++++++++++++++-- .../UnityCliLoopEditorBootstrapper.cs | 8 ++ .../FirstPartyToolsEditorStartup.cs | 19 ++++ .../HotReload/HotReloadEditorStartup.cs | 18 +++- .../HotReload/HotReloadTools.cs | 8 +- .../HotReload/Shared/HotReloadConstants.cs | 7 ++ .../Shared/HotReloadSourceSnapshotCapture.cs | 40 +++++-- .../Shared/HotReloadSourceSnapshotter.cs | 7 +- docs/vibe-logs.md | 4 + 15 files changed, 279 insertions(+), 33 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs index 95f6e395f7..1653bfe1be 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs @@ -182,7 +182,7 @@ private static HotReloadServices CreateServicesSharing(HotReloadDomain domain) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => { }), + new HotReloadSourceSnapshotCapture(() => true), installed.WarmUp); } @@ -196,7 +196,7 @@ private static HotReloadServices CreateServicesOwning(HotReloadDomain domain) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => { }), + new HotReloadSourceSnapshotCapture(() => true), HotReloadWarmUpTestDoubles.CreateInert()); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs index 065e265d76..77b610072f 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs @@ -358,7 +358,7 @@ public async Task ExecuteAsync_WhenFilesAreOmitted_CapturesTheSourceSnapshotBefo { int captureCount = 0; using IDisposable captureScope = HotReloadServicesTestScope.BeginWithSourceSnapshotCapture( - new HotReloadSourceSnapshotCapture(() => captureCount++)); + new HotReloadSourceSnapshotCapture(() => { captureCount++; return true; })); int captureCountAtDetection = -1; using IDisposable detectorScope = HotReloadServicesTestScope.BeginWithChangeDetector( new HotReloadStubChangeDetector(() => @@ -387,7 +387,7 @@ public async Task ExecuteAsync_WithFiles_CapturesTheSourceSnapshotBeforeTheRun() { int captureCount = 0; using IDisposable captureScope = HotReloadServicesTestScope.BeginWithSourceSnapshotCapture( - new HotReloadSourceSnapshotCapture(() => captureCount++)); + new HotReloadSourceSnapshotCapture(() => { captureCount++; return true; })); int captureCountAtRun = -1; using IDisposable orchestratorScope = HotReloadServicesTestScope.BeginWithOrchestrator( new HotReloadStubOrchestrator((files, ignoredCt) => @@ -409,7 +409,7 @@ public async Task ExecuteAsync_ForStatus_DoesNotCaptureTheSourceSnapshot() { int captureCount = 0; using IDisposable captureScope = HotReloadServicesTestScope.BeginWithSourceSnapshotCapture( - new HotReloadSourceSnapshotCapture(() => captureCount++)); + new HotReloadSourceSnapshotCapture(() => { captureCount++; return true; })); HotReloadResponse response = await ExecuteAsync(new JObject { ["Status"] = true }); diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs new file mode 100644 index 0000000000..7cb294de6e --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs @@ -0,0 +1,74 @@ +using System; + +using Newtonsoft.Json.Linq; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// EditMode coverage for the source snapshot capture the Editor startup runs before this + /// domain serves any command. Works on a capture installed in place of the production one. + /// + public class HotReloadEditorStartupTests + { + /// + /// What: the capture before serving commands runs the installed capture once and records the domain-load trigger. + /// + [Test] + public void CaptureSourceSnapshotBeforeServingCommands_RunsTheInstalledCaptureOnceWithTheDomainLoadTrigger() + { + VibeLogger.ClearMemoryLogs(); + int captureCount = 0; + HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + { + captureCount++; + return true; + }); + + using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) + { + HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(); + } + + Assert.That(captureCount, Is.EqualTo(1), "captures"); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured); + Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); + } + + /// + /// What: a capture that throws before serving commands passes the exception on and stays + /// undone, so the first update tick's capture runs it again. + /// + [Test] + public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureThrows_ThrowsAndLeavesItForTheNextCall() + { + VibeLogger.ClearMemoryLogs(); + int captureCount = 0; + HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + { + captureCount++; + if (captureCount == 1) + { + throw new InvalidOperationException("The capture failed."); + } + + return true; + }); + + using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) + { + Assert.Throws( + () => HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands()); + + HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured( + HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + } + + Assert.That(captureCount, Is.EqualTo(2), "captures"); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs.meta new file mode 100644 index 0000000000..d9d472bc98 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e82e42c4846024cd386a1bac102c1616 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs index f7a1b892e4..cd48cea250 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs @@ -216,7 +216,7 @@ private static IDisposable BeginReplacementWith(IHotReloadHarmony harmony) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => { }), + new HotReloadSourceSnapshotCapture(() => true), HotReloadWarmUpTestDoubles.CreateInert())); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs index 006c24873b..59fd7c093f 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs @@ -90,7 +90,7 @@ private static IDisposable BeginReplacementWith(IHotReloadHarmony harmony) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => { }), + new HotReloadSourceSnapshotCapture(() => true), HotReloadWarmUpTestDoubles.CreateInert())); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs index 951a4538d4..1da3d54d13 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs @@ -1,8 +1,11 @@ using System; +using Newtonsoft.Json.Linq; + using NUnit.Framework; using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { @@ -18,10 +21,14 @@ public class HotReloadSourceSnapshotCaptureTests public void EnsureCaptured_CalledTwice_RunsTheCaptureOnce() { int captureCount = 0; - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => captureCount++); + HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + { + captureCount++; + return true; + }); - capture.EnsureCaptured(); - capture.EnsureCaptured(); + capture.EnsureCaptured("test"); + capture.EnsureCaptured("test"); Assert.That(captureCount, Is.EqualTo(1)); } @@ -42,17 +49,100 @@ public void EnsureCaptured_WhenTheCaptureThrows_RunsItAgainOnTheNextCall() { throw failure; } + + return true; }); InvalidOperationException thrown = - Assert.Throws(() => capture.EnsureCaptured()); + Assert.Throws(() => capture.EnsureCaptured("test")); Assert.That(thrown, Is.SameAs(failure)); - Assert.DoesNotThrow(() => capture.EnsureCaptured()); + Assert.DoesNotThrow(() => capture.EnsureCaptured("test")); Assert.That(captureCount, Is.EqualTo(2)); - capture.EnsureCaptured(); + capture.EnsureCaptured("test"); Assert.That(captureCount, Is.EqualTo(2)); } + + /// + /// What: a capture that runs to completion writes one captured entry naming its trigger and how long it took. + /// + [Test] + public void EnsureCaptured_WhenTheCaptureRuns_WritesOneCapturedEntryWithItsTrigger() + { + VibeLogger.ClearMemoryLogs(); + HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => true); + + capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad); + + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured); + Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); + Assert.That(context["captureMs"].Type, Is.EqualTo(JTokenType.Integer), "captureMs type"); + Assert.That((long)context["captureMs"], Is.GreaterThanOrEqualTo(0), "captureMs"); + } + + /// + /// What: a call that finds the capture already done writes no second entry, so the entry keeps the first trigger. + /// + [Test] + public void EnsureCaptured_WhenAlreadyCaptured_WritesNoSecondEntry() + { + VibeLogger.ClearMemoryLogs(); + HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => true); + + capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad); + capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured); + Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); + } + + /// + /// What: a capture that throws writes no captured entry. + /// + [Test] + public void EnsureCaptured_WhenTheCaptureThrows_WritesNoEntry() + { + VibeLogger.ClearMemoryLogs(); + HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + { + throw new InvalidOperationException("The capture failed."); + }); + + Assert.Throws( + () => capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); + + Assert.That(CountCapturedEntries(), Is.EqualTo(0)); + } + + /// + /// What: a capture that saw no compilation assembly is not marked done and writes no entry, + /// so the next call runs it again and the entry names that call's trigger. + /// + [Test] + public void EnsureCaptured_WhenTheCaptureSawNoAssembly_RunsItAgainOnTheNextCall() + { + VibeLogger.ClearMemoryLogs(); + int captureCount = 0; + HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + { + captureCount++; + return captureCount > 1; + }); + + capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad); + Assert.That(CountCapturedEntries(), Is.EqualTo(0), "entries after the first call"); + + capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + + Assert.That(captureCount, Is.EqualTo(2), "captures"); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured); + Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick)); + } + + private static int CountCapturedEntries() + { + return JArray.Parse(VibeLogger.GetLogsForAi(HotReloadConstants.VibeLogSourceSnapshotCaptured)).Count; + } } } diff --git a/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs index c2ad373290..e811db4752 100644 --- a/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs +++ b/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs @@ -39,6 +39,14 @@ internal void Initialize() applicationServices.ToolSettingsUseCase, applicationServices.SkillSetupUseCase, applicationServices.ThirdPartyToolMigrationUseCase); + // Why last: a capture that throws stops nothing after it; Unity logs the exception and + // hot reload captures again on the first update tick. Why inside this + // InitializeOnLoadMethod and not afterAssemblyReload: the IPC listener opens in + // afterAssemblyReload, and every command except get-editor-status runs on the main + // thread's update or tick, so no command of this domain is answered before this + // returns — an edit made after `uloop compile` returns can no longer be captured as + // compiled source. + FirstPartyToolsEditorStartup.PrepareBeforeServingCommands(); } } } diff --git a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs index 2935d55193..7a279d88b1 100644 --- a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs @@ -1,3 +1,5 @@ +using UnityEditor; + namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { // Keeps bundled tool initialization inside the bundled-tool assembly. @@ -31,6 +33,23 @@ public static void Initialize() #endif } + /// + /// Runs the first-party tool work that has to finish before this domain answers its first + /// uloop command. Called once, at the end of the Editor startup. + /// + public static void PrepareBeforeServingCommands() + { + // Why not in an import worker: it never serves commands (the server does not start + // there) and it shares Library with the main Editor, so a capture there would only + // race the main Editor's own. + if (AssetDatabase.IsAssetImportWorkerProcess()) + { + return; + } + + HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(); + } + public static void ResetServerScopedServices() { ExecuteDynamicCodeEditorStartup.ResetServerScopedServices(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs index 07758e9097..407ffd9d92 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs @@ -17,13 +17,13 @@ public static void Initialize() // "Scripts have compiler errors" dialog never flushes delayCall again for the rest of // that process's lifetime, even for later registrations — while // EditorApplication.update keeps ticking (see SetupWizardWindow.cs:56-70). The - // hot-reload apply entry makes sure of the capture through the same gate before it - // reads a snapshot; this tick still captures as early as it can when no request comes - // first, because a file edited before the capture is snapshotted with that edit. + // snapshot is captured at the end of the domain load; this tick retries only when that + // capture threw or Unity listed no compilation assembly yet, and the gate does nothing + // when it already succeeded. void CaptureOnFirstUpdateTick() { EditorApplication.update -= CaptureOnFirstUpdateTick; - HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured(); + HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); } // Why a callback of its own rather than a line in the capture above: an exception in @@ -77,5 +77,15 @@ void StartWarmUpOnFirstUpdateTick() HotReloadWiredValueEditorHooks.Initialize(); TransformWorkerHostLifecycle.RegisterForEditorStartup(); } + + /// + /// Captures the source snapshot of the current compile before this domain answers any uloop + /// command, so an edit made after the compile returned is never captured as compiled source. + /// + internal static void CaptureSourceSnapshotBeforeServingCommands() + { + HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured( + HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad); + } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs index 54cd817ecc..53db67b8eb 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs @@ -331,10 +331,10 @@ protected override async Task ExecuteAsync( // Why here and not only at the run entry: the tool normalizes script paths for its own // selection and response rows, and PackageInfo is main-thread only, which this path is. services.PackageRootCapture.CaptureCurrent(); - // Why here: a request that waited out a domain reload runs before the Editor's first update - // tick, where the capture is scheduled, and both the default selection below and the run - // read the snapshot of the compile that reload loaded. - services.SourceSnapshotCapture.EnsureCaptured(); + // Why here: both the default selection below and the run read the snapshot of the compile + // the last reload loaded. The gate does nothing when the domain load captured it; it + // captures here when that capture threw or Unity listed no compilation assembly yet. + services.SourceSnapshotCapture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerApply); HotReloadDefaultFileSelection selection = HotReloadDefaultFileSelector.Resolve( parameters.Files, services.ChangeDetector.Detect, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 6ebff323d5..e8af99090c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -681,6 +681,13 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string VibeLogCallSiteCacheEvicted = "hot_reload_call_site_cache_evicted"; public const string VibeLogWarmUpComplete = "hot_reload_warm_up_complete"; public const string VibeLogWarmUpSkipped = "hot_reload_warm_up_skipped"; + public const string VibeLogSourceSnapshotCaptured = "hot_reload_source_snapshot_captured"; + + // What captured the source snapshot of a domain, as named in the trigger field of + // hot_reload_source_snapshot_captured. + public const string SourceSnapshotCaptureTriggerDomainLoad = "domain_load"; + public const string SourceSnapshotCaptureTriggerFirstUpdateTick = "first_update_tick"; + public const string SourceSnapshotCaptureTriggerApply = "apply"; // What stopped a running warm-up, as named in the cancelledBy field of hot_reload_warm_up_complete. public const string WarmUpCancelledByRun = "run"; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs index b0e9e8a45e..bbeadc3eed 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs @@ -1,41 +1,61 @@ using System; +using System.Diagnostics; -using UnityEngine; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +using Debug = UnityEngine.Debug; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Captures the source snapshot of the current compile once per domain, for whichever comes - /// first: the Editor's first update tick after a domain reload, or a reload about to read - /// a snapshot. + /// Captures the source snapshot of the current compile once per domain, at the end of the + /// domain load. When that capture throws or Unity listed no compilation assembly yet, the + /// first update tick or the hot reload apply entry captures again. /// internal sealed class HotReloadSourceSnapshotCapture { - private readonly Action _capture; + private readonly Func _capture; private bool _captured; - internal HotReloadSourceSnapshotCapture(Action capture) + /// + /// Captures the snapshot and returns whether Unity listed at least one compilation assembly. + /// + internal HotReloadSourceSnapshotCapture(Func capture) { Debug.Assert(capture != null, "capture must not be null."); _capture = capture; } /// - /// Runs the capture unless it already ran to completion in this domain. Main thread - /// only. A capture that throws is not marked done, so the next caller runs it again. + /// Runs the capture unless it already ran to completion in this domain, and records + /// when this call completes it. Main thread only. A capture that + /// throws or saw no compilation assembly is not marked done, so the next caller runs it again. /// - internal void EnsureCaptured() + internal void EnsureCaptured(string trigger) { + Debug.Assert(!string.IsNullOrEmpty(trigger), "trigger must not be null or empty."); if (_captured) { return; } - _capture(); + Stopwatch watch = Stopwatch.StartNew(); // Marked only after the capture returns: a capture that threw may have left assemblies // without a snapshot, and marking it done first would keep every later reader from // capturing them. + bool sawAssemblies = _capture(); + // Why an empty list is not done: Unity lists none while it compiles, and a domain marked + // captured from an empty list would never be captured by any of its readers. + if (!sawAssemblies) + { + return; + } + _captured = true; + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotCaptured, + "Hot reload captured the source snapshot of this domain.", + new { trigger, captureMs = watch.ElapsedMilliseconds }); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index 649ea487bc..ec430b1099 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -28,11 +28,14 @@ internal static class HotReloadSourceSnapshotter /// Why: adoption is decided at use time by PDB document checksum, so a racy capture /// (edit between compile and this call) can only fail closed into "no baseline" — /// never into a silently wrong method diff. + /// Returns false when Unity listed no compilation assembly (as while it compiles). /// - internal static void CaptureAfterDomainReload() + internal static bool CaptureAfterDomainReload() { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - CaptureAssemblies(projectRoot, HotReloadCompilationAssemblies.Current()); + IReadOnlyList assemblies = HotReloadCompilationAssemblies.Current(); + CaptureAssemblies(projectRoot, assemblies); + return assemblies.Count > 0; } // Why separate from CaptureAfterDomainReload: the project root and the compilation assemblies come diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index f33ff6792e..57f5566b3f 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -63,6 +63,10 @@ command ran against: from `/UnityCliLoopCompilation/RoslynWorkerCache//`, keyed by the worker source and the compiler paths), or `built` (csc ran); `cachePublish` and `cachePublishError` say whether a built assembly was added to that cache. + `hot_reload_source_snapshot_captured` appears once per domain when the source snapshot + capture ran to completion, with its `trigger` (`domain_load` normally; `first_update_tick` or + `apply` only when the capture at domain load threw or Unity listed no compilation assembly + yet) and `captureMs`. After a domain reload, the warm-up writes one `hot_reload_warm_up_complete` with the target assemblies, each item's `outcome` (`done`, `cancelled` or `failed`) and time, and `cancelledBy` (`run`, `beforeAssemblyReload`, `compilationStarted`, or null), or one From 57bbb472572b9021463d268177a8cee5d889b867 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 9 Oct 2026 14:20:53 +0900 Subject: [PATCH 26/29] fix: Hot reload no longer silently skips a file saved while the compile was running (#3256) --- .../references/scope-and-limits.md | 3 + .../references/scope-and-limits.md | 3 + ...ReloadChangedSiblingSourceDetectorTests.cs | 95 +++++++- .../HotReload/HotReloadCompileStartTests.cs | 144 ++++++++++++ .../HotReloadCompileStartTests.cs.meta | 11 + ...tReloadSnapshotAssemblyEnumerationTests.cs | 106 ++++++++- ...tReloadSnapshotEditedDuringCompileTests.cs | 167 +++++++++++++ ...adSnapshotEditedDuringCompileTests.cs.meta | 11 + .../HotReload/HotReloadSourceSnapshotTests.cs | 17 +- .../HotReloadSourceSnapshotterTests.cs | 117 +++++++++- .../HotReloadSourceStampManifestTests.cs | 81 ++++++- .../HotReloadChangedSiblingSourceDetector.cs | 11 +- .../HotReload/HotReloadEditorStartup.cs | 1 + .../HotReload/Shared/HotReloadCompileStart.cs | 49 ++++ .../Shared/HotReloadCompileStart.cs.meta | 11 + .../Shared/HotReloadCompileStartRecord.cs | 52 +++++ .../HotReloadCompileStartRecord.cs.meta | 11 + .../HotReload/Shared/HotReloadConstants.cs | 15 +- .../Shared/HotReloadSnapshotSourceCheck.cs | 73 ++++++ .../HotReloadSnapshotSourceCheck.cs.meta | 11 + .../Shared/HotReloadSourceBaseline.cs | 53 ++++- .../Shared/HotReloadSourceSnapshotter.cs | 221 +++++++++++++++--- .../Shared/HotReloadSourceStampManifest.cs | 48 +++- .../Skill/references/scope-and-limits.md | 3 + docs/hot-reload.md | 5 +- docs/vibe-logs.md | 5 +- 26 files changed, 1252 insertions(+), 72 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompileStartTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompileStartTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStart.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStart.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStartRecord.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStartRecord.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs.meta diff --git a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md index e42aaee8db..338c531eb7 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -356,6 +356,9 @@ earlier package version carries no such record and is compared by bytes once per session. This applies wherever a source is compared with its baseline: finding drifted siblings, deciding which live patches to re-apply after a skip, and selecting files when `--files` is omitted. +A file saved while the compile was running — after the compiler read it — is marked when +the snapshot is captured: a run without `--files` selects it, and its methods are all +patched with a warning, until the next compile. Property getters with a body (including expression-bodied properties) are patched like ordinary methods. Editing a compiled property's setter, init, or indexer accessor diff --git a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md index e42aaee8db..338c531eb7 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -356,6 +356,9 @@ earlier package version carries no such record and is compared by bytes once per session. This applies wherever a source is compared with its baseline: finding drifted siblings, deciding which live patches to re-apply after a skip, and selecting files when `--files` is omitted. +A file saved while the compile was running — after the compiler read it — is marked when +the snapshot is captured: a run without `--files` selects it, and its methods are all +patched with a warning, until the next compile. Property getters with a body (including expression-bodied properties) are patched like ordinary methods. Editing a compiled property's setter, init, or indexer accessor diff --git a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs index c80c6ccb79..74f578438f 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs @@ -828,6 +828,89 @@ public void DetectAllChangedFromSnapshotDirectory_WhenTheManifestStampEqualsTheS } } + /// + /// What: a sibling the capture marked as edited after the compile is reported as changed, + /// even though its bytes and stamp equal the snapshot. + /// + [Test] + public void DetectFromSnapshotDirectory_ASiblingMarkedEditedAfterCompile_ReportsItThoughItsBytesMatch() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + WriteStampManifest(projectRoot, "Asm-mvid", MarkedStampLine(siblingRelative, 12, FirstWriteTimeUtc)); + + Assert.That( + ScanSibling(projectRoot, "Asm-mvid", siblingRelative), + Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) })); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: the re-apply check after a skip does not take a file marked as edited after the + /// compile to be back at its compiled source, even though its bytes and stamp equal the snapshot. + /// + [Test] + public void SourceMatchesSnapshotDirectory_AFileMarkedEditedAfterCompile_IsFalseThoughItsBytesMatch() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + WriteStampManifest(projectRoot, "Asm-mvid", MarkedStampLine(siblingRelative, 12, FirstWriteTimeUtc)); + + bool matches = HotReloadChangedSiblingSourceDetector.SourceMatchesSnapshotDirectory( + projectRoot, + "Asm-mvid", + siblingRelative, + AbsoluteProjectPath(projectRoot, siblingRelative)); + + Assert.That(matches, Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: the default file selection picks a file marked as edited after the compile, even + /// though its bytes and stamp equal the snapshot. + /// + [Test] + public void DetectAllChangedFromSnapshotDirectory_AFileMarkedEditedAfterCompile_SelectsItThoughItsBytesMatch() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + WriteStampManifest(projectRoot, "Asm-mvid", MarkedStampLine(siblingRelative, 12, FirstWriteTimeUtc)); + + HotReloadChangedSourceScanResult result = + HotReloadChangedSiblingSourceDetector.DetectAllChangedFromSnapshotDirectory( + projectRoot, + "Asm-mvid", + new[] { siblingRelative }); + + Assert.That(result.ChangedProjectRelativePaths, Is.EqualTo(new[] { siblingRelative })); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + /// /// What: sibling-derived warnings are ordinal-deduped among themselves and skipped /// when the own-file list already contains the exact string, without collapsing @@ -916,7 +999,7 @@ private static void WriteSnapshot( } // Plants a stamp manifest as the capture would write it: the header line, then one - // "\t\t" line per entry, "\n" after every line. + // "\t\t\t" line per entry, "\n" after every line. private static void WriteStampManifest( string projectRoot, string assemblySnapshotDirectoryName, @@ -944,7 +1027,15 @@ private static string StampLine(string projectRelativePath, long length, DateTim { return HotReloadSourceSnapshotter.HashProjectRelativePath(projectRelativePath.Replace('\\', '/')) + ".cs\t" + length.ToString(CultureInfo.InvariantCulture) - + "\t" + lastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture); + + "\t" + lastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture) + + "\t0"; + } + + // A stamp line the capture marked as edited after the compile. + private static string MarkedStampLine(string projectRelativePath, long length, DateTime lastWriteTimeUtc) + { + string unmarked = StampLine(projectRelativePath, length, lastWriteTimeUtc); + return unmarked.Substring(0, unmarked.Length - 1) + "1"; } private static string AbsoluteProjectPath(string projectRoot, string projectRelativePath) diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompileStartTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompileStartTests.cs new file mode 100644 index 0000000000..15ed27808c --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompileStartTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Reflection; + +using NUnit.Framework; + +using UnityEditor; +using UnityEditor.Compilation; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// EditMode coverage for the compile start the snapshot capture checks sources from: which time + /// it answers for a DLL, and how the time is recorded across a compile's domain reload. + /// + public sealed class HotReloadCompileStartTests + { + private string _savedRecord; + + [SetUp] + public void SetUp() + { + _savedRecord = SessionState.GetString(HotReloadConstants.CompileStartedUtcTicksSessionStateKey, null); + } + + [TearDown] + public void TearDown() + { + if (_savedRecord == null) + { + SessionState.EraseString(HotReloadConstants.CompileStartedUtcTicksSessionStateKey); + return; + } + + SessionState.SetString(HotReloadConstants.CompileStartedUtcTicksSessionStateKey, _savedRecord); + } + + /// + /// Verifies that a compile start recorded before the DLL was written is where the check starts. + /// + [Test] + public void SuspectWritesFrom_WhenTheRecordedStartIsBeforeTheDllWrite_ReturnsTheStart() + { + Assert.That(HotReloadCompileStart.At(100).SuspectWritesFrom(200), Is.EqualTo(100)); + } + + /// + /// Verifies that a compile start recorded after the DLL was written, as a later compile that + /// did not rewrite the DLL leaves it, never moves the check past the DLL write. + /// + [Test] + public void SuspectWritesFrom_WhenTheRecordedStartIsAfterTheDllWrite_ReturnsTheDllWrite() + { + Assert.That(HotReloadCompileStart.At(300).SuspectWritesFrom(200), Is.EqualTo(200)); + } + + /// + /// Verifies that without a recorded start the check starts at the DLL write. + /// + [Test] + public void SuspectWritesFrom_WhenUnknown_ReturnsTheDllWrite() + { + Assert.That(HotReloadCompileStart.Unknown.SuspectWritesFrom(200), Is.EqualTo(200)); + } + + /// + /// Verifies that a recorded start reads back. + /// + [Test] + public void Read_AfterRecord_ReturnsThatStart() + { + HotReloadCompileStartRecord.Record(123); + + Assert.That(HotReloadCompileStartRecord.Read().SuspectWritesFrom(long.MaxValue), Is.EqualTo(123)); + } + + /// + /// Verifies that a stored value that is not a number reads as no recorded start. + /// + [Test] + public void Read_WhenTheStoredValueIsNotANumber_ReturnsUnknown() + { + SessionState.SetString(HotReloadConstants.CompileStartedUtcTicksSessionStateKey, "x"); + + Assert.That(HotReloadCompileStartRecord.Read().SuspectWritesFrom(200), Is.EqualTo(200)); + } + + /// + /// Verifies that Initialize subscribes the recorder to compile start. + /// + [Test] + public void Initialize_SubscribesTheRecorderToCompilationStarted() + { + HotReloadCompileStartRecord.Initialize(); + + Assert.That( + StaticEventHasHandler(typeof(CompilationPipeline), IsTheRecorder), + Is.True, + "HotReloadCompileStartRecord.OnCompilationStarted must be subscribed on CompilationPipeline."); + } + + // Why the declaring type as well as the name: HotReloadCompilationAssemblies subscribes a + // handler of the same name to the same event. + private static bool IsTheRecorder(Delegate listener) + { + MethodInfo listenerMethod = listener.Method; + return listenerMethod.DeclaringType == typeof(HotReloadCompileStartRecord) + && listenerMethod.Name == nameof(HotReloadCompileStartRecord.OnCompilationStarted); + } + + // Why scan the fields: CompilationPipeline keeps its event handlers in static Delegate fields + // that no public member lists. + private static bool StaticEventHasHandler(Type eventOwner, Func isMatch) + { + FieldInfo[] fields = eventOwner.GetFields( + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); + for (int index = 0; index < fields.Length; index++) + { + Delegate current = fields[index].GetValue(null) as Delegate; + if (current != null && InvocationListContains(current, isMatch)) + { + return true; + } + } + + return false; + } + + private static bool InvocationListContains(Delegate current, Func isMatch) + { + Delegate[] listeners = current.GetInvocationList(); + for (int listenerIndex = 0; listenerIndex < listeners.Length; listenerIndex++) + { + if (isMatch(listeners[listenerIndex])) + { + return true; + } + } + + return false; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompileStartTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompileStartTests.cs.meta new file mode 100644 index 0000000000..7402685a44 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompileStartTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c52f633c14c004ca9932943103143f00 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs index f2db96c95f..fde5e5ce40 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs @@ -23,6 +23,7 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload public sealed class HotReloadSnapshotAssemblyEnumerationTests { private const string SourceRelativePath = "Assets/Fixture.cs"; + private static readonly DateTime CompileStartUtc = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); private string _projectRoot; private string _snapshotRoot; @@ -35,7 +36,11 @@ public void SetUp() Directory.CreateDirectory(Path.Combine(_projectRoot, "Assets")); Directory.CreateDirectory(Path.Combine(_projectRoot, HotReloadConstants.ScriptAssembliesRelativeDirectory)); Directory.CreateDirectory(_snapshotRoot); - File.WriteAllText(Path.Combine(_projectRoot, "Assets", "Fixture.cs"), "class Fixture {}\n"); + string fixturePath = Path.Combine(_projectRoot, "Assets", "Fixture.cs"); + File.WriteAllText(fixturePath, "class Fixture {}\n"); + // A source the compile read is older than the compiled assembly; keeping it so leaves it + // out of the capture's PDB check. + File.SetLastWriteTimeUtc(fixturePath, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)); } [TearDown] @@ -59,7 +64,12 @@ public void CaptureAssemblyIfNeeded_WithoutStamp_CapturesSourcesRemovesStaleSnap string staleDirectory = Path.Combine(_snapshotRoot, "Fixture-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staleDirectory); - HotReloadSourceSnapshotter.CaptureAssemblyIfNeeded(_projectRoot, _snapshotRoot, CreateAssembly("Fixture")); + HotReloadSourceSnapshotter.CaptureAssemblyIfNeeded( + _projectRoot, + _snapshotRoot, + CreateAssembly("Fixture"), + HotReloadCompileStart.Unknown, + CreateDocumentIndex()); Assert.That(File.Exists(SnapshotSourcePath("Fixture", mvid)), Is.True); Assert.That(Directory.Exists(staleDirectory), Is.False); @@ -77,7 +87,12 @@ public void CaptureAssemblyIfNeeded_WhenStampMatches_SkipsCapture() string stamp = ExpectedStamp(dllPath, mvid); File.WriteAllText(StampPath("Fixture"), stamp); - HotReloadSourceSnapshotter.CaptureAssemblyIfNeeded(_projectRoot, _snapshotRoot, CreateAssembly("Fixture")); + HotReloadSourceSnapshotter.CaptureAssemblyIfNeeded( + _projectRoot, + _snapshotRoot, + CreateAssembly("Fixture"), + HotReloadCompileStart.Unknown, + CreateDocumentIndex()); Assert.That(Directory.Exists(Path.Combine(_snapshotRoot, "Fixture-" + mvid)), Is.False); Assert.That(File.ReadAllText(StampPath("Fixture")), Is.EqualTo(stamp)); @@ -97,7 +112,12 @@ public void CaptureAssemblyIfNeeded_WhenMvidDirectoryExists_KeepsSnapshotsAndWri string staleDirectory = Path.Combine(_snapshotRoot, "Fixture-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staleDirectory); - HotReloadSourceSnapshotter.CaptureAssemblyIfNeeded(_projectRoot, _snapshotRoot, CreateAssembly("Fixture")); + HotReloadSourceSnapshotter.CaptureAssemblyIfNeeded( + _projectRoot, + _snapshotRoot, + CreateAssembly("Fixture"), + HotReloadCompileStart.Unknown, + CreateDocumentIndex()); Assert.That(Directory.GetFiles(currentDirectory), Is.Empty); Assert.That(Directory.Exists(staleDirectory), Is.True); @@ -123,7 +143,9 @@ public void CaptureAssemblies_WhenOneAssemblyCannotBeRead_WarnsAndCapturesTheRes HotReloadSourceSnapshotter.CaptureAssemblies( _projectRoot, - new[] { CreateAssembly("Broken"), CreateAssembly("Fixture") }); + new[] { CreateAssembly("Broken"), CreateAssembly("Fixture") }, + HotReloadCompileStart.Unknown, + CreateDocumentIndex()); Assert.That(File.Exists(StampPath("Broken")), Is.False); Assert.That(File.Exists(SnapshotSourcePath("Fixture", mvid)), Is.True); @@ -175,10 +197,18 @@ public void CaptureAssemblies_ForVirtualPlayerRoot_ReadsTheMainProjectsDllAndSna File.Copy(Path.ChangeExtension(sourceDllPath, ".pdb"), Path.ChangeExtension(dllPath, ".pdb")); string playerRoot = Path.Combine(mainRoot, "Library", "VP", "mppm1"); Directory.CreateDirectory(Path.Combine(playerRoot, "Assets")); - File.WriteAllText(Path.Combine(playerRoot, "Assets", "Fixture.cs"), "class Fixture {}\n"); + string playerFixturePath = Path.Combine(playerRoot, "Assets", "Fixture.cs"); + File.WriteAllText(playerFixturePath, "class Fixture {}\n"); + // A source the compile read is older than the compiled assembly; keeping it so leaves + // it out of the capture's PDB check. + File.SetLastWriteTimeUtc(playerFixturePath, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)); string mvid = HotReloadSourceSnapshotter.ReadAssemblyMvid(dllPath); - HotReloadSourceSnapshotter.CaptureAssemblies(playerRoot, new[] { CreateAssembly("Fixture") }); + HotReloadSourceSnapshotter.CaptureAssemblies( + playerRoot, + new[] { CreateAssembly("Fixture") }, + HotReloadCompileStart.Unknown, + CreateDocumentIndex()); string playerSnapshotSource = Path.Combine( playerRoot, @@ -197,6 +227,68 @@ public void CaptureAssemblies_ForVirtualPlayerRoot_ReadsTheMainProjectsDllAndSna } } + /// + /// Verifies that the capture takes the recorded compile start as the start of the PDB check, so + /// a source saved after the compile started but before the DLL was written is checked and, with + /// no PDB document to confirm it, marked as edited after the compile. + /// + [Test] + public void CaptureAssemblyIfNeeded_ASourceSavedAfterTheCompileStartedButBeforeTheDll_IsMarked() + { + string snapshotDirectory = CaptureWithSourceSavedBetweenStartAndDll( + HotReloadCompileStart.At(CompileStartUtc.Ticks)); + + HotReloadSourceStampManifest manifest = HotReloadSourceStampManifest.Load(snapshotDirectory); + Assert.That(manifest.IsEditedAfterCompile(SnapshotFileName()), Is.True); + } + + /// + /// Verifies that without a recorded compile start the same source is not checked, because the + /// check then starts at the DLL write; the mark in the case above comes from the compile start. + /// + [Test] + public void CaptureAssemblyIfNeeded_WithoutARecordedStart_LeavesASourceSavedBeforeTheDllUnmarked() + { + string snapshotDirectory = CaptureWithSourceSavedBetweenStartAndDll(HotReloadCompileStart.Unknown); + + HotReloadSourceStampManifest manifest = HotReloadSourceStampManifest.Load(snapshotDirectory); + Assert.That(manifest.IsEditedAfterCompile(SnapshotFileName()), Is.False); + Assert.That(manifest.TryGetStamp(SnapshotFileName(), out long _, out long _), Is.True); + } + + // Plants the compiled assembly and orders the write times as compile start < source < DLL. + // The fixture source has no document in the planted PDB, so a check cannot confirm it. + private string CaptureWithSourceSavedBetweenStartAndDll(HotReloadCompileStart compileStart) + { + string dllPath = PlantCompiledAssembly("Fixture"); + File.SetLastWriteTimeUtc(dllPath, new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + File.SetLastWriteTimeUtc( + Path.Combine(_projectRoot, "Assets", "Fixture.cs"), + new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + string mvid = HotReloadSourceSnapshotter.ReadAssemblyMvid(dllPath); + + HotReloadSourceSnapshotter.CaptureAssemblyIfNeeded( + _projectRoot, + _snapshotRoot, + CreateAssembly("Fixture"), + compileStart, + CreateDocumentIndex()); + + return Path.Combine(_snapshotRoot, "Fixture-" + mvid); + } + + private static string SnapshotFileName() + { + return HotReloadSourceSnapshotter.HashProjectRelativePath(SourceRelativePath) + ".cs"; + } + + // Persists the PDB document lists under the temporary root, so a test never writes the + // Editor's shared index. + private HotReloadPdbDocumentIndex CreateDocumentIndex() + { + return new HotReloadPdbDocumentIndex(Path.Combine(_projectRoot, "PdbDocuments")); + } + // Copies this test assembly's own DLL and PDB, so the fixture has a real image for Cecil to read. private string PlantCompiledAssembly(string assemblyName) { diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs new file mode 100644 index 0000000000..1be29d8fe9 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs @@ -0,0 +1,167 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// EditMode coverage for the capture's PDB check of sources written since the compile started, + /// run against this test assembly's own DLL and PDB and copies of its real fixture sources. + /// + public sealed class HotReloadSnapshotEditedDuringCompileTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string FixtureProjectRelativePath = + "Assets/Tests/Editor/HotReload/HotReloadE2EFixtures.cs"; + // A file without method bodies, so the PDB has no document for it. + private const string BodylessFixtureProjectRelativePath = + "Assets/Tests/Editor/HotReload/HotReloadSnapshotBodylessFixture.cs"; + + private static readonly long CompileStartUtcTicks = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; + private static readonly DateTime SinceTheStartUtc = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime BeforeTheStartUtc = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private string _realProjectRoot; + private string _tempRoot; + private string _snapshotDirectory; + + [SetUp] + public void SetUp() + { + _realProjectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + _tempRoot = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N")); + _snapshotDirectory = Path.Combine(_tempRoot, "Snapshot"); + Directory.CreateDirectory(_tempRoot); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempRoot)) + { + Directory.Delete(_tempRoot, true); + } + } + + /// + /// Verifies that a source written since the compile started whose bytes match the PDB gets an + /// unmarked stamp. + /// + [Test] + public void CaptureAssemblySourcesAtomically_ASourceWrittenSinceTheStartThatMatchesThePdb_RecordsAnUnmarkedStamp() + { + PlantSource(FixtureProjectRelativePath, RealBytes(FixtureProjectRelativePath), SinceTheStartUtc); + + HotReloadSourceStampManifest manifest = Capture(FixtureProjectRelativePath); + + string snapshotFileName = SnapshotFileName(FixtureProjectRelativePath); + Assert.That(manifest.IsEditedAfterCompile(snapshotFileName), Is.False); + Assert.That(manifest.TryGetStamp(snapshotFileName, out long _, out long _), Is.True); + } + + /// + /// Verifies that a source written since the compile started whose bytes differ from the PDB is + /// marked, and that its copy is still written with the edited bytes. + /// + [Test] + public void CaptureAssemblySourcesAtomically_ASourceWrittenSinceTheStartThatDiffersFromThePdb_MarksItAndStillCopiesIt() + { + byte[] editedBytes = EditedBytes(FixtureProjectRelativePath); + PlantSource(FixtureProjectRelativePath, editedBytes, SinceTheStartUtc); + + HotReloadSourceStampManifest manifest = Capture(FixtureProjectRelativePath); + + string snapshotFileName = SnapshotFileName(FixtureProjectRelativePath); + string snapshotPath = Path.Combine(_snapshotDirectory, snapshotFileName); + Assert.That(manifest.IsEditedAfterCompile(snapshotFileName), Is.True); + Assert.That(File.Exists(snapshotPath), Is.True); + Assert.That(File.ReadAllBytes(snapshotPath), Is.EqualTo(editedBytes)); + } + + /// + /// Verifies that a source written before the compile started is not checked against the PDB, + /// so even bytes the PDB does not match stay unmarked. + /// + [Test] + public void CaptureAssemblySourcesAtomically_ASourceWrittenBeforeTheStart_IsNotCheckedAgainstThePdb() + { + PlantSource(FixtureProjectRelativePath, EditedBytes(FixtureProjectRelativePath), BeforeTheStartUtc); + + HotReloadSourceStampManifest manifest = Capture(FixtureProjectRelativePath); + + Assert.That(manifest.IsEditedAfterCompile(SnapshotFileName(FixtureProjectRelativePath)), Is.False); + } + + /// + /// Verifies that a source written since the compile started that the PDB has no document for is + /// marked, because it cannot be confirmed as the compiled source. + /// + [Test] + public void CaptureAssemblySourcesAtomically_ASourceThePdbHasNoDocumentFor_MarksIt() + { + PlantSource( + BodylessFixtureProjectRelativePath, + RealBytes(BodylessFixtureProjectRelativePath), + SinceTheStartUtc); + + HotReloadSourceStampManifest manifest = Capture(BodylessFixtureProjectRelativePath); + + Assert.That(manifest.IsEditedAfterCompile(SnapshotFileName(BodylessFixtureProjectRelativePath)), Is.True); + } + + private HotReloadSourceStampManifest Capture(string projectRelativePath) + { + string dllPath = Path.Combine( + _realProjectRoot, + HotReloadConstants.ScriptAssembliesRelativeDirectory, + TestAssemblyName + HotReloadConstants.CompiledAssemblyExtension); + HotReloadSnapshotSourceCheck check = new HotReloadSnapshotSourceCheck( + CompileStartUtcTicks, + dllPath, + Path.ChangeExtension(dllPath, ".pdb"), + HotReloadSourceSnapshotter.ReadAssemblyMvid(dllPath), + new HotReloadPdbDocumentIndex(Path.Combine(_tempRoot, "PdbDocuments"))); + + HotReloadSourceSnapshotter.CaptureAssemblySourcesAtomically( + _tempRoot, + _snapshotDirectory, + new[] { projectRelativePath }, + TestAssemblyName, + check); + + return HotReloadSourceStampManifest.Load(_snapshotDirectory); + } + + private byte[] RealBytes(string projectRelativePath) + { + return File.ReadAllBytes(Path.Combine(_realProjectRoot, projectRelativePath)); + } + + private byte[] EditedBytes(string projectRelativePath) + { + return RealBytes(projectRelativePath).Concat(Encoding.UTF8.GetBytes("// edited\n")).ToArray(); + } + + // Why a write time in whole seconds: SetLastWriteTimeUtc stores microseconds while + // LastWriteTimeUtc reports 100 ns ticks, so only such a value is read back exactly. + private void PlantSource(string projectRelativePath, byte[] bytes, DateTime lastWriteTimeUtc) + { + string path = Path.Combine(_tempRoot, projectRelativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllBytes(path, bytes); + File.SetLastWriteTimeUtc(path, lastWriteTimeUtc); + } + + private static string SnapshotFileName(string projectRelativePath) + { + return HotReloadSourceSnapshotter.HashProjectRelativePath(projectRelativePath) + ".cs"; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs.meta new file mode 100644 index 0000000000..deb668e6ba --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2422018aa82164b918478814061e4f9d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs index 7765ef4ef6..c690fbbbab 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs @@ -480,6 +480,17 @@ private static string TestAssemblyDllPath(string projectRoot) TestAssemblyName + HotReloadConstants.CompiledAssemblyExtension); } + // No source is written at or after the end of time, so the capture never reads a compiled assembly. + private static HotReloadSnapshotSourceCheck SuspectsNothing(string root) + { + return new HotReloadSnapshotSourceCheck( + DateTime.MaxValue.Ticks, + "unused.dll", + "unused.pdb", + "unused", + new HotReloadPdbDocumentIndex(Path.Combine(root, "PdbDocuments"))); + } + // Copies the fixture's real snapshot under a temporary root with its first byte flipped. private static string WriteTamperedSnapshotTree(string projectRoot, string dllPath) { @@ -603,7 +614,8 @@ public void CaptureAssemblySourcesAtomically_LongWindowsSource_CapturesByteExact root, snapshotDirectory, new[] { sourceRelativePath }, - "LongPathAssembly"); + "LongPathAssembly", + SuspectsNothing(root)); string normalizedRelativePath = sourceRelativePath.Replace('\\', '/'); string snapshotPath = Path.Combine( @@ -676,7 +688,8 @@ public void CaptureAssemblySourcesAtomically_OneLockedSource_CompletesWithReadab root, snapshotDirectory, sourceRelativePaths, - "LockedSourceAssembly"); + "LockedSourceAssembly", + SuspectsNothing(root)); } Assert.That(Directory.Exists(snapshotDirectory), Is.True); diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs index 9041e441d6..e530d49b55 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs @@ -93,7 +93,8 @@ public void CaptureAssemblySourcesAtomically_ExistingAndMissingSources_CopiesExi _tempRoot, snapshotDirectory, sourceFiles, - "Fixture"); + "Fixture", + SuspectsNothing(_tempRoot)); string firstSnapshot = Path.Combine( snapshotDirectory, @@ -128,13 +129,14 @@ public void CaptureAssemblySourcesAtomically_RecordsTheLengthAndWriteTimeOfEachC _tempRoot, snapshotDirectory, sourceFiles, - "Fixture"); + "Fixture", + SuspectsNothing(_tempRoot)); string expected = HotReloadConstants.SourceStampManifestHeader + "\n" + HotReloadSourceSnapshotter.HashProjectRelativePath("Sources/First.cs") + ".cs\t7\t" - + firstWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture) + "\n" + + firstWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture) + "\t0\n" + HotReloadSourceSnapshotter.HashProjectRelativePath("Sources/Second.cs") + ".cs\t2\t" - + secondWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture) + "\n"; + + secondWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture) + "\t0\n"; Assert.That( File.ReadAllText(Path.Combine(snapshotDirectory, HotReloadConstants.SourceStampManifestFileName)), Is.EqualTo(expected)); @@ -159,7 +161,8 @@ public void CaptureAssemblySourcesAtomically_WhenTemporaryDirectoryIsLeftOver_Pu _tempRoot, snapshotDirectory, new[] { "Sources/Fresh.cs" }, - "Fixture"); + "Fixture", + SuspectsNothing(_tempRoot)); string[] publishedNames = Directory.GetFiles(snapshotDirectory).Select(Path.GetFileName).ToArray(); // Why EquivalentTo: the order GetFiles lists files in depends on the file system. @@ -173,6 +176,110 @@ public void CaptureAssemblySourcesAtomically_WhenTemporaryDirectoryIsLeftOver_Pu Assert.That(Directory.Exists(leftoverDirectory), Is.False); } + /// + /// Verifies that a source written before the compile started and unchanged while it was read + /// keeps its stamp without being checked against the PDB. + /// + [Test] + public void JudgeCopy_ASourceWrittenBeforeTheStartAndReadWhole_TrustsTheStampWithoutChecking() + { + int checks = 0; + + HotReloadSnapshotCopyVerdict verdict = HotReloadSourceSnapshotter.JudgeCopy( + true, + 99, + 100, + () => + { + checks++; + return false; + }); + + Assert.That(verdict, Is.EqualTo(HotReloadSnapshotCopyVerdict.StampTrusted)); + Assert.That(checks, Is.EqualTo(0)); + } + + /// + /// Verifies that a source written exactly when the compile started is checked, and marked when + /// it differs from the PDB. + /// + [Test] + public void JudgeCopy_ASourceWrittenAtTheStartThatDiffersFromThePdb_MarksIt() + { + HotReloadSnapshotCopyVerdict verdict = HotReloadSourceSnapshotter.JudgeCopy(true, 100, 100, () => false); + + Assert.That(verdict, Is.EqualTo(HotReloadSnapshotCopyVerdict.EditedAfterCompile)); + } + + /// + /// Verifies that a source written after the compile started keeps an unmarked stamp when it + /// matches the PDB. + /// + [Test] + public void JudgeCopy_ASourceWrittenAfterTheStartThatMatchesThePdb_TrustsTheStamp() + { + HotReloadSnapshotCopyVerdict verdict = HotReloadSourceSnapshotter.JudgeCopy(true, 101, 100, () => true); + + Assert.That(verdict, Is.EqualTo(HotReloadSnapshotCopyVerdict.StampTrusted)); + } + + /// + /// Verifies that a source written after the compile started is marked when it differs from the PDB. + /// + [Test] + public void JudgeCopy_ASourceWrittenAfterTheStartThatDiffersFromThePdb_MarksIt() + { + HotReloadSnapshotCopyVerdict verdict = HotReloadSourceSnapshotter.JudgeCopy(true, 101, 100, () => false); + + Assert.That(verdict, Is.EqualTo(HotReloadSnapshotCopyVerdict.EditedAfterCompile)); + } + + /// + /// Verifies that a source that changed while it was read is checked even though its write time + /// is before the start, and gets no stamp when it matches the PDB. + /// + [Test] + public void JudgeCopy_ASourceThatChangedWhileReadAndMatchesThePdb_LeavesItUnstamped() + { + int checks = 0; + + HotReloadSnapshotCopyVerdict verdict = HotReloadSourceSnapshotter.JudgeCopy( + false, + 99, + 100, + () => + { + checks++; + return true; + }); + + Assert.That(verdict, Is.EqualTo(HotReloadSnapshotCopyVerdict.NoStamp)); + Assert.That(checks, Is.EqualTo(1)); + } + + /// + /// Verifies that a source that changed while it was read is marked when it differs from the PDB, + /// even though its write time is before the start. + /// + [Test] + public void JudgeCopy_ASourceThatChangedWhileReadAndDiffersFromThePdb_MarksIt() + { + HotReloadSnapshotCopyVerdict verdict = HotReloadSourceSnapshotter.JudgeCopy(false, 99, 100, () => false); + + Assert.That(verdict, Is.EqualTo(HotReloadSnapshotCopyVerdict.EditedAfterCompile)); + } + + // No source is written at or after the end of time, so the capture never reads a compiled assembly. + private static HotReloadSnapshotSourceCheck SuspectsNothing(string root) + { + return new HotReloadSnapshotSourceCheck( + DateTime.MaxValue.Ticks, + "unused.dll", + "unused.pdb", + "unused", + new HotReloadPdbDocumentIndex(Path.Combine(root, "PdbDocuments"))); + } + // Why a write time the test chooses: SetLastWriteTimeUtc stores microseconds while // LastWriteTimeUtc reports 100 ns ticks, so only a chosen value can be put back exactly. private static void WriteSourceAt(string path, byte[] bytes, DateTime lastWriteTimeUtc) diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceStampManifestTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceStampManifestTests.cs index 2ee96d3b58..8d9af441af 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceStampManifestTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceStampManifestTests.cs @@ -57,8 +57,8 @@ public void Load_RoundTripsWhatWriteWrote() _tempRoot, new[] { - HotReloadSourceStampManifest.FormatLine("a.cs", 7, FirstTicks), - HotReloadSourceStampManifest.FormatLine("b.cs", 2, SecondTicks), + HotReloadSourceStampManifest.FormatLine("a.cs", 7, FirstTicks, false), + HotReloadSourceStampManifest.FormatLine("b.cs", 2, SecondTicks, false), }); HotReloadSourceStampManifest manifest = HotReloadSourceStampManifest.Load(_tempRoot); @@ -79,7 +79,7 @@ public void Load_RoundTripsWhatWriteWrote() [Test] public void Load_WhenTheHeaderDiffers_IsEmpty() { - WriteRawManifest("uloop-source-stamps 2\n" + ValidLine() + "\n"); + WriteRawManifest("uloop-source-stamps 99\n" + ValidLine() + "\n"); AssertIgnoredAsAWhole(); } @@ -102,7 +102,7 @@ public void Load_WhenALineHasTwoFields_IsEmpty() [Test] public void Load_WhenALengthIsNotAnInteger_IsEmpty() { - WriteRawManifest(Header() + ValidLine() + "\n" + "a.cs\t7x\t1\n"); + WriteRawManifest(Header() + ValidLine() + "\n" + "a.cs\t7x\t1\t0\n"); AssertIgnoredAsAWhole(); } @@ -114,7 +114,7 @@ public void Load_WhenALengthIsNotAnInteger_IsEmpty() [Test] public void Load_WhenAWriteTimeIsNegative_IsEmpty() { - WriteRawManifest(Header() + ValidLine() + "\n" + "a.cs\t7\t-1\n"); + WriteRawManifest(Header() + ValidLine() + "\n" + "a.cs\t7\t-1\t0\n"); AssertIgnoredAsAWhole(); } @@ -160,6 +160,75 @@ public void Load_AcceptsCarriageReturnLineEndings() Assert.That(ticks, Is.EqualTo(SecondTicks)); } + /// + /// Verifies that a line marked as edited after the compile reads back as marked and still + /// answers its stamp. + /// + [Test] + public void Load_ALineMarkedEditedAfterCompile_ReportsTheMarkAndKeepsTheStamp() + { + HotReloadSourceStampManifest.Write( + _tempRoot, + new[] { HotReloadSourceStampManifest.FormatLine("a.cs", 7, FirstTicks, true) }); + + HotReloadSourceStampManifest manifest = HotReloadSourceStampManifest.Load(_tempRoot); + + Assert.That(manifest.IsEditedAfterCompile("a.cs"), Is.True); + Assert.That(manifest.TryGetStamp("a.cs", out long length, out long ticks), Is.True); + Assert.That(length, Is.EqualTo(7)); + Assert.That(ticks, Is.EqualTo(FirstTicks)); + } + + /// + /// Verifies that a line written without the mark does not read back as edited after the compile. + /// + [Test] + public void Load_AnUnmarkedLine_IsNotEditedAfterCompile() + { + HotReloadSourceStampManifest.Write( + _tempRoot, + new[] { HotReloadSourceStampManifest.FormatLine("a.cs", 7, FirstTicks, false) }); + + HotReloadSourceStampManifest manifest = HotReloadSourceStampManifest.Load(_tempRoot); + + Assert.That(manifest.IsEditedAfterCompile("a.cs"), Is.False); + } + + /// + /// Verifies that a mark other than 0 or 1 makes the whole manifest ignored. + /// + [Test] + public void Load_WhenTheMarkIsNeitherZeroNorOne_AnswersEmpty() + { + WriteRawManifest(Header() + ValidLine() + "\n" + "a.cs\t7\t1\t2\n"); + + AssertIgnoredAsAWhole(); + } + + /// + /// Verifies that a line without the mark field, as the former format wrote, makes the whole + /// manifest ignored. + /// + [Test] + public void Load_ALineWithoutTheMark_AnswersEmpty() + { + WriteRawManifest(Header() + ValidLine() + "\n" + "a.cs\t7\t1\n"); + + AssertIgnoredAsAWhole(); + } + + /// + /// Verifies that a manifest under the former header is ignored, so its unmarked stamps are + /// not trusted. + /// + [Test] + public void Load_TheFormerHeader_AnswersEmpty() + { + WriteRawManifest("uloop-source-stamps 1\n" + ValidLine() + "\n"); + + AssertIgnoredAsAWhole(); + } + /// /// Verifies that a file name the manifest has no line for answers no stamp. /// @@ -182,7 +251,7 @@ private static string Header() // instead of dropping the file would still answer this one, so the test tells them apart. private static string ValidLine() { - return HotReloadSourceStampManifest.FormatLine("b.cs", 2, SecondTicks); + return HotReloadSourceStampManifest.FormatLine("b.cs", 2, SecondTicks, false); } private void WriteRawManifest(string text) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs index 803af9141f..d5bfad49ec 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs @@ -287,7 +287,8 @@ private static string TryResolveChangedProjectRelativePath( // Compares an existing source with an existing snapshot file: reuses the last verdict while // the source keeps its length and write time, then trusts the stamp the capture recorded - // for the same length and write time, and only then reads both files. + // for the same length and write time, and only then reads both files. A copy the capture + // marked as edited after the compile never matches. private static bool MatchesSnapshot( FileInfo source, string snapshotPath, @@ -304,8 +305,12 @@ private static bool MatchesSnapshot( return matches; } - bool equal = HasRecordedStamp(manifest.Value, snapshotPath, source.Length, lastWriteTimeUtcTicks) - || BytesEqual(File.ReadAllBytes(source.FullName), File.ReadAllBytes(snapshotPath)); + // Why the mark before the stamp and the bytes: a marked copy usually holds the save made after + // the compile, so it equals the current file by both and would answer "unchanged". The + // verdict cache may keep this false: the mark does not change within one MVID's snapshot. + bool equal = !manifest.Value.IsEditedAfterCompile(Path.GetFileName(snapshotPath)) + && (HasRecordedStamp(manifest.Value, snapshotPath, source.Length, lastWriteTimeUtcTicks) + || BytesEqual(File.ReadAllBytes(source.FullName), File.ReadAllBytes(snapshotPath))); // Why the stamp taken before reading: a write during the read moves the stamp on, so the // next scan reads the file again instead of trusting this verdict. _verdicts.Record(snapshotPath, source.FullName, source.Length, lastWriteTimeUtcTicks, equal); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs index 407ffd9d92..1e18d42057 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs @@ -73,6 +73,7 @@ void StartWarmUpOnFirstUpdateTick() HotReloadPlayModeEntryDropRecorder.Initialize(); HotReloadAutoRefreshHold.Initialize(); HotReloadWarmUpEditorHooks.Initialize(); + HotReloadCompileStartRecord.Initialize(); HotReloadUnityMessageForwardingEditorHooks.Initialize(); HotReloadWiredValueEditorHooks.Initialize(); TransformWorkerHostLifecycle.RegisterForEditorStartup(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStart.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStart.cs new file mode 100644 index 0000000000..57b7d9f522 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStart.cs @@ -0,0 +1,49 @@ +using System; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// When the latest compile started, as far as this Editor session knows. The snapshot capture + /// uses it to decide which sources may have been saved after the compiler read them. + /// + internal readonly struct HotReloadCompileStart + { + internal static readonly HotReloadCompileStart Unknown = new HotReloadCompileStart(false, 0); + + private readonly bool _known; + private readonly long _utcTicks; + + private HotReloadCompileStart(bool known, long utcTicks) + { + _known = known; + _utcTicks = utcTicks; + } + + internal static HotReloadCompileStart At(long utcTicks) + { + return new HotReloadCompileStart(true, utcTicks); + } + + /// + /// Returns the earliest write time (UTC ticks) from which a source of an assembly whose DLL + /// was written at may differ from what the + /// compiler read. + /// + /// + /// Why the earlier of the two: a start later than the DLL write belongs to a compile after the + /// one that wrote the DLL (a failed one, or one that left this DLL as it was), and starting the + /// check there would miss a save made during the compile that did. Why the DLL write when no + /// start is known (the first compile after the Editor started): a source written after the DLL + /// cannot be in it, so at least those are still checked. + /// + internal long SuspectWritesFrom(long dllLastWriteTimeUtcTicks) + { + if (!_known) + { + return dllLastWriteTimeUtcTicks; + } + + return Math.Min(_utcTicks, dllLastWriteTimeUtcTicks); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStart.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStart.cs.meta new file mode 100644 index 0000000000..690280cd0f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStart.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2864471cd44564d4486ad6f61f2af6cc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStartRecord.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStartRecord.cs new file mode 100644 index 0000000000..a5f0590cfb --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStartRecord.cs @@ -0,0 +1,52 @@ +using System; +using System.Globalization; + +using UnityEditor; +using UnityEditor.Compilation; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Carries the time a compile started across that compile's domain reload to the snapshot + /// capture that follows it. Every member reads or writes SessionState, so each call has to come + /// from the Unity main thread. + /// + internal static class HotReloadCompileStartRecord + { + internal static void Initialize() + { + // Why unsubscribe first: Initialize runs once per domain, but a repeated registration + // during tests must not stack handlers. + CompilationPipeline.compilationStarted -= OnCompilationStarted; + CompilationPipeline.compilationStarted += OnCompilationStarted; + } + + internal static void OnCompilationStarted(object context) + { + Record(DateTime.UtcNow.Ticks); + } + + internal static void Record(long utcTicks) + { + SessionState.SetString( + HotReloadConstants.CompileStartedUtcTicksSessionStateKey, + utcTicks.ToString(CultureInfo.InvariantCulture)); + } + + internal static HotReloadCompileStart Read() + { + string text = SessionState.GetString(HotReloadConstants.CompileStartedUtcTicksSessionStateKey, string.Empty); + if (long.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out long utcTicks)) + { + return HotReloadCompileStart.At(utcTicks); + } + + return HotReloadCompileStart.Unknown; + } + + internal static void Clear() + { + SessionState.EraseString(HotReloadConstants.CompileStartedUtcTicksSessionStateKey); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStartRecord.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStartRecord.cs.meta new file mode 100644 index 0000000000..9c5f20f6b9 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompileStartRecord.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 427f1722a073240e7aaeb60f3712edc5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index e8af99090c..0f88ed35b0 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -52,7 +52,7 @@ internal static class HotReloadConstants // The file inside a snapshot directory that records each copied source's length and last // write time (UTC ticks), so a later domain tells an unchanged source by its stat alone. public const string SourceStampManifestFileName = "source-stamps.txt"; - public const string SourceStampManifestHeader = "uloop-source-stamps 1"; + public const string SourceStampManifestHeader = "uloop-source-stamps 2"; // Per-assembly lists of the documents a PDB's sequence points refer to, keyed by the dll's // file name and stamped with the dll's and the PDB's length, write time and MVID. Lets the @@ -466,11 +466,11 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE // Format: file name, assembly name. Used instead of the warning above when the snapshot's // bytes do not match the checksum the compiled PDB recorded. Why "most often": a PDB // document with no checksum, or with an algorithm the check does not support, reports the - // same reason, but a file saved between the compile and the capture is the usual cause. + // same reason, but a file saved after the compile had read it is the usual cause. public const string NoVerifiedSourceSnapshotMismatchWarningFormat = "No verified source snapshot for {0} (assembly {1}): the snapshot does not match the " - + "compiled file (most often the file changed between the compile and the snapshot " - + "capture); patching all methods. Run 'uloop compile' to re-establish the baseline."; + + "compiled file (most often the file was saved after the compile had read it); " + + "patching all methods. Run 'uloop compile' to re-establish the baseline."; // Format: file name, assembly name. Used instead of the warnings above when the compiled // assembly or its PDB is not on disk, so there is nothing to verify a snapshot against. @@ -671,6 +671,7 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string VibeLogShimFirstCompileTiming = "hot_reload_shim_first_compile_timing"; public const string VibeLogShimReferencesTiming = "hot_reload_shim_references_timing"; public const string VibeLogPublicizedCopyWritten = "hot_reload_publicized_copy_written"; + public const string VibeLogSourceSnapshotChecked = "hot_reload_source_snapshot_checked"; public const string VibeLogShimCompilerTiming = "hot_reload_shim_compiler_timing"; public const string VibeLogShimCompileFailed = "hot_reload_shim_compile_failed"; public const string VibeLogIsolationRetry = "hot_reload_isolation_retry"; @@ -876,6 +877,12 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string CompanionSourcesSessionStateKey = "io.github.hatayama.uloop.hot-reload.companionSources"; + // SessionState key for the time the latest compile started (UTC ticks). The snapshot capture + // checks sources written since then against the PDB. SessionState survives the compile's + // domain reload and is cleared when the Editor process exits. + public const string CompileStartedUtcTicksSessionStateKey = + "io.github.hatayama.uloop.hot-reload.compileStartedUtcTicks"; + // Format: remaining discarded identity count. Used only when --status active count is 0. public const string PlayModeEntryDropStatusMessageFormat = "0 change(s) currently active. {0} change(s) were discarded by the domain reload when Play Mode was entered — hot-reloaded edits that were never compiled are not in effect. Re-apply 'uloop hot-reload', or edit the files and run 'uloop compile'."; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs new file mode 100644 index 0000000000..63b40820c0 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs @@ -0,0 +1,73 @@ +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Tells, during one assembly's snapshot capture, which copied sources may not be the ones the + /// compiler read, and checks such a copy against the checksum the assembly's PDB recorded. + /// + internal sealed class HotReloadSnapshotSourceCheck + { + private readonly string _dllPath; + private readonly string _pdbPath; + private readonly string _moduleVersionId; + private readonly HotReloadPdbDocumentIndex _documentIndex; + + internal HotReloadSnapshotSourceCheck( + long suspectWritesFromUtcTicks, + string dllPath, + string pdbPath, + string moduleVersionId, + HotReloadPdbDocumentIndex documentIndex) + { + Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(moduleVersionId), "moduleVersionId must not be null or empty."); + Debug.Assert(documentIndex != null, "documentIndex must not be null."); + + SuspectWritesFromUtcTicks = suspectWritesFromUtcTicks; + _dllPath = dllPath; + _pdbPath = pdbPath; + _moduleVersionId = moduleVersionId; + _documentIndex = documentIndex; + } + + /// + /// Sources last written at or after this time (UTC ticks) may have been saved after the + /// compiler read them. + /// + internal long SuspectWritesFromUtcTicks { get; } + + /// + /// Returns whether the bytes match the checksum the compiled assembly's PDB recorded for the + /// source. A source the PDB has no usable checksum for is not confirmed. Main thread only. + /// + internal bool IsCompiledSource(string projectRoot, string slashNormalizedRelativePath, byte[] sourceBytes) + { + // Why NoDocumentInPdb counts as not compiled: a file without method bodies has no document, + // and a copy that cannot be confirmed is safer treated as edited than trusted. + return HotReloadSourceBaseline.CompareWithCompiledDocument( + projectRoot, + slashNormalizedRelativePath, + _dllPath, + _pdbPath, + _moduleVersionId, + sourceBytes, + _documentIndex) + == HotReloadSnapshotMissReason.None; + } + } + + /// + /// What the capture records for one copied source in the stamp manifest. + /// + internal enum HotReloadSnapshotCopyVerdict + { + // A stamp line that vouches for the copy. + StampTrusted, + // No line: readers compare the copy by bytes. + NoStamp, + // A line marked as edited after the compile: the copy is not the compiled source. + EditedAfterCompile + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs.meta new file mode 100644 index 0000000000..96526942b5 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 36b5661241e79431a901f00159f2c6db +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs index 55022836d7..0104a79a7c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs @@ -120,14 +120,54 @@ private static HotReloadSnapshotMissReason TryLoadVerifiedSnapshotSource( // Why read once: the verified bytes must be the exact payload decoded for the worker — // a second read could race with another writer and diverge from the checksummed content. byte[] snapshotBytes = File.ReadAllBytes(snapshotPath); + HotReloadSnapshotMissReason comparison = CompareWithCompiledDocument( + projectRoot, + slashNormalizedRelativePath, + targetDllPath, + pdbPath, + mvid, + snapshotBytes, + documentIndex); + if (comparison != HotReloadSnapshotMissReason.None) + { + return comparison; + } + + using MemoryStream memoryStream = new MemoryStream(snapshotBytes, writable: false); + using StreamReader reader = new StreamReader(memoryStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + source = reader.ReadToEnd(); + return HotReloadSnapshotMissReason.None; + } + + /// + /// Answers whether the bytes match the checksum the compiled assembly's PDB recorded for the + /// source: , or why they could not be confirmed + /// ( or + /// ). Main thread only, because it asks + /// the Package Manager for the source's physical path. + /// + internal static HotReloadSnapshotMissReason CompareWithCompiledDocument( + string projectRoot, + string slashNormalizedRelativePath, + string targetDllPath, + string pdbPath, + string moduleVersionId, + byte[] sourceBytes, + HotReloadPdbDocumentIndex documentIndex) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(slashNormalizedRelativePath), "slashNormalizedRelativePath must not be null or empty."); + Debug.Assert(sourceBytes != null, "sourceBytes must not be null."); + Debug.Assert(documentIndex != null, "documentIndex must not be null."); + // Why the physical path here and the asset path for the snapshot file: the snapshot is keyed // by the asset path Unity reports for the file, but the PDB records the path the compiler was // given, which for an embedded or local package is the folder behind the virtual // Packages/ path. Why the Package Manager is asked rather than the package roots a // run captures: those live in the main hot-reload assembly, which this one cannot see, and - // every caller of the loader already runs on the Unity main thread the Package Manager - // requires: a run's group step, and the pause-point port, which the pause-point tools and a - // run's patch step call. + // every caller already runs on the Unity main thread the Package Manager requires: a run's + // group step, the pause-point port, which the pause-point tools and a run's patch step call, + // and the snapshot capture after a domain reload. // Why the root that compiled the assembly rather than the snapshot owner's: the PDB spells a // source inside the compiling project as a path relative to that project's root (behind a // leading "./"), which the lookup matches as the end of the url. A Multiplayer Play Mode @@ -140,7 +180,7 @@ private static HotReloadSnapshotMissReason TryLoadVerifiedSnapshotSource( if (!documentIndex.TryFindDocument( targetDllPath, pdbPath, - mvid, + moduleVersionId, pdbLookupPath, out HotReloadPdbDocument document)) { @@ -154,15 +194,12 @@ private static HotReloadSnapshotMissReason TryLoadVerifiedSnapshotSource( return HotReloadSnapshotMissReason.HashMismatch; } - byte[] actualHash = ComputeDocumentHash(document.HashAlgorithm, snapshotBytes); + byte[] actualHash = ComputeDocumentHash(document.HashAlgorithm, sourceBytes); if (actualHash == null || !actualHash.SequenceEqual(document.Hash)) { return HotReloadSnapshotMissReason.HashMismatch; } - using MemoryStream memoryStream = new MemoryStream(snapshotBytes, writable: false); - using StreamReader reader = new StreamReader(memoryStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - source = reader.ReadToEnd(); return HotReloadSnapshotMissReason.None; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index ec430b1099..b155f6e5ef 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -8,6 +8,8 @@ using UnityEditor.PackageManager; +using io.github.hatayama.UnityCliLoop.ToolContracts; + using UnityEngine; using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; @@ -25,25 +27,35 @@ internal static class HotReloadSourceSnapshotter /// /// Captures snapshots for project assemblies that have adjacent portable PDBs. - /// Why: adoption is decided at use time by PDB document checksum, so a racy capture - /// (edit between compile and this call) can only fail closed into "no baseline" — - /// never into a silently wrong method diff. + /// A copy of a source written since the compile started is checked against the PDB checksum + /// when it is captured, and marked in the manifest when it does not match, so the default file + /// selection, the sibling scan and the skip check treat that file as changed. The method-diff + /// baseline checks every copy against the PDB when it uses one. /// Returns false when Unity listed no compilation assembly (as while it compiles). /// internal static bool CaptureAfterDomainReload() { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); IReadOnlyList assemblies = HotReloadCompilationAssemblies.Current(); - CaptureAssemblies(projectRoot, assemblies); + CaptureAssemblies( + projectRoot, + assemblies, + HotReloadCompileStartRecord.Read(), + HotReloadPdbDocumentIndex.Shared); return assemblies.Count > 0; } // Why separate from CaptureAfterDomainReload: the project root and the compilation assemblies come // from Unity, so taking them as arguments lets tests drive the per-assembly loop in a temporary root. - internal static void CaptureAssemblies(string projectRoot, IEnumerable assemblies) + internal static void CaptureAssemblies( + string projectRoot, + IEnumerable assemblies, + HotReloadCompileStart compileStart, + HotReloadPdbDocumentIndex documentIndex) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); Debug.Assert(assemblies != null, "assemblies must not be null."); + Debug.Assert(documentIndex != null, "documentIndex must not be null."); string snapshotRoot = Path.Combine(projectRoot, HotReloadConstants.SourceSnapshotRelativeDirectory); Directory.CreateDirectory(snapshotRoot); @@ -52,14 +64,14 @@ internal static void CaptureAssemblies(string projectRoot, IEnumerable manifestLines = new List(sourceFiles.Length); foreach (string projectRelativeSourcePath in sourceFiles) { - if (!CopySourceFileByteExact(projectRoot, temporaryDirectory, projectRelativeSourcePath, manifestLines)) + SourceCopyOutcome outcome = CopySourceFileByteExact( + projectRoot, + temporaryDirectory, + projectRelativeSourcePath, + check, + manifestLines); + if (outcome.Kind == SourceCopyKind.Unreadable) { skippedSourceCount++; firstSkippedSourcePath ??= projectRelativeSourcePath; + continue; + } + + if (outcome.Kind == SourceCopyKind.Checked || outcome.Kind == SourceCopyKind.CheckedEditedAfterCompile) + { + suspectSourceCount++; + checkTicks += outcome.CheckTicks; + } + + if (outcome.Kind == SourceCopyKind.CheckedEditedAfterCompile) + { + editedAfterCompileCount++; } } HotReloadSourceStampManifest.Write(temporaryDirectory, manifestLines); Directory.Move(temporaryDirectory, assemblySnapshotDirectory); + if (suspectSourceCount > 0) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotChecked, + "Hot reload checked the sources written since the compile started against the PDB.", + new + { + assemblyName, + suspect = suspectSourceCount, + editedAfterCompile = editedAfterCompileCount, + checkMs = (long)TimeSpan.FromTicks(checkTicks).TotalMilliseconds + }); + } + if (skippedSourceCount > 0) { UnityEngine.Debug.LogWarning( @@ -241,10 +298,11 @@ internal static void CaptureAssemblySourcesAtomically( } } - private static bool CopySourceFileByteExact( + private static SourceCopyOutcome CopySourceFileByteExact( string projectRoot, string assemblySnapshotDirectory, string projectRelativeSourcePath, + HotReloadSnapshotSourceCheck check, List manifestLines) { string normalizedRelativePath = projectRelativeSourcePath.Replace('\\', '/'); @@ -253,39 +311,126 @@ private static bool CopySourceFileByteExact( FileInfo sourceBefore = new FileInfo(fileSystemSourcePath); if (!sourceBefore.Exists) { - return true; + return new SourceCopyOutcome(SourceCopyKind.NotFound, 0); } long length = sourceBefore.Length; long lastWriteTimeUtcTicks = sourceBefore.LastWriteTimeUtc.Ticks; + string snapshotFileName = HashProjectRelativePath(normalizedRelativePath) + ".cs"; + if (!TryCopySource( + fileSystemSourcePath, + Path.Combine(assemblySnapshotDirectory, snapshotFileName), + length, + lastWriteTimeUtcTicks, + out byte[] bytes, + out bool stampHeldDuringRead)) + { + return new SourceCopyOutcome(SourceCopyKind.Unreadable, 0); + } + + // Why the PDB check stays outside TryCopySource's catch: that catch counts an IO failure as + // an unreadable source, and a PDB read failure would make that warning name the wrong file. + // The assembly-level catch in CaptureAssemblies handles it instead. + System.Diagnostics.Stopwatch checkWatch = null; + HotReloadSnapshotCopyVerdict verdict = JudgeCopy( + stampHeldDuringRead, + lastWriteTimeUtcTicks, + check.SuspectWritesFromUtcTicks, + () => + { + checkWatch = System.Diagnostics.Stopwatch.StartNew(); + bool isCompiledSource = check.IsCompiledSource(projectRoot, normalizedRelativePath, bytes); + checkWatch.Stop(); + return isCompiledSource; + }); + if (verdict != HotReloadSnapshotCopyVerdict.NoStamp) + { + manifestLines.Add(HotReloadSourceStampManifest.FormatLine( + snapshotFileName, + length, + lastWriteTimeUtcTicks, + verdict == HotReloadSnapshotCopyVerdict.EditedAfterCompile)); + } + + if (checkWatch == null) + { + return new SourceCopyOutcome(SourceCopyKind.Trusted, 0); + } + + SourceCopyKind checkedKind = verdict == HotReloadSnapshotCopyVerdict.EditedAfterCompile + ? SourceCopyKind.CheckedEditedAfterCompile + : SourceCopyKind.Checked; + return new SourceCopyOutcome(checkedKind, checkWatch.Elapsed.Ticks); + } + + // Copies the source and reports whether its stamp held while it was read. Returns false when the + // source could not be read or the copy could not be written. + private static bool TryCopySource( + string fileSystemSourcePath, + string destinationPath, + long length, + long lastWriteTimeUtcTicks, + out byte[] bytes, + out bool stampHeldDuringRead) + { try { - string snapshotFileName = HashProjectRelativePath(normalizedRelativePath) + ".cs"; - string destinationPath = Path.Combine(assemblySnapshotDirectory, snapshotFileName); - byte[] bytes = File.ReadAllBytes(fileSystemSourcePath); + bytes = File.ReadAllBytes(fileSystemSourcePath); File.WriteAllBytes(destinationPath, bytes); // Why the stamp is taken before the read and checked again after it: a write during the // read moves the stamp on, and a stamp recorded then could vouch for bytes the copy does - // not hold. Such a source gets no line and is compared by bytes until the next capture. + // not hold. Such a source is checked against the PDB: it gets no line when it matches, + // so readers compare it by bytes until the next capture, and a marked line when it does not. FileInfo sourceAfter = new FileInfo(fileSystemSourcePath); - if (sourceAfter.Exists + stampHeldDuringRead = sourceAfter.Exists && sourceAfter.Length == length - && sourceAfter.LastWriteTimeUtc.Ticks == lastWriteTimeUtcTicks) - { - manifestLines.Add(HotReloadSourceStampManifest.FormatLine(snapshotFileName, length, lastWriteTimeUtcTicks)); - } - + && sourceAfter.LastWriteTimeUtc.Ticks == lastWriteTimeUtcTicks; return true; } catch (Exception ex) when (IsSkippableSourceReadException(ex)) { // Approved deviation from the no-try-catch rule: source IO can fail independently // while an assembly is being snapshotted. Skipping preserves the fail-closed - // invariant because PDB checksum validation rejects any missing baseline at use time. + // invariant because the method-diff baseline rejects any missing snapshot by PDB + // checksum at use time. + bytes = null; + stampHeldDuringRead = false; return false; } } + /// + /// Decides what the manifest records for a copied source. Only a source that may have been + /// saved after the compiler read it is checked against the PDB: one written at or after the + /// start of the suspect window, or one that changed while it was read. + /// + internal static HotReloadSnapshotCopyVerdict JudgeCopy( + bool stampHeldDuringRead, + long lastWriteTimeUtcTicks, + long suspectWritesFromUtcTicks, + Func copyIsCompiledSource) + { + // Why a source that changed while read is checked whatever its write time: the stat before + // the read can be old while the copy holds bytes written during the read. + bool suspect = !stampHeldDuringRead || lastWriteTimeUtcTicks >= suspectWritesFromUtcTicks; + if (!suspect) + { + // Written before the compile started and unchanged while read: what the compiler read. + return HotReloadSnapshotCopyVerdict.StampTrusted; + } + + if (copyIsCompiledSource()) + { + // Why a matching source that changed while read still gets no line: its stamp does not + // vouch for the copy's bytes, so readers compare by bytes as before. + return stampHeldDuringRead + ? HotReloadSnapshotCopyVerdict.StampTrusted + : HotReloadSnapshotCopyVerdict.NoStamp; + } + + return HotReloadSnapshotCopyVerdict.EditedAfterCompile; + } + private static bool IsSkippableSourceReadException(Exception ex) { Debug.Assert(ex != null, "ex must not be null"); @@ -367,5 +512,29 @@ internal static void DeleteStaleSnapshotDirectories( Directory.Delete(candidateDirectory, recursive: true); } } + + private enum SourceCopyKind + { + NotFound, + Unreadable, + // Copied and recorded without a PDB check. + Trusted, + // Checked against the PDB and confirmed. + Checked, + // Checked against the PDB and marked as edited after the compile. + CheckedEditedAfterCompile + } + + private readonly struct SourceCopyOutcome + { + internal readonly SourceCopyKind Kind; + internal readonly long CheckTicks; + + internal SourceCopyOutcome(SourceCopyKind kind, long checkTicks) + { + Kind = kind; + CheckTicks = checkTicks; + } + } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceStampManifest.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceStampManifest.cs index 3c747e6be8..5ce80b18ea 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceStampManifest.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceStampManifest.cs @@ -12,6 +12,8 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// The length and last write time each source had when its snapshot copy was taken, kept in /// the snapshot directory so a source that still has both is known to equal its copy without /// reading either file. A manifest that cannot be parsed in full answers no stamp at all. + /// A copy of a source written since the compile started that did not match the PDB carries a + /// mark; a marked copy is not the compiled source. /// internal sealed class HotReloadSourceStampManifest { @@ -94,10 +96,25 @@ internal bool TryGetStamp(string snapshotFileName, out long length, out long las } /// - /// Formats one manifest line. The file name is a hex hash plus ".cs", so it never holds a - /// TAB or a line break. + /// Returns whether the snapshot file's line is marked as edited after the compile. /// - internal static string FormatLine(string snapshotFileName, long length, long lastWriteTimeUtcTicks) + internal bool IsEditedAfterCompile(string snapshotFileName) + { + Debug.Assert(!string.IsNullOrEmpty(snapshotFileName), "snapshotFileName must not be null or empty."); + + return _stamps.TryGetValue(snapshotFileName, out Stamp stamp) && stamp.EditedAfterCompile; + } + + /// + /// Formats one manifest line: "<file name>\t<length>\t<ticks>\t<0 or 1>", where 1 + /// marks a copy edited after the compile. The file name is a hex hash plus ".cs", so it never + /// holds a TAB or a line break. + /// + internal static string FormatLine( + string snapshotFileName, + long length, + long lastWriteTimeUtcTicks, + bool editedAfterCompile) { Debug.Assert(!string.IsNullOrEmpty(snapshotFileName), "snapshotFileName must not be null or empty."); Debug.Assert(length >= 0, "length must not be negative."); @@ -105,7 +122,8 @@ internal static string FormatLine(string snapshotFileName, long length, long las return snapshotFileName + "\t" + length.ToString(CultureInfo.InvariantCulture) - + "\t" + lastWriteTimeUtcTicks.ToString(CultureInfo.InvariantCulture); + + "\t" + lastWriteTimeUtcTicks.ToString(CultureInfo.InvariantCulture) + + "\t" + (editedAfterCompile ? "1" : "0"); } /// @@ -137,7 +155,7 @@ private static bool TryParseLine(string line, out string fileName, out Stamp sta fileName = null; stamp = default; string[] fields = line.Split('\t'); - if (fields.Length != 3 || fields[0].Length == 0) + if (fields.Length != 4 || fields[0].Length == 0) { return false; } @@ -152,8 +170,22 @@ private static bool TryParseLine(string line, out string fileName, out Stamp sta return false; } + bool editedAfterCompile; + if (string.Equals(fields[3], "1", StringComparison.Ordinal)) + { + editedAfterCompile = true; + } + else if (string.Equals(fields[3], "0", StringComparison.Ordinal)) + { + editedAfterCompile = false; + } + else + { + return false; + } + fileName = fields[0]; - stamp = new Stamp(length, ticks); + stamp = new Stamp(length, ticks, editedAfterCompile); return true; } @@ -161,11 +193,13 @@ private readonly struct Stamp { internal readonly long Length; internal readonly long LastWriteTimeUtcTicks; + internal readonly bool EditedAfterCompile; - internal Stamp(long length, long lastWriteTimeUtcTicks) + internal Stamp(long length, long lastWriteTimeUtcTicks, bool editedAfterCompile) { Length = length; LastWriteTimeUtcTicks = lastWriteTimeUtcTicks; + EditedAfterCompile = editedAfterCompile; } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md index e42aaee8db..338c531eb7 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md @@ -356,6 +356,9 @@ earlier package version carries no such record and is compared by bytes once per session. This applies wherever a source is compared with its baseline: finding drifted siblings, deciding which live patches to re-apply after a skip, and selecting files when `--files` is omitted. +A file saved while the compile was running — after the compiler read it — is marked when +the snapshot is captured: a run without `--files` selects it, and its methods are all +patched with a warning, until the next compile. Property getters with a body (including expression-bodied properties) are patched like ordinary methods. Editing a compiled property's setter, init, or indexer accessor diff --git a/docs/hot-reload.md b/docs/hot-reload.md index b6b630f3be..f10c9f395f 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -75,7 +75,10 @@ Caches: `Library/UloopHotReload/PublicizedRefs/fmt2/-.dll`, `Library/UloopHotReload/Worker//`, `Library/UloopHotReload/SourceSnapshot/-/` (with a `source-stamps.txt` that records each copied source's length and write time, so a run after a domain reload tells an -unchanged sibling by a stat instead of reading it), +unchanged sibling by a stat instead of reading it; a source written since the compile started +whose bytes do not match the PDB checksum carries a mark that it was edited after the compile, +and the default selection, the sibling scan and the skip check treat a marked source as +changed), `Library/UloopHotReload/PdbDocuments/fmt1/.txt` (the documents the PDB's sequence points refer to, stamped with the dll's and the PDB's length and write time and the MVID, so the first run after a domain reload does not walk the PDB again while they still match), and diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 57f5566b3f..64b0a80ccf 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -54,7 +54,10 @@ command ran against: target's publicized copy, the artifact copies and the reference copies with their counts. `hot_reload_publicized_copy_written` appears only when a rewritten copy was written, with its rewrite and write time. The warm-up's `publicized_targets` item writes it too, before any - run. `hot_reload_shim_compiler_timing` has the compile, the load and + run. `hot_reload_source_snapshot_checked` appears when a capture checked sources written + since the compile started against the PDB: `suspect` sources checked, `editedAfterCompile` + of them marked as not the compiled source, and `checkMs`. + `hot_reload_shim_compiler_timing` has the compile, the load and the backend kind. `dynamic_code_shared_worker_started` appears whenever no live shared Roslyn worker could serve the compile and one had to be started, with its source sync, worker assembly check and process spawn; `ready` is false when the start failed (no worker From 0b9ded33a22fba8549872b966ddbdaded4cdfb28 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 9 Oct 2026 15:21:33 +0900 Subject: [PATCH 27/29] docs: Note that a package's own AssetDatabase.Refresh compiles saved edits while hot reload holds Auto Refresh (#3257) --- .../uloop-hot-reload/references/mechanism-and-lifecycle.md | 5 +++++ .../uloop-hot-reload/references/mechanism-and-lifecycle.md | 5 +++++ .../HotReload/Skill/references/mechanism-and-lifecycle.md | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/.agents/skills/uloop-hot-reload/references/mechanism-and-lifecycle.md b/.agents/skills/uloop-hot-reload/references/mechanism-and-lifecycle.md index c0be60faa4..c44421bedf 100644 --- a/.agents/skills/uloop-hot-reload/references/mechanism-and-lifecycle.md +++ b/.agents/skills/uloop-hot-reload/references/mechanism-and-lifecycle.md @@ -27,6 +27,11 @@ patch binds to the newest shim. Edit the file and reload again to apply new chan default for projects created before Unity 6.6), `uloop compile`, and the compile `uloop run-tests` runs first unless given `--skip-compile`. `uloop control-play-mode --action Play` warns with the counts when it is about to drop patches or pause points. There is no persistence and no automatic re-apply. + A compile Unity starts on its own ends them too: the hold that keeps a returning focus from + recompiling (`AutoRefreshHeld`) stops only Unity's Auto Refresh, so an editor script or + package that calls `AssetDatabase.Refresh()` itself — some do after every domain reload — + still imports the edits saved since the last compile, and Unity compiles them. The compiled + code then contains those edits, so behavior still converges. - Never reflected by hot reload: initializer changes on compiled fields and new types. Those always need `uloop compile`. Signature changes — return type, rename, parameter list — are handled through the added-member rules and the diff --git a/.claude/skills/uloop-hot-reload/references/mechanism-and-lifecycle.md b/.claude/skills/uloop-hot-reload/references/mechanism-and-lifecycle.md index c0be60faa4..c44421bedf 100644 --- a/.claude/skills/uloop-hot-reload/references/mechanism-and-lifecycle.md +++ b/.claude/skills/uloop-hot-reload/references/mechanism-and-lifecycle.md @@ -27,6 +27,11 @@ patch binds to the newest shim. Edit the file and reload again to apply new chan default for projects created before Unity 6.6), `uloop compile`, and the compile `uloop run-tests` runs first unless given `--skip-compile`. `uloop control-play-mode --action Play` warns with the counts when it is about to drop patches or pause points. There is no persistence and no automatic re-apply. + A compile Unity starts on its own ends them too: the hold that keeps a returning focus from + recompiling (`AutoRefreshHeld`) stops only Unity's Auto Refresh, so an editor script or + package that calls `AssetDatabase.Refresh()` itself — some do after every domain reload — + still imports the edits saved since the last compile, and Unity compiles them. The compiled + code then contains those edits, so behavior still converges. - Never reflected by hot reload: initializer changes on compiled fields and new types. Those always need `uloop compile`. Signature changes — return type, rename, parameter list — are handled through the added-member rules and the diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/mechanism-and-lifecycle.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/mechanism-and-lifecycle.md index c0be60faa4..c44421bedf 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/mechanism-and-lifecycle.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/mechanism-and-lifecycle.md @@ -27,6 +27,11 @@ patch binds to the newest shim. Edit the file and reload again to apply new chan default for projects created before Unity 6.6), `uloop compile`, and the compile `uloop run-tests` runs first unless given `--skip-compile`. `uloop control-play-mode --action Play` warns with the counts when it is about to drop patches or pause points. There is no persistence and no automatic re-apply. + A compile Unity starts on its own ends them too: the hold that keeps a returning focus from + recompiling (`AutoRefreshHeld`) stops only Unity's Auto Refresh, so an editor script or + package that calls `AssetDatabase.Refresh()` itself — some do after every domain reload — + still imports the edits saved since the last compile, and Unity compiles them. The compiled + code then contains those edits, so behavior still converges. - Never reflected by hot reload: initializer changes on compiled fields and new types. Those always need `uloop compile`. Signature changes — return type, rename, parameter list — are handled through the added-member rules and the From 88d0ff2cdf7759ba54c1868b5d7a87d410491a9d Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 9 Oct 2026 15:51:39 +0900 Subject: [PATCH 28/29] perf: The first hot reload shim compile after a server reset finds the shared Roslyn worker already running with its references loaded (#3258) --- .../DynamicCodeExecutionFacadeTests.cs | 7 +- .../ExternalCompilerPathResolverTests.cs | 54 ++ .../RoslynCompilerBackendTests.cs | 14 + .../SharedRoslynCompilerWorkerHostTests.cs | 193 +++++++ ...redRoslynCompilerWorkerWarmUpTestSafety.cs | 23 + ...slynCompilerWorkerWarmUpTestSafety.cs.meta | 11 + .../SharedRoslynCompilerWorkerWarmUpTests.cs | 469 ++++++++++++++++++ ...redRoslynCompilerWorkerWarmUpTests.cs.meta | 11 + ...haredCompilerWarmUpReferenceSourceTests.cs | 269 ++++++++++ ...CompilerWarmUpReferenceSourceTests.cs.meta | 11 + .../ExecuteDynamicCode/DynamicCodeServices.cs | 23 +- .../ExternalCompilerPathResolution.cs | 75 +++ .../ExternalCompilerPathResolution.cs.meta | 11 + .../ExternalCompilerPathResolver.cs | 30 +- .../SharedRoslynCompilerWorkerHost.cs | 105 ++++ .../SharedRoslynCompilerWorkerHostProcess.cs | 11 + .../SharedRoslynCompilerWorkerHostResults.cs | 49 ++ .../SharedRoslynCompilerWorkerWarmUp.cs | 211 ++++++++ .../SharedRoslynCompilerWorkerWarmUp.cs.meta | 11 + .../HotReload/HotReloadEditorStartup.cs | 8 + ...loadSharedCompilerWarmUpReferenceSource.cs | 103 ++++ ...haredCompilerWarmUpReferenceSource.cs.meta | 11 + .../Patching/HotReloadShimReferenceBuilder.cs | 96 ++-- .../Patching/ShimCompileReference.cs | 19 + .../Patching/ShimCompileReference.cs.meta | 11 + .../SharedCompilerWarmUpCoordination.cs | 22 + .../SharedCompilerWarmUpCoordination.cs.meta | 11 + docs/hot-reload.md | 9 +- docs/vibe-logs.md | 13 + 29 files changed, 1832 insertions(+), 59 deletions(-) create mode 100644 Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTestSafety.cs create mode 100644 Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTestSafety.cs.meta create mode 100644 Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTests.cs create mode 100644 Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolution.cs create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolution.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerWarmUp.cs create mode 100644 Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerWarmUp.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Patching/ShimCompileReference.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Patching/ShimCompileReference.cs.meta create mode 100644 Packages/src/Editor/ToolContracts/SharedCompilerWarmUpCoordination.cs create mode 100644 Packages/src/Editor/ToolContracts/SharedCompilerWarmUpCoordination.cs.meta diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionFacadeTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionFacadeTests.cs index e7b0477381..aa6806724d 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionFacadeTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionFacadeTests.cs @@ -58,7 +58,12 @@ await facade.ExecuteAsync( public void ResetServerScopedServicesBeforeDomainReload_ShouldSignalShutdownWithoutWaitingForRuntimeDrain() { // Tests that domain reload reset does not leave a pending drain task that can block Unity teardown. - DynamicCodeServicesRegistry registry = new(); + DynamicCodeServicesRegistry registry = new DynamicCodeServicesRegistry( + new SharedRoslynCompilerWorkerWarmUp( + () => null, + () => null, + () => "package", + (references, paths) => Task.FromResult(SharedWorkerWarmUpOutcome.AlreadyRunning()))); FakeShutdownAwareRuntime runtime = new(); registry.SetRuntimeFacadeForTests(runtime); diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs index ced3fffd25..56a196cc0c 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.IO; using System.Threading; @@ -21,6 +22,19 @@ public class ExternalCompilerPathResolverTests private string _tempDirectoryPath; + /// + /// Waits for a shared-worker warm-up that started before the run. + /// + [UnitySetUp] + public IEnumerator WaitForSharedWorkerWarmUp() + { + Task warmUp = DynamicCodeServices.GetRegistry().GetSharedWorkerWarmUpTaskForTests(); + while (!warmUp.IsCompleted) + { + yield return null; + } + } + [SetUp] public void SetUp() { @@ -37,6 +51,46 @@ public void TearDown() } } + /// + /// Verifies resolving an Editor layout without the compiler lists the missing components + /// and logs nothing, leaving the report to the caller. + /// + [Test] + public void ResolveFromEditorPath_WithMissingComponents_ListsThemWithoutLogging() + { + string editorPath = Path.Combine(_tempDirectoryPath, "Fake.app"); + Directory.CreateDirectory(Path.Combine(editorPath, "Contents")); + + ExternalCompilerPathResolution resolution = ExternalCompilerPathResolver.ResolveFromEditorPath(editorPath); + + Assert.That(resolution.Paths, Is.Null); + Assert.That(resolution.MissingComponents.Count, Is.GreaterThan(0)); + LogAssert.NoUnexpectedReceived(); + } + + /// + /// Verifies the entry that does not report resolves the same compiler as the one compiles use, so the warm-up starts the worker a compile would. + /// + [Test] + public void Resolve_ReturnsTheSamePathsAsResolveWithoutReporting() + { + ExternalCompilerPaths reported = ExternalCompilerPathResolver.Resolve(); + ExternalCompilerPaths unreported = ExternalCompilerPathResolver.ResolveWithoutReporting(); + + Assert.That(reported, Is.Not.Null); + Assert.That(unreported, Is.Not.Null); + Assert.That(unreported.EditorContentsPath, Is.EqualTo(reported.EditorContentsPath)); + Assert.That(unreported.ScriptingRootPath, Is.EqualTo(reported.ScriptingRootPath)); + Assert.That(unreported.DotnetHostPath, Is.EqualTo(reported.DotnetHostPath)); + Assert.That(unreported.CompilerDllPath, Is.EqualTo(reported.CompilerDllPath)); + Assert.That(unreported.CompilerRuntimeConfigPath, Is.EqualTo(reported.CompilerRuntimeConfigPath)); + Assert.That(unreported.CompilerDepsFilePath, Is.EqualTo(reported.CompilerDepsFilePath)); + Assert.That(unreported.CodeAnalysisDllPath, Is.EqualTo(reported.CodeAnalysisDllPath)); + Assert.That(unreported.CodeAnalysisCSharpDllPath, Is.EqualTo(reported.CodeAnalysisCSharpDllPath)); + Assert.That(unreported.NetCoreRuntimeSharedDirectoryPath, Is.EqualTo(reported.NetCoreRuntimeSharedDirectoryPath)); + Assert.That(unreported.LayoutKind, Is.EqualTo(reported.LayoutKind)); + } + [Test] public void ResolveNetCoreRuntimeSharedDirectoryPath_WhenMultipleRuntimeVersionsExist_ShouldChooseHighestVersion() { diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/RoslynCompilerBackendTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/RoslynCompilerBackendTests.cs index 1ec791f986..e234907809 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/RoslynCompilerBackendTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/RoslynCompilerBackendTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.IO; using System.Reflection; @@ -18,6 +19,19 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.DynamicCodeToolTests [TestFixture] public sealed class RoslynCompilerBackendTests { + /// + /// Waits for a shared-worker warm-up that started before the run. + /// + [UnitySetUp] + public IEnumerator WaitForSharedWorkerWarmUp() + { + Task warmUp = DynamicCodeServices.GetRegistry().GetSharedWorkerWarmUpTaskForTests(); + while (!warmUp.IsCompleted) + { + yield return null; + } + } + /// /// Verifies WriteCompilerResponseFile emits -debug:portable so the one-shot csc path keeps PDBs. /// diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs index e469cefdbe..007c22ded7 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs @@ -1,15 +1,18 @@ using System; +using System.Collections; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Text; using System.Threading.Tasks; +using Newtonsoft.Json.Linq; using NUnit.Framework; using UnityEngine.TestTools; using UnityEditor.Compilation; using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.Tests.Editor.DynamicCodeToolTests { @@ -22,6 +25,23 @@ public class SharedRoslynCompilerWorkerHostTests private const string WorkerSourceA = "// worker source A"; private const string WorkerSourceB = "// worker source B"; + /// + /// Waits for a shared-worker warm-up that started before the run. + /// + [UnitySetUp] + public IEnumerator WaitForSharedWorkerWarmUp() + { + Task warmUp = DynamicCodeServices.GetRegistry().GetSharedWorkerWarmUpTaskForTests(); + while (!warmUp.IsCompleted) + { + yield return null; + } + + // Why shut it down: the warm-up leaves a worker running, and tests here count and + // replace the worker process. + SharedRoslynCompilerWorkerHost.ShutdownForTests(); + } + /// /// Verifies lifecycle closure is returned as a non-error compile outcome. /// @@ -856,6 +876,179 @@ public async Task EnsureWorkerAssemblyBuilt_WhenTheWorkerAssemblyExists_DoesNotT Assert.That(Directory.Exists(fixture.CacheRoot), Is.False); } + /// + /// Verifies a warm-up whose worker cannot start logs nothing, and leaves the once-only error + /// report to the next real compile. + /// + [Test] + public async Task WarmUpAsync_WhenTheWorkerCannotStart_LogsNothingAndLeavesTheErrorToTheNextCompile() + { + ExternalCompilerPaths paths = ExternalCompilerPathResolver.Resolve(); + Assert.That(paths, Is.Not.Null); + string[] references = { typeof(object).Assembly.Location }; + string directory = CreateTemporaryDirectory(); + string requestFilePath = WriteWorkerRequest(directory, references); + SharedRoslynCompilerWorkerHost.ShutdownForTests(); + DynamicCompilationHealthMonitor.ResetForTests(); + using SharedRoslynCompilerWorkerCacheScope cacheScope = SharedRoslynCompilerWorkerCacheScope.ForHost(); + Func previousCompiler = + SharedRoslynCompilerWorkerHost.SwapWorkerAssemblyCompilerForTests( + (ExternalCompilerPaths _, string __, string ___, string ____) => + new[] + { + new CompilerMessage + { + type = CompilerMessageType.Error, + message = "worker unavailable" + } + }); + try + { + SharedWorkerWarmUpOutcome outcome = await Task.Run( + () => SharedRoslynCompilerWorkerHost.WarmUpAsync(references, paths)); + + Assert.That(outcome.Outcome, Is.EqualTo(SharedWorkerWarmUpOutcome.OutcomeStartFailed)); + LogAssert.NoUnexpectedReceived(); + + LogAssert.Expect( + UnityEngine.LogType.Error, + new System.Text.RegularExpressions.Regex( + "execute-dynamic-code shared Roslyn worker failed to operate correctly; reason=worker_build_failed")); + SharedWorkerCompileOutcome compile = await Task.Run( + () => SharedRoslynCompilerWorkerHost.TryCompileAsync( + requestFilePath, + paths, + System.Threading.CancellationToken.None, + () => { }, + () => { }, + () => { })); + Assert.That(compile.Succeeded, Is.False); + } + finally + { + SharedRoslynCompilerWorkerHost.SwapWorkerAssemblyCompilerForTests(previousCompiler); + SharedRoslynCompilerWorkerHost.ShutdownForTests(); + DynamicCompilationHealthMonitor.ResetForTests(); + Directory.Delete(directory, true); + } + } + + /// + /// Verifies a warm-up leaves the worker running, so the next compile reuses it instead of + /// starting another. + /// + [Test] + public async Task WarmUpAsync_LeavesARunningWorkerThatTheNextCompileReuses() + { + ExternalCompilerPaths paths = ExternalCompilerPathResolver.Resolve(); + Assert.That(paths, Is.Not.Null); + string[] references = { typeof(object).Assembly.Location }; + string directory = CreateTemporaryDirectory(); + string requestFilePath = WriteWorkerRequest(directory, references); + SharedRoslynCompilerWorkerHost.ShutdownForTests(); + using SharedRoslynCompilerWorkerCacheScope cacheScope = SharedRoslynCompilerWorkerCacheScope.ForHost(); + try + { + SharedWorkerWarmUpOutcome outcome = await Task.Run( + () => SharedRoslynCompilerWorkerHost.WarmUpAsync(references, paths)); + Assert.That(outcome.Outcome, Is.EqualTo(SharedWorkerWarmUpOutcome.OutcomeAnswered)); + Assert.That(outcome.ErrorCount, Is.EqualTo(0)); + + VibeLogger.ClearMemoryLogs(); + SharedWorkerCompileOutcome compile = await Task.Run( + () => SharedRoslynCompilerWorkerHost.TryCompileAsync( + requestFilePath, + paths, + System.Threading.CancellationToken.None, + () => { }, + () => { }, + () => { })); + + Assert.That(compile.Succeeded, Is.True); + Assert.That( + JArray.Parse(VibeLogger.GetLogsForAi("dynamic_code_shared_worker_started")).Count, + Is.EqualTo(0)); + } + finally + { + SharedRoslynCompilerWorkerHost.ShutdownForTests(); + Directory.Delete(directory, true); + } + } + + /// + /// Verifies a warm-up that finds the worker already running sends it nothing. + /// + [Test] + public async Task WarmUpAsync_WhenAWorkerAlreadyRuns_SendsNothing() + { + ExternalCompilerPaths paths = ExternalCompilerPathResolver.Resolve(); + Assert.That(paths, Is.Not.Null); + string[] references = { typeof(object).Assembly.Location }; + SharedRoslynCompilerWorkerHost.ShutdownForTests(); + using SharedRoslynCompilerWorkerCacheScope cacheScope = SharedRoslynCompilerWorkerCacheScope.ForHost(); + try + { + await Task.Run(() => SharedRoslynCompilerWorkerHost.WarmUpAsync(references, paths)); + SharedWorkerWarmUpOutcome second = await Task.Run( + () => SharedRoslynCompilerWorkerHost.WarmUpAsync(references, paths)); + + Assert.That(second.Outcome, Is.EqualTo(SharedWorkerWarmUpOutcome.OutcomeAlreadyRunning)); + } + finally + { + SharedRoslynCompilerWorkerHost.ShutdownForTests(); + } + } + + /// + /// Verifies the warm-up deletes the source, request and assembly it wrote once the worker answered. + /// + [Test] + public async Task WarmUpAsync_RemovesItsFilesAfterTheWorkerAnswers() + { + ExternalCompilerPaths paths = ExternalCompilerPathResolver.Resolve(); + Assert.That(paths, Is.Not.Null); + string[] references = { typeof(object).Assembly.Location }; + SharedRoslynCompilerWorkerHost.ShutdownForTests(); + using SharedRoslynCompilerWorkerCacheScope cacheScope = SharedRoslynCompilerWorkerCacheScope.ForHost(); + try + { + SharedWorkerWarmUpOutcome outcome = await Task.Run( + () => SharedRoslynCompilerWorkerHost.WarmUpAsync(references, paths)); + + Assert.That(outcome.Outcome, Is.EqualTo(SharedWorkerWarmUpOutcome.OutcomeAnswered)); + Assert.That(Directory.Exists(SharedRoslynCompilerWorkerHostProcess.GetWarmUpDirectoryPath()), Is.False); + } + finally + { + SharedRoslynCompilerWorkerHost.ShutdownForTests(); + } + } + + private static string CreateTemporaryDirectory() + { + string directory = Path.Combine(Path.GetTempPath(), "shared-worker-warm-up-" + Path.GetRandomFileName()); + Directory.CreateDirectory(directory); + return directory; + } + + private static string WriteWorkerRequest(string directory, IReadOnlyList references) + { + string sourcePath = Path.Combine(directory, "Probe.cs"); + string requestFilePath = Path.Combine(directory, "Probe.worker"); + File.WriteAllText(sourcePath, "internal static class SharedWorkerWarmUpProbe { }"); + RoslynCompilerRequestFileWriter.WriteWorkerRequestFile( + requestFilePath, + sourcePath, + Path.Combine(directory, "Probe.dll"), + references, + Array.Empty(), + allowUnsafeCode: false, + emitDebugCode: true); + return requestFilePath; + } + /// /// A session with a fake csc, an empty test-owned cache, and a temp worker directory, for /// driving the worker assembly ensure step directly. diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTestSafety.cs b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTestSafety.cs new file mode 100644 index 0000000000..3d15f07a5e --- /dev/null +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTestSafety.cs @@ -0,0 +1,23 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.DynamicCodeToolTests +{ + /// + /// Stops the shared-worker warm-up once for the whole run of the tests in this namespace. + /// Why: a warm-up started by the server reset after the last reload compiles in the shared + /// worker these tests swap, shut down and count error logs of. + /// + [SetUpFixture] + public sealed class SharedRoslynCompilerWorkerWarmUpTestSafety + { + [OneTimeSetUp] + public void StopWarmUp() + { + // The task is awaited by each fixture's [UnitySetUp]; OneTimeSetUp cannot wait without + // blocking the main thread. + _ = DynamicCodeServices.GetRegistry().StopSharedWorkerWarmUpForTests(); + } + } +} diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTestSafety.cs.meta b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTestSafety.cs.meta new file mode 100644 index 0000000000..6a6bf5f929 --- /dev/null +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTestSafety.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 53f6a5fecd1424418b5fe1ccd1a265d5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTests.cs new file mode 100644 index 0000000000..ca1533808e --- /dev/null +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTests.cs @@ -0,0 +1,469 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +using Stopwatch = System.Diagnostics.Stopwatch; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.DynamicCodeToolTests +{ + /// + /// Verifies when the shared-worker warm-up runs, what it skips, and what it hands the worker. + /// + [TestFixture] + public sealed class SharedRoslynCompilerWorkerWarmUpTests + { + private const int WaitTimeoutMilliseconds = 30000; + + private const int EarlyCompletionWindowMilliseconds = 2000; + + /// Waits for a shared-worker warm-up that started before the run. + [UnitySetUp] + public IEnumerator WaitForSharedWorkerWarmUp() + { + Task warmUp = DynamicCodeServices.GetRegistry().GetSharedWorkerWarmUpTaskForTests(); + while (!warmUp.IsCompleted) + { + yield return null; + } + + VibeLogger.ClearMemoryLogs(); + } + + /// + /// Verifies none of the warm-up runs inside Start, so a server reset that starts it never waits for or fails on it. + /// + [UnityTest] + public IEnumerator Start_ReturnsBeforeAskingForReferences() + { + WarmUpParts parts = new WarmUpParts(); + SharedRoslynCompilerWorkerWarmUp warmUp = parts.Create(); + + warmUp.Start(); + + Assert.That(parts.CollectCalls, Is.EqualTo(0)); + yield return WaitUntilCompleted(warmUp.GetTaskForTests()); + Assert.That(parts.CollectCalls, Is.EqualTo(1)); + } + + /// + /// Verifies a warm-up stopped for tests stays stopped: a later start logs a skip and touches nothing. + /// + [UnityTest] + public IEnumerator Start_AfterStopForTests_SkipsWithoutAskingForReferences() + { + WarmUpParts parts = new WarmUpParts(); + SharedRoslynCompilerWorkerWarmUp warmUp = parts.Create(); + + yield return WaitUntilCompleted(warmUp.StopForTests()); + warmUp.Start(); + yield return WaitUntilCompleted(warmUp.GetTaskForTests()); + + Assert.That(parts.CollectCalls, Is.EqualTo(0)); + Assert.That(ReadSkipReason(), Is.EqualTo(SharedRoslynCompilerWorkerWarmUp.SkipReasonStoppedForTests)); + } + + /// + /// Verifies a domain where the hot-reload tool has not wired its reference list starts no worker. + /// + [UnityTest] + public IEnumerator Start_WithoutAReferenceProvider_SkipsAsNoHotReload() + { + WarmUpParts parts = new WarmUpParts(); + parts.Collector = null; + SharedRoslynCompilerWorkerWarmUp warmUp = parts.Create(); + + warmUp.Start(); + yield return WaitUntilCompleted(warmUp.GetTaskForTests()); + + Assert.That(parts.ResolveCalls, Is.EqualTo(0)); + Assert.That(parts.WarmCalls, Is.EqualTo(0)); + Assert.That(ReadSkipReason(), Is.EqualTo(SharedRoslynCompilerWorkerWarmUp.SkipReasonNoHotReload)); + } + + /// + /// Verifies a project with no compiled hot-reload target starts no worker and does not even look for the compiler. + /// + [UnityTest] + public IEnumerator Start_WhenTheProviderListsNothing_SkipsWithoutResolvingCompilerPaths() + { + WarmUpParts parts = new WarmUpParts(); + parts.Collector = _ => Task.FromResult>(Array.Empty()); + SharedRoslynCompilerWorkerWarmUp warmUp = parts.Create(); + + warmUp.Start(); + yield return WaitUntilCompleted(warmUp.GetTaskForTests()); + + Assert.That(parts.ResolveCalls, Is.EqualTo(0)); + Assert.That(parts.WarmCalls, Is.EqualTo(0)); + Assert.That(ReadSkipReason(), Is.EqualTo(SharedRoslynCompilerWorkerWarmUp.SkipReasonNoTargets)); + } + + /// + /// Verifies a missing compiler only skips the warm-up, leaving the error to the next real compile. + /// + [UnityTest] + public IEnumerator Start_WhenCompilerPathsAreMissing_SkipsWithoutWarmingOrLogging() + { + WarmUpParts parts = new WarmUpParts(); + parts.Paths = null; + SharedRoslynCompilerWorkerWarmUp warmUp = parts.Create(); + + warmUp.Start(); + yield return WaitUntilCompleted(warmUp.GetTaskForTests()); + + Assert.That(parts.WarmCalls, Is.EqualTo(0)); + Assert.That(ReadSkipReason(), Is.EqualTo(SharedRoslynCompilerWorkerWarmUp.SkipReasonCompilerUnavailable)); + LogAssert.NoUnexpectedReceived(); + } + + /// + /// Verifies an exception in the warm-up is logged and recorded as failed instead of faulting a task nobody awaits. + /// + [UnityTest] + public IEnumerator Start_WhenTheProviderThrows_LogsTheExceptionAndCompletes() + { + WarmUpParts parts = new WarmUpParts(); + parts.Collector = _ => throw new InvalidOperationException("provider failed for the test"); + SharedRoslynCompilerWorkerWarmUp warmUp = parts.Create(); + LogAssert.Expect(LogType.Exception, new System.Text.RegularExpressions.Regex("provider failed for the test")); + + warmUp.Start(); + Task task = warmUp.GetTaskForTests(); + yield return WaitUntilCompleted(task); + + Assert.That(task.Status, Is.EqualTo(TaskStatus.RanToCompletion)); + JObject complete = ReadSingleContext(SharedRoslynCompilerWorkerWarmUp.VibeLogWarmUpComplete); + Assert.That((string)complete["outcome"], Is.EqualTo(SharedRoslynCompilerWorkerWarmUp.OutcomeFailed)); + Assert.That((string)complete["failureReason"], Is.EqualTo(nameof(InvalidOperationException))); + } + + /// + /// Verifies the worker warms exactly the listed references with the resolved compiler, and that the log reports what it answered. + /// + [UnityTest] + public IEnumerator Start_HandsTheReferencesAndPathsToTheWorker() + { + string[] references = { "first.dll", "second.dll", "third.dll" }; + WarmUpParts parts = new WarmUpParts(); + parts.Collector = _ => Task.FromResult>(references); + parts.Warm = () => Task.FromResult(SharedWorkerWarmUpOutcome.Answered(3)); + SharedRoslynCompilerWorkerWarmUp warmUp = parts.Create(); + + warmUp.Start(); + yield return WaitUntilCompleted(warmUp.GetTaskForTests()); + + Assert.That(parts.WarmedReferences, Is.EqualTo(references)); + Assert.That(parts.WarmedPaths, Is.SameAs(parts.Paths)); + JObject complete = ReadSingleContext(SharedRoslynCompilerWorkerWarmUp.VibeLogWarmUpComplete); + Assert.That((int)complete["referenceCount"], Is.EqualTo(references.Length)); + Assert.That((string)complete["outcome"], Is.EqualTo(SharedWorkerWarmUpOutcome.OutcomeAnswered)); + Assert.That((int)complete["errorCount"], Is.EqualTo(3)); + } + + /// + /// Verifies the package path is read on the main thread even when the reference list + /// completes on a pool thread. + /// + [UnityTest] + public IEnumerator Start_ReadsThePackagePathOnTheMainThread() + { + TaskCompletionSource> references = new TaskCompletionSource>(); + WarmUpParts parts = new WarmUpParts(); + parts.Collector = _ => references.Task; + SharedRoslynCompilerWorkerWarmUp warmUp = parts.Create(); + try + { + warmUp.Start(); + yield return WaitUntil(() => parts.CollectCalls == 1); + Task completion = Task.Run(() => references.SetResult(new[] { "first.dll" })); + yield return WaitUntilCompleted(completion); + yield return WaitUntilCompleted(warmUp.GetTaskForTests()); + + Assert.That(parts.PackagePathReads, Is.EqualTo(1)); + Assert.That(parts.PackagePathReadOnMainThread, Is.True); + } + finally + { + references.TrySetResult(Array.Empty()); + } + } + + /// + /// Verifies the compiler paths are resolved on a pool thread. The reference list completes + /// synchronously, so everything outside a pool hand-off runs on the main thread. + /// + [UnityTest] + public IEnumerator Start_ResolvesCompilerPathsOffTheMainThread() + { + WarmUpParts parts = new WarmUpParts(); + SharedRoslynCompilerWorkerWarmUp warmUp = parts.Create(); + + warmUp.Start(); + yield return WaitUntilCompleted(warmUp.GetTaskForTests()); + + Assert.That(parts.ResolveCalls, Is.EqualTo(1)); + Assert.That(parts.ResolvedOnMainThread, Is.False); + } + + /// + /// Verifies stopping for tests hands back a task that completes only when the warm-up in flight ends. + /// + [UnityTest] + public IEnumerator StopForTests_ReturnsTheWarmUpStillInFlight() + { + TaskCompletionSource worker = new TaskCompletionSource(); + WarmUpParts parts = new WarmUpParts(); + parts.Warm = () => worker.Task; + SharedRoslynCompilerWorkerWarmUp warmUp = parts.Create(); + try + { + warmUp.Start(); + yield return WaitUntil(() => parts.WarmCalls == 1); + + Task stopped = warmUp.StopForTests(); + Assert.That(stopped.IsCompleted, Is.False); + worker.SetResult(SharedWorkerWarmUpOutcome.AlreadyRunning()); + yield return WaitUntilCompleted(stopped); + } + finally + { + worker.TrySetResult(SharedWorkerWarmUpOutcome.AlreadyRunning()); + } + } + + /// + /// Verifies that when the later of two warm-ups ends first, the task tests wait for does not complete until the earlier one ends too. + /// + [UnityTest] + public IEnumerator GetTaskForTests_CoversEveryStartedWarmUp() + { + TaskCompletionSource first = new TaskCompletionSource(); + TaskCompletionSource second = new TaskCompletionSource(); + ConcurrentQueue> workers = + new ConcurrentQueue>(new[] { first, second }); + WarmUpParts parts = new WarmUpParts(); + parts.Warm = () => + { + workers.TryDequeue(out TaskCompletionSource worker); + return worker.Task; + }; + SharedRoslynCompilerWorkerWarmUp warmUp = parts.Create(); + try + { + // Why wait between the starts: the first warm-up must be the one holding `first`. + warmUp.Start(); + yield return WaitUntil(() => parts.WarmCalls == 1); + warmUp.Start(); + yield return WaitUntil(() => parts.WarmCalls == 2); + + Task inFlight = warmUp.GetTaskForTests(); + // Why finish the later warm-up first: a task that tracked only the latest start + // would then complete while the earlier one is still in flight. + second.SetResult(SharedWorkerWarmUpOutcome.AlreadyRunning()); + yield return WaitAtMost(() => inFlight.IsCompleted, EarlyCompletionWindowMilliseconds); + Assert.That(inFlight.IsCompleted, Is.False); + first.SetResult(SharedWorkerWarmUpOutcome.AlreadyRunning()); + yield return WaitUntilCompleted(inFlight); + } + finally + { + first.TrySetResult(SharedWorkerWarmUpOutcome.AlreadyRunning()); + second.TrySetResult(SharedWorkerWarmUpOutcome.AlreadyRunning()); + } + } + + /// + /// Verifies a server reset starts a warm-up but returns before any of it runs. + /// + [UnityTest] + public IEnumerator ResetServerScopedServices_StartsAWarmUpWithoutRunningItInline() + { + WarmUpParts parts = new WarmUpParts(); + DynamicCodeServicesRegistry registry = new DynamicCodeServicesRegistry(parts.Create()); + try + { + registry.ResetServerScopedServices(); + + Assert.That(parts.CollectCalls, Is.EqualTo(0)); + yield return WaitUntilCompleted(registry.GetSharedWorkerWarmUpTaskForTests()); + Assert.That(parts.CollectCalls, Is.EqualTo(1)); + } + finally + { + SharedRoslynCompilerWorkerHost.ShutdownForTests(); + } + } + + /// + /// Verifies the reset before a domain reload starts no warm-up, whose worker the reload would kill. + /// + [UnityTest] + public IEnumerator ResetServerScopedServicesBeforeDomainReload_StartsNoWarmUp() + { + WarmUpParts parts = new WarmUpParts(); + DynamicCodeServicesRegistry registry = new DynamicCodeServicesRegistry(parts.Create()); + try + { + registry.ResetServerScopedServicesBeforeDomainReload(); + for (int tick = 0; tick < 5; tick++) + { + yield return null; + } + + Assert.That(parts.CollectCalls, Is.EqualTo(0)); + } + finally + { + SharedRoslynCompilerWorkerHost.ShutdownForTests(); + } + } + + // Waits until the condition holds or the window ends, without failing either way. + private static IEnumerator WaitAtMost(Func condition, int windowMilliseconds) + { + Stopwatch watch = Stopwatch.StartNew(); + while (!condition() && watch.ElapsedMilliseconds < windowMilliseconds) + { + yield return null; + } + } + + private static IEnumerator WaitUntilCompleted(Task task) + { + return WaitUntil(() => task.IsCompleted); + } + + private static IEnumerator WaitUntil(Func condition) + { + Stopwatch watch = Stopwatch.StartNew(); + while (!condition()) + { + if (watch.ElapsedMilliseconds > WaitTimeoutMilliseconds) + { + Assert.Fail("The condition did not hold within the timeout."); + } + + yield return null; + } + } + + private static string ReadSkipReason() + { + return (string)ReadSingleContext(SharedRoslynCompilerWorkerWarmUp.VibeLogWarmUpSkipped)["reason"]; + } + + private static JObject ReadSingleContext(string operation) + { + JArray entries = JArray.Parse(VibeLogger.GetLogsForAi(operation)); + Assert.That(entries.Count, Is.EqualTo(1), "entries of " + operation); + return (JObject)entries[0]["context"]; + } + + private static ExternalCompilerPaths CreateFakePaths() + { + return new ExternalCompilerPaths( + "contents", + "scripting", + "dotnet", + "csc.dll", + "csc.runtimeconfig.json", + "csc.deps.json", + "Microsoft.CodeAnalysis.dll", + "Microsoft.CodeAnalysis.CSharp.dll", + "shared", + ExternalCompilerLayoutKind.Unknown); + } + + /// + /// The three parts a warm-up is built from, faked: each records how it was called. + /// + private sealed class WarmUpParts + { + internal Func>> Collector = + _ => Task.FromResult>(new[] { "first.dll" }); + + internal ExternalCompilerPaths Paths = CreateFakePaths(); + + internal Func> Warm = + () => Task.FromResult(SharedWorkerWarmUpOutcome.Answered(0)); + + private int _collectCalls; + private int _resolveCalls; + private int _warmCalls; + + internal int CollectCalls => Volatile.Read(ref _collectCalls); + + internal int ResolveCalls => Volatile.Read(ref _resolveCalls); + + internal int WarmCalls => Volatile.Read(ref _warmCalls); + + internal bool ResolvedOnMainThread { get; private set; } + + internal int PackagePathReads { get; private set; } + + internal bool PackagePathReadOnMainThread { get; private set; } + + internal IReadOnlyList WarmedReferences { get; private set; } + + internal ExternalCompilerPaths WarmedPaths { get; private set; } + + internal SharedRoslynCompilerWorkerWarmUp Create() + { + return new SharedRoslynCompilerWorkerWarmUp( + GetCollector, + ResolvePaths, + ReadPackagePath, + WarmWorker); + } + + private Func>> GetCollector() + { + if (Collector == null) + { + return null; + } + + return ct => + { + Interlocked.Increment(ref _collectCalls); + return Collector(ct); + }; + } + + private ExternalCompilerPaths ResolvePaths() + { + ResolvedOnMainThread = MainThreadSwitcher.IsMainThread; + Interlocked.Increment(ref _resolveCalls); + return Paths; + } + + private string ReadPackagePath() + { + PackagePathReadOnMainThread = MainThreadSwitcher.IsMainThread; + PackagePathReads++; + return "package"; + } + + private Task WarmWorker( + IReadOnlyList references, + ExternalCompilerPaths paths) + { + WarmedReferences = references; + WarmedPaths = paths; + Interlocked.Increment(ref _warmCalls); + return Warm(); + } + } + } +} diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTests.cs.meta b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTests.cs.meta new file mode 100644 index 0000000000..57420bca2f --- /dev/null +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerWarmUpTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 951183298a497441eb625106585e77ff +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs new file mode 100644 index 0000000000..6203bfd66a --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs @@ -0,0 +1,269 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// EditMode coverage for : the list it + /// hands the shared-worker warm-up is the part of the next shim compile's references that the + /// compile binds as they are. + /// + public class HotReloadSharedCompilerWarmUpReferenceSourceTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + + private const string HotReloadEditorAssemblyName = "UnityCLILoop.FirstPartyTools.HotReload.Editor"; + + private const string MissingAssemblyName = "No.Such.Assembly"; + + // Why wait: the run's reference list built in some tests writes publicized copies, which the + // installed warm-up may be writing on a pool thread at the same time. + [UnitySetUp] + public IEnumerator WaitForTheInstalledWarmUpToStop() + { + Task stopped = PublicizedCopyTestCache.StopInstalledWarmUpAsync(); + while (!stopped.IsCompleted) + { + yield return null; + } + } + + /// + /// Verifies empty entries, missing files and the target itself are left out, and that only a + /// publicizable project assembly under the compiled-assemblies directory is marked for a copy. + /// + [Test] + public void ClassifyCompileReferences_SortsEachKindOfReference() + { + string projectRoot = ProjectRoot(); + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + string fullTarget = Path.GetFullPath(layout.DllPath(TestAssemblyName)); + string targetWithOtherCase = Path.Combine( + Path.GetDirectoryName(fullTarget), + Path.GetFileName(fullTarget).ToUpperInvariant()); + string toolContracts = "Library/ScriptAssemblies/UnityCLILoop.ToolContracts.dll"; + string testRunner = "Library/ScriptAssemblies/UnityEngine.TestRunner.dll"; + string engine = typeof(UnityEngine.Object).Assembly.Location; + string[] allReferences = + { + string.Empty, + Path.Combine(Path.GetTempPath(), "missing-" + Path.GetRandomFileName() + ".dll"), + targetWithOtherCase, + toolContracts, + testRunner, + engine + }; + + List classified = HotReloadShimReferenceBuilder.ClassifyCompileReferences( + allReferences, + projectRoot, + fullTarget, + layout.CompiledAssembliesDirectory); + + Assert.That(classified.Count, Is.EqualTo(3)); + Assert.That(classified[0].FullPath, Is.EqualTo(Path.GetFullPath(Path.Combine(projectRoot, toolContracts)))); + Assert.That(classified[0].UsesRewrittenCopy, Is.True); + Assert.That(classified[1].FullPath, Is.EqualTo(Path.GetFullPath(Path.Combine(projectRoot, testRunner)))); + Assert.That(classified[1].UsesRewrittenCopy, Is.False); + Assert.That(classified[2].FullPath, Is.EqualTo(Path.GetFullPath(engine))); + Assert.That(classified[2].UsesRewrittenCopy, Is.False); + } + + /// + /// Verifies every listed reference is one the run's shim compile binds, and that the only + /// references the run binds beyond the list are rewritten copies. + /// + [Test] + public async Task CollectAsync_ListsWhatTheShimCompileBindsAsItIs() + { + string projectRoot = ProjectRoot(); + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource( + projectRoot, + _ => new[] { TestAssemblyName }, + HotReloadCompilationAssemblies.FindByName); + + IReadOnlyList listed = await source.CollectAsync(CancellationToken.None); + + Assert.That(listed, Is.Not.Empty); + HotReloadShimReferenceBuilder.ShimReferencePathsResult run = HotReloadShimReferenceBuilder.TryBuildShimReferencePaths( + HotReloadCompilationAssemblies.FindByName(TestAssemblyName), + HotReloadTypeHome.ScriptAssemblies(TestAssemblyName, layout.DllPath(TestAssemblyName)), + false, + false, + Array.Empty()); + Assert.That(run.ErrorMessage, Is.Null); + HashSet runReferences = new HashSet(run.References, StringComparer.Ordinal); + foreach (string reference in listed) + { + Assert.That(runReferences.Contains(reference), Is.True, "not bound by the run: " + reference); + } + + HashSet listedReferences = new HashSet(listed, StringComparer.Ordinal); + string publicizedRefs = Path.GetFullPath(Path.Combine(projectRoot, HotReloadConstants.PublicizedRefsRelativeDirectory)); + string publicizedExternalRefs = Path.GetFullPath( + Path.Combine(projectRoot, HotReloadConstants.PublicizedExternalRefsRelativeDirectory)); + foreach (string reference in run.References) + { + if (listedReferences.Contains(reference)) + { + continue; + } + + string directory = Path.GetDirectoryName(Path.GetFullPath(reference)); + Assert.That( + directory == publicizedRefs || directory == publicizedExternalRefs, + Is.True, + "bound as it is by the run but not listed: " + reference); + } + } + + /// + /// Verifies a ledger name whose dll is gone is skipped before Unity is asked about it, and the next name is used. + /// + [Test] + public async Task CollectAsync_SkipsANameWithNoDllWithoutAskingUnity() + { + List askedForMissing = new List(); + HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource( + ProjectRoot(), + _ => new[] { MissingAssemblyName, TestAssemblyName }, + name => + { + if (name == MissingAssemblyName) + { + askedForMissing.Add(name); + return null; + } + + return HotReloadCompilationAssemblies.FindByName(name); + }); + + IReadOnlyList listed = await source.CollectAsync(CancellationToken.None); + + IReadOnlyList expected = await CollectFor(new[] { TestAssemblyName }); + Assert.That(listed, Is.EqualTo(expected)); + Assert.That(askedForMissing, Is.Empty); + } + + /// + /// Verifies a name Unity no longer lists, as during a compile, gives nothing to warm. + /// + [Test] + public async Task CollectAsync_SkipsANameWhoseCompilationAssemblyIsGone() + { + HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource( + ProjectRoot(), + _ => new[] { TestAssemblyName }, + _ => null); + + IReadOnlyList listed = await source.CollectAsync(CancellationToken.None); + + Assert.That(listed, Is.Empty); + } + + /// + /// Verifies a project that never ran a hot reload gives nothing to warm without asking Unity anything. + /// + [Test] + public async Task CollectAsync_WithAnEmptyLedger_ReturnsEmptyWithoutAskingUnity() + { + int finderCalls = 0; + HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource( + ProjectRoot(), + _ => Array.Empty(), + name => + { + finderCalls++; + return HotReloadCompilationAssemblies.FindByName(name); + }); + + IReadOnlyList listed = await source.CollectAsync(CancellationToken.None); + + Assert.That(listed, Is.Empty); + Assert.That(finderCalls, Is.EqualTo(0)); + } + + /// + /// Verifies only the first usable name is listed. The hot-reload tool comes first because + /// its references bound as they are are a subset of the test assembly's, so a union of + /// both differs from the first name's list only in that order. + /// + [Test] + public async Task CollectAsync_UsesOnlyTheFirstUsableName() + { + IReadOnlyList listed = await CollectFor(new[] { HotReloadEditorAssemblyName, TestAssemblyName }); + + IReadOnlyList expected = await CollectFor(new[] { HotReloadEditorAssemblyName }); + IReadOnlyList testAssemblyOnly = await CollectFor(new[] { TestAssemblyName }); + HashSet expectedReferences = new HashSet(expected, StringComparer.Ordinal); + bool testAssemblyAddsAReference = false; + foreach (string reference in testAssemblyOnly) + { + testAssemblyAddsAReference |= !expectedReferences.Contains(reference); + } + + // Why a precondition: without a reference only the test assembly binds as it is, a + // union of both names would equal the first name's list and this test could not tell. + Assert.That(testAssemblyAddsAReference, Is.True, "the test assembly binds no extra reference as it is"); + Assert.That(listed, Is.EqualTo(expected)); + } + + /// + /// Verifies the compilation pipeline is asked on the main thread even when the list is requested from a pool thread. + /// + [Test] + public async Task CollectAsync_AsksUnityOnTheMainThreadWhenCalledFromAPoolThread() + { + List askedOnMainThread = new List(); + HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource( + ProjectRoot(), + _ => new[] { TestAssemblyName }, + name => + { + askedOnMainThread.Add(MainThreadSwitcher.IsMainThread); + return HotReloadCompilationAssemblies.FindByName(name); + }); + + await Task.Run(() => source.CollectAsync(CancellationToken.None)); + + Assert.That(askedOnMainThread, Is.EqualTo(new[] { true })); + } + + /// + /// Verifies the hot-reload startup publishes its reference list to the dynamic-code tool in every domain. + /// + [Test] + public void HotReloadStartup_WiresTheWarmUpReferenceCollector() + { + Assert.That(SharedCompilerWarmUpCoordination.CollectWarmUpReferencePaths, Is.Not.Null); + } + + private static Task> CollectFor(IReadOnlyList ledger) + { + HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource( + ProjectRoot(), + _ => ledger, + HotReloadCompilationAssemblies.FindByName); + return source.CollectAsync(CancellationToken.None); + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs.meta new file mode 100644 index 0000000000..d6804e370d --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e4b4a309c045649568027555b0438dac +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs index ed735c44b2..1c52a3ac07 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs @@ -24,8 +24,11 @@ internal sealed class DynamicCodeServicesRegistry private readonly Lazy _executorFactoryValue; - internal DynamicCodeServicesRegistry() + private readonly SharedRoslynCompilerWorkerWarmUp _sharedWorkerWarmUp; + + internal DynamicCodeServicesRegistry(SharedRoslynCompilerWorkerWarmUp sharedWorkerWarmUp) { + UnityEngine.Debug.Assert(sharedWorkerWarmUp != null, "sharedWorkerWarmUp must not be null."); _sourcePreparationServiceValue = new Lazy( () => new DynamicCodeSourcePreparationService()); _executorFactoryValue = new Lazy( @@ -33,6 +36,7 @@ internal DynamicCodeServicesRegistry() new DynamicCodeCompilationServiceFactory(), SourcePreparationService, CommandEntryPointResolver)); + _sharedWorkerWarmUp = sharedWorkerWarmUp; } internal RegistryDynamicCodeExecutorFactory ExecutorFactory => _executorFactoryValue.Value; @@ -55,6 +59,10 @@ internal void ResetServerScopedServices() _serverScopedDrainTask, ShutdownRuntimeAsync(runtimeFacade)); } + + // Why after the shutdown above: the reset has just stopped the shared worker, and the + // first compile of the new server session would otherwise start it cold. + _sharedWorkerWarmUp.Start(); } internal void ResetServerScopedServicesBeforeDomainReload() @@ -81,6 +89,16 @@ internal void SetRuntimeFacadeForTests(IDynamicCodeExecutionRuntime runtimeFacad } } + internal Task StopSharedWorkerWarmUpForTests() + { + return _sharedWorkerWarmUp.StopForTests(); + } + + internal Task GetSharedWorkerWarmUpTaskForTests() + { + return _sharedWorkerWarmUp.GetTaskForTests(); + } + internal Task GetServerScopedDrainTaskForTests() { lock (_serverScopedServicesLock) @@ -197,7 +215,8 @@ private static void LogDrainFailure(Task drainTask) /// internal static class DynamicCodeServices { - private static readonly DynamicCodeServicesRegistry RegistryValue = new DynamicCodeServicesRegistry(); + private static readonly DynamicCodeServicesRegistry RegistryValue = new DynamicCodeServicesRegistry( + SharedRoslynCompilerWorkerWarmUp.CreateProduction()); internal static DynamicCodeServicesRegistry GetRegistry() { diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolution.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolution.cs new file mode 100644 index 0000000000..54804eba00 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolution.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// What resolving the external compiler found: the paths, or the components that are missing. + /// + internal sealed class ExternalCompilerPathResolution + { + private ExternalCompilerPathResolution( + ExternalCompilerPaths paths, + string editorPath, + string contentsPath, + IReadOnlyList missingComponents) + { + Paths = paths; + EditorPath = editorPath; + ContentsPath = contentsPath; + MissingComponents = missingComponents; + } + + // Null unless every component exists. + internal ExternalCompilerPaths Paths { get; } + + internal string EditorPath { get; } + + internal string ContentsPath { get; } + + // Empty unless some component is missing. + internal IReadOnlyList MissingComponents { get; } + + internal static ExternalCompilerPathResolution Found(ExternalCompilerPaths paths) + { + UnityEngine.Debug.Assert(paths != null, "paths must not be null."); + return new ExternalCompilerPathResolution(paths, null, null, Array.Empty()); + } + + internal static ExternalCompilerPathResolution Missing( + string editorPath, + string contentsPath, + List missingComponents) + { + UnityEngine.Debug.Assert( + missingComponents != null && missingComponents.Count > 0, + "missingComponents must list at least one component."); + return new ExternalCompilerPathResolution(null, editorPath, contentsPath, missingComponents); + } + + /// + /// The Editor path or its contents path is empty: there is no layout to report on. + /// + internal static ExternalCompilerPathResolution NoEditorLayout() + { + return new ExternalCompilerPathResolution(null, null, null, Array.Empty()); + } + + /// + /// Reports the missing components the way a compile reports them, once per Editor path; + /// does nothing when none is missing. + /// + internal void ReportMissingComponents() + { + if (MissingComponents.Count == 0) + { + return; + } + + DynamicCompilationHealthMonitor.ReportFastPathUnavailable( + EditorPath, + ContentsPath, + MissingComponents); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolution.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolution.cs.meta new file mode 100644 index 0000000000..6a2431cd29 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolution.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 06e80fcd292e84e53b0c853a84d752e1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolver.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolver.cs index e42667e65a..0a3c0caeef 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolver.cs @@ -31,16 +31,30 @@ internal static class ExternalCompilerPathResolver public static ExternalCompilerPaths Resolve() { - string editorPath = EditorApplication.applicationPath; + ExternalCompilerPathResolution resolution = ResolveFromEditorPath(EditorApplication.applicationPath); + resolution.ReportMissingComponents(); + return resolution.Paths; + } + + // Why a second entry: the shared-worker warm-up runs after every server reset, and a missing + // compiler must not log an Error there, nor use up the once-only report the first real compile + // gives. + internal static ExternalCompilerPaths ResolveWithoutReporting() + { + return ResolveFromEditorPath(EditorApplication.applicationPath).Paths; + } + + internal static ExternalCompilerPathResolution ResolveFromEditorPath(string editorPath) + { if (string.IsNullOrEmpty(editorPath)) { - return null; + return ExternalCompilerPathResolution.NoEditorLayout(); } string contentsPath = ResolveEditorContentsPath(editorPath); if (string.IsNullOrEmpty(contentsPath)) { - return null; + return ExternalCompilerPathResolution.NoEditorLayout(); } string scriptingRootPath = ResolveScriptingRootPath(contentsPath); @@ -126,14 +140,10 @@ public static ExternalCompilerPaths Resolve() if (missingComponents.Count > 0) { - DynamicCompilationHealthMonitor.ReportFastPathUnavailable( - editorPath, - contentsPath, - missingComponents); - return null; + return ExternalCompilerPathResolution.Missing(editorPath, contentsPath, missingComponents); } - return new ExternalCompilerPaths( + return ExternalCompilerPathResolution.Found(new ExternalCompilerPaths( contentsPath, scriptingRootPath, dotnetHostPath, @@ -143,7 +153,7 @@ public static ExternalCompilerPaths Resolve() codeAnalysisDllPath, codeAnalysisCSharpDllPath, netCoreRuntimeSharedDirectoryPath, - layoutKind); + layoutKind)); } // Why NetCoreRuntime first: 6000.3/6000.5 already satisfy csc's required major from diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs index 953118790d..03fd48d7f0 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHost.cs @@ -17,6 +17,10 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal static class SharedRoslynCompilerWorkerHost { private const int SharedCompilerWorkerMaxAttempts = 2; + private const string WarmUpSourceFileName = "SharedWorkerWarmUp.cs"; + private const string WarmUpAssemblyFileName = "SharedWorkerWarmUp.dll"; + private const string WarmUpRequestFileName = "SharedWorkerWarmUp.worker"; + private const string WarmUpSource = "internal sealed class UnityCliLoopSharedWorkerWarmUp { }"; private static readonly SharedRoslynCompilerWorkerSession ServiceValue = new(); @@ -47,6 +51,107 @@ public static Task TryCompileAsync( ct); } + /// + /// Starts the shared worker when none runs and has it compile a tiny source against + /// , so a later compile finds the worker and those references + /// loaded. Any thread once the package path has been read on the main thread. Reports nothing + /// to the health monitor: a failure here is retried by the next real compile, which reports it. + /// + internal static Task WarmUpAsync( + IReadOnlyList references, + ExternalCompilerPaths externalCompilerPaths) + { + return ServiceValue.RunSerializedCompileAsync( + _ => WarmUpInsideCompileGateAsync(references, externalCompilerPaths), + CancellationToken.None); + } + + private static async Task WarmUpInsideCompileGateAsync( + IReadOnlyList references, + ExternalCompilerPaths externalCompilerPaths) + { + int lifecycleGeneration = 0; + bool hasLiveProcess = false; + ServiceValue.ExecuteWithStateLock(() => + { + lifecycleGeneration = ServiceValue.GetLifecycleGenerationLocked(); + hasLiveProcess = ServiceValue.HasLiveProcessLocked(); + }); + // Why not compile again: a compile that got the gate first started the worker, and its + // session already paid the cold start this warm-up exists to move. + if (hasLiveProcess) + { + return SharedWorkerWarmUpOutcome.AlreadyRunning(); + } + + WorkerStartupResult startupResult = await SharedRoslynCompilerWorkerHostProcess.EnsureWorkerReadyAsync( + ServiceValue, + externalCompilerPaths, + lifecycleGeneration).ConfigureAwait(false); + if (!startupResult.IsReady) + { + ServiceValue.ExecuteWithStateLock(ServiceValue.ShutdownProcessLocked); + return SharedWorkerWarmUpOutcome.StartFailed(startupResult.FailureReason); + } + + string directory = SharedRoslynCompilerWorkerHostProcess.GetWarmUpDirectoryPath(); + Directory.CreateDirectory(directory); + try + { + string sourcePath = Path.Combine(directory, WarmUpSourceFileName); + string requestFilePath = Path.Combine(directory, WarmUpRequestFileName); + File.WriteAllText(sourcePath, WarmUpSource); + // Same options as the hot-reload shim compile, so the same compiler paths get compiled. + RoslynCompilerRequestFileWriter.WriteWorkerRequestFile( + requestFilePath, + sourcePath, + Path.Combine(directory, WarmUpAssemblyFileName), + references, + Array.Empty(), + allowUnsafeCode: false, + emitDebugCode: true); + // Why no token: cancelling a read kills the worker, and only a shutdown stops a warm-up. + WorkerAttemptResult attemptResult = await InvokeWorkerOnceAsync( + requestFilePath, + CancellationToken.None, + NoOperation, + NoOperation, + NoOperation).ConfigureAwait(false); + if (!attemptResult.Succeeded) + { + ServiceValue.ExecuteWithStateLock(ServiceValue.ShutdownProcessLocked); + return SharedWorkerWarmUpOutcome.RequestFailed(attemptResult.FailureReason); + } + + return SharedWorkerWarmUpOutcome.Answered(CountErrors(attemptResult.Messages)); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + } + + private static void NoOperation() + { + } + + private static int CountErrors(CompilerMessage[] messages) + { + int errorCount = 0; + foreach (CompilerMessage message in messages) + { + if (message.type == CompilerMessageType.Error) + { + errorCount++; + } + } + + return errorCount; + } + private static async Task TryCompileWithRetriesAsync( string requestFilePath, ExternalCompilerPaths externalCompilerPaths, diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs index b24481ef1a..10010911ba 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostProcess.cs @@ -53,6 +53,17 @@ internal static string GetWorkerDirectoryPath() $"RoslynWorker-{Process.GetCurrentProcess().Id}"); } + // One per Editor process, outside the worker directory: a reload that drops a warm-up midway + // leaves only this, which the next warm-up overwrites, and the worker-directory cleanup must + // not delete a warm-up's files under it. + internal static string GetWarmUpDirectoryPath() + { + return Path.Combine( + Path.GetTempPath(), + "UnityCliLoopCompilation", + $"SharedWorkerWarmUp-{Process.GetCurrentProcess().Id}"); + } + internal static WorkerPaths CreateWorkerPaths(SharedRoslynCompilerWorkerSession session) { string workerDirectoryPath = GetWorkerDirectoryPath(); diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs index efea041c37..9f6aed7882 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerHostResults.cs @@ -178,4 +178,53 @@ public static WorkerAssemblyEnsureResult Failed(WorkerStartupResult failure) return new WorkerAssemblyEnsureResult(failure, string.Empty, CachePublishOutcome.NotAttempted); } } + + /// + /// What one shared-worker warm-up did. + /// + internal sealed class SharedWorkerWarmUpOutcome + { + // The worker answered the compile, whatever its exit code. + internal const string OutcomeAnswered = "answered"; + // A compile had started the worker first; nothing was sent. + internal const string OutcomeAlreadyRunning = "already_running"; + internal const string OutcomeStartFailed = "start_failed"; + internal const string OutcomeRequestFailed = "request_failed"; + + private SharedWorkerWarmUpOutcome(string outcome, string failureReason, int errorCount) + { + Outcome = outcome; + FailureReason = failureReason; + ErrorCount = errorCount; + } + + internal string Outcome { get; } + + // Null unless the worker failed to start or to answer. + internal string FailureReason { get; } + + // Errors the worker reported; 0 unless it answered. + internal int ErrorCount { get; } + + internal static SharedWorkerWarmUpOutcome Answered(int errorCount) + { + Debug.Assert(errorCount >= 0, "errorCount must not be negative"); + return new SharedWorkerWarmUpOutcome(OutcomeAnswered, null, errorCount); + } + + internal static SharedWorkerWarmUpOutcome AlreadyRunning() + { + return new SharedWorkerWarmUpOutcome(OutcomeAlreadyRunning, null, 0); + } + + internal static SharedWorkerWarmUpOutcome StartFailed(string failureReason) + { + return new SharedWorkerWarmUpOutcome(OutcomeStartFailed, failureReason, 0); + } + + internal static SharedWorkerWarmUpOutcome RequestFailed(string failureReason) + { + return new SharedWorkerWarmUpOutcome(OutcomeRequestFailed, failureReason, 0); + } + } } diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerWarmUp.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerWarmUp.cs new file mode 100644 index 0000000000..9e476774b7 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerWarmUp.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +using Debug = UnityEngine.Debug; +using Stopwatch = System.Diagnostics.Stopwatch; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Starts the shared Roslyn compiler worker in the background after a server reset and runs one + /// small compile against the references the next hot-reload shim compile binds, so that compile + /// finds the worker running and its references loaded. + /// + internal sealed class SharedRoslynCompilerWorkerWarmUp + { + internal const string VibeLogWarmUpComplete = "dynamic_code_shared_worker_warm_up_complete"; + internal const string VibeLogWarmUpSkipped = "dynamic_code_shared_worker_warm_up_skipped"; + internal const string SkipReasonStoppedForTests = "stopped_for_tests"; + internal const string SkipReasonNoHotReload = "no_hot_reload"; + internal const string SkipReasonNoTargets = "no_targets"; + internal const string SkipReasonCompilerUnavailable = "compiler_unavailable"; + internal const string OutcomeFailed = "failed"; + + private readonly object _lock = new object(); + private readonly Func>>> _getReferenceCollector; + private readonly Func _resolveCompilerPaths; + private readonly Func _readPackagePath; + private readonly Func, ExternalCompilerPaths, Task> _warmWorker; + private Task _inFlight = Task.CompletedTask; + private bool _stopped; + + internal SharedRoslynCompilerWorkerWarmUp( + Func>>> getReferenceCollector, + Func resolveCompilerPaths, + Func readPackagePath, + Func, ExternalCompilerPaths, Task> warmWorker) + { + Debug.Assert(getReferenceCollector != null, "getReferenceCollector must not be null."); + Debug.Assert(resolveCompilerPaths != null, "resolveCompilerPaths must not be null."); + Debug.Assert(readPackagePath != null, "readPackagePath must not be null."); + Debug.Assert(warmWorker != null, "warmWorker must not be null."); + _getReferenceCollector = getReferenceCollector; + _resolveCompilerPaths = resolveCompilerPaths; + _readPackagePath = readPackagePath; + _warmWorker = warmWorker; + } + + internal static SharedRoslynCompilerWorkerWarmUp CreateProduction() + { + return new SharedRoslynCompilerWorkerWarmUp( + () => SharedCompilerWarmUpCoordination.CollectWarmUpReferencePaths, + ExternalCompilerPathResolver.ResolveWithoutReporting, + () => UnityCliLoopConstants.PackageResolvedPath, + SharedRoslynCompilerWorkerHost.WarmUpAsync); + } + + /// + /// Starts a warm-up in the background. Never throws and never runs any of the warm-up + /// before it returns. + /// + internal void Start() + { + lock (_lock) + { + if (_stopped) + { + LogSkipped(SkipReasonStoppedForTests); + return; + } + + Task run = RunAsync(); + // Why chain rather than replace: when starts follow each other (a manual server + // restart), the task a test waits for has to cover the earlier warm-up too. + // RunAsync never lets an exception out, so this never faults. + _inFlight = Task.WhenAll(_inFlight, run); + } + } + + /// + /// Keeps later starts from running and returns every warm-up still in flight. + /// + internal Task StopForTests() + { + lock (_lock) + { + _stopped = true; + return _inFlight; + } + } + + internal Task GetTaskForTests() + { + lock (_lock) + { + return _inFlight; + } + } + + private async Task RunAsync() + { + // Why yield first: Start runs inside the server reset, before the readiness probe + // awaits; none of the warm-up may run there, and an exception there would fail the + // server start. + await Task.Yield(); + Stopwatch total = Stopwatch.StartNew(); + try + { + Func>> collect = _getReferenceCollector(); + if (collect == null) + { + LogSkipped(SkipReasonNoHotReload); + return; + } + + Stopwatch watch = Stopwatch.StartNew(); + IReadOnlyList references = await collect(CancellationToken.None).ConfigureAwait(false); + long referencesMs = watch.ElapsedMilliseconds; + if (references.Count == 0) + { + LogSkipped(SkipReasonNoTargets); + return; + } + + await MainThreadSwitcher.SwitchToMainThread(); + watch.Restart(); + // Why read it here: the worker's program source needs the package path, whose first + // read calls a main-thread-only Unity API, and the worker starts on a pool thread below. + string packagePath = _readPackagePath(); + Debug.Assert(!string.IsNullOrEmpty(packagePath), "package path must resolve on the main thread."); + long mainThreadMs = watch.ElapsedMilliseconds; + + // Why a pool thread: the resolver reads only values Unity reads thread-safely, and + // walks the Editor's folders on every call. + ExternalCompilerPaths paths = await Task.Run(_resolveCompilerPaths).ConfigureAwait(false); + if (paths == null) + { + LogSkipped(SkipReasonCompilerUnavailable); + return; + } + + watch.Restart(); + SharedWorkerWarmUpOutcome outcome = + await Task.Run(() => _warmWorker(references, paths)).ConfigureAwait(false); + LogComplete( + total.ElapsedMilliseconds, + referencesMs, + mainThreadMs, + watch.ElapsedMilliseconds, + references.Count, + outcome); + } + catch (Exception exception) + { + // Why catch everything here: nothing awaits this task outside tests, so an exception + // would go unseen; it is logged where a developer looks instead of failing silently. + LogFailed(total.ElapsedMilliseconds, exception); + Debug.LogException(exception); + } + } + + private static void LogSkipped(string reason) + { + VibeLogger.LogInfo( + VibeLogWarmUpSkipped, + "Shared Roslyn worker warm-up skipped.", + new { reason }); + } + + private static void LogComplete( + long totalMs, + long referencesMs, + long mainThreadMs, + long warmMs, + int referenceCount, + SharedWorkerWarmUpOutcome outcome) + { + VibeLogger.LogInfo( + VibeLogWarmUpComplete, + "Shared Roslyn worker warm-up complete.", + new + { + ms = totalMs, + referencesMs, + mainThreadMs, + warmMs, + referenceCount, + outcome = outcome.Outcome, + failureReason = outcome.FailureReason, + errorCount = outcome.ErrorCount + }); + } + + private static void LogFailed(long totalMs, Exception exception) + { + VibeLogger.LogWarning( + VibeLogWarmUpComplete, + "Shared Roslyn worker warm-up failed.", + new + { + ms = totalMs, + outcome = OutcomeFailed, + failureReason = exception.GetType().Name, + exceptionMessage = exception.Message + }); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerWarmUp.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerWarmUp.cs.meta new file mode 100644 index 0000000000..062f259725 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerWarmUp.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e0639aae50aa4196907b9c246daaab5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs index 1e18d42057..881fd704b6 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs @@ -58,6 +58,14 @@ void StartWarmUpOnFirstUpdateTick() // null delegate as "nothing to lose". HotReloadRuntimeChangeCoordination.GetActiveRuntimeChangeCount = () => HotReloadCompositionRoot.Services.Domain.CountActiveChanges().RuntimeChangeTotal; + // Why here, like the count above: the dynamic-code tool reads it after each server + // reset, which can come before anything in this assembly is touched. + string warmUpProjectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + HotReloadSharedCompilerWarmUpReferenceSource warmUpReferences = new HotReloadSharedCompilerWarmUpReferenceSource( + warmUpProjectRoot, + HotReloadWarmUpTargetLedger.Read, + HotReloadCompilationAssemblies.FindByName); + SharedCompilerWarmUpCoordination.CollectWarmUpReferencePaths = warmUpReferences.CollectAsync; // Why the same shape for these two: both run from Editor callbacks that take no // argument, so they have to read whichever services are installed when they fire. HotReloadAutoRefreshHold.GetServices = () => HotReloadCompositionRoot.Services; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs new file mode 100644 index 0000000000..38426aa938 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Lists the compile references the next shim compile would bind as they are, for the most + /// recently edited assembly that is compiled, so the shared compiler worker can load them + /// before the first run after a server reset. + /// + internal sealed class HotReloadSharedCompilerWarmUpReferenceSource + { + private readonly string _projectRoot; + private readonly Func> _readLedger; + private readonly Func _findCompilationAssembly; + + internal HotReloadSharedCompilerWarmUpReferenceSource( + string projectRoot, + Func> readLedger, + Func findCompilationAssembly) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be empty."); + Debug.Assert(readLedger != null, "readLedger must not be null."); + Debug.Assert(findCompilationAssembly != null, "findCompilationAssembly must not be null."); + _projectRoot = projectRoot; + _readLedger = readLedger; + _findCompilationAssembly = findCompilationAssembly; + } + + /// + /// Any thread. Empty when the ledger names no assembly whose dll exists and whose + /// compilation assembly Unity lists (none is listed while the Editor compiles). + /// + internal async Task> CollectAsync(CancellationToken ct) + { + // Why the main thread: the compilation pipeline answers only there. + await MainThreadSwitcher.SwitchToMainThread(ct); + IReadOnlyList names = _readLedger(_projectRoot); + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_projectRoot); + int count = Math.Min(names.Count, HotReloadWarmUpContextSource.MaxWarmedTargets); + for (int index = 0; index < count; index++) + { + string name = names[index]; + string fullTargetDllPath = Path.GetFullPath(layout.DllPath(name)); + // Renamed or removed since a run recorded it. + if (!File.Exists(fullTargetDllPath)) + { + continue; + } + + UnityCompilationAssembly assembly = _findCompilationAssembly(name); + if (assembly == null || assembly.allReferences == null) + { + continue; + } + + // Read here: the pool thread must not touch the Unity object. + string[] allReferences = assembly.allReferences; + string scriptAssembliesDirectory = layout.CompiledAssembliesDirectory; + // Why only the first usable name: the references bound as they are hardly differ + // between assemblies, and the worker keeps every one it loads for its lifetime. + // Why off the main thread: one File.Exists per reference, several hundred on a + // large project. + return await Task.Run( + () => ListReferencesBoundAsTheyAre(allReferences, fullTargetDllPath, scriptAssembliesDirectory), + ct) + .ConfigureAwait(false); + } + + return Array.Empty(); + } + + private IReadOnlyList ListReferencesBoundAsTheyAre( + string[] allReferences, + string fullTargetDllPath, + string scriptAssembliesDirectory) + { + List paths = new List(); + foreach (ShimCompileReference entry in HotReloadShimReferenceBuilder.ClassifyCompileReferences( + allReferences, + _projectRoot, + fullTargetDllPath, + scriptAssembliesDirectory)) + { + if (!entry.UsesRewrittenCopy) + { + paths.Add(entry.FullPath); + } + } + + return paths; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs.meta new file mode 100644 index 0000000000..e07f934a0c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 60de2f3c930f54d728e86847364b8d7d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs index d1d923b691..20c6edddc6 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs @@ -470,33 +470,28 @@ private static List BuildShimReferencePaths( string fullTarget = Path.GetFullPath(targetHome.DllPath); // The name another assembly's InternalsVisibleTo grant names the edited assembly by. string shimTargetAssemblyName = Path.GetFileNameWithoutExtension(fullTarget); - foreach (string reference in compilationAssembly.allReferences) + foreach (ShimCompileReference entry in ClassifyCompileReferences( + compilationAssembly.allReferences, + projectRoot, + fullTarget, + scriptAssembliesDirectory)) { - if (string.IsNullOrEmpty(reference)) - { - continue; - } - - // Resolved against the root for the same reason as in BuildWorkerReferencePaths. - string fullReference = Path.GetFullPath(Path.Combine(projectRoot, reference)); - if (!File.Exists(fullReference)) - { - continue; - } - if (string.Equals(fullReference, fullTarget, StringComparison.OrdinalIgnoreCase)) - { - // Replaced by the publicized copy above. - continue; - } - - string shimReference = PublicizeProjectReference( - fullReference, - scriptAssembliesDirectory, - resolverSearchDirectories, - shimTargetAssemblyName); + // A rewritten copy of a project assembly the shim may need non-public members of. + // Unless the reference grants its internals to the edited assembly, the copy keeps + // the reference's internal types and its private and internal members as they are + // and publicizes its protected ones, so the shim compile sees what the edited + // assembly's own compile saw. + string shimReference = entry.UsesRewrittenCopy + ? ReferencePublicizer.GetOrCreateShimReferenceCopy( + HotReloadTypeHome.ScriptAssemblies( + Path.GetFileNameWithoutExtension(entry.FullPath), + entry.FullPath), + resolverSearchDirectories, + shimTargetAssemblyName) + : entry.FullPath; references.Add(shimReference); timing.ReferenceCount++; - if (!string.Equals(shimReference, fullReference, StringComparison.Ordinal)) + if (!string.Equals(shimReference, entry.FullPath, StringComparison.Ordinal)) { timing.CopiedReferenceCount++; } @@ -538,30 +533,45 @@ private sealed class ShimReferencesTiming } /// - /// Returns the reference a shim compile binds against for one of Unity's compile - /// references: a publicized copy when the reference is a project assembly the shim may - /// need non-public members of, and the reference itself otherwise. Unless the reference - /// grants its internals to the edited assembly, the copy keeps the reference's internal - /// types and its private and internal members as they are and publicizes its protected - /// ones, so the shim compile sees what the edited assembly's own compile saw. + /// The compile references a shim compile of the assembly at fullTargetDllPath binds, in + /// Unity's order: empty entries, missing files and the target itself are left out; a + /// project assembly under the compiled-assemblies directory whose name may be publicized + /// is marked to be bound through a rewritten copy; every other reference is bound as it is. + /// Any thread. /// - private static string PublicizeProjectReference( - string fullReference, - string scriptAssembliesDirectory, - IReadOnlyCollection resolverSearchDirectories, - string shimTargetAssemblyName) + internal static List ClassifyCompileReferences( + IReadOnlyList allReferences, + string projectRoot, + string fullTargetDllPath, + string scriptAssembliesDirectory) { - string referenceFileName = Path.GetFileNameWithoutExtension(fullReference); - if (!IsUnderDirectory(fullReference, scriptAssembliesDirectory) - || !HotReloadConstants.IsPublicizableProjectAssemblyFileName(referenceFileName)) + List classified = new List(); + foreach (string reference in allReferences) { - return fullReference; + if (string.IsNullOrEmpty(reference)) + { + continue; + } + + // Resolved against the root for the same reason as in BuildWorkerReferencePaths. + string fullReference = Path.GetFullPath(Path.Combine(projectRoot, reference)); + if (!File.Exists(fullReference)) + { + continue; + } + if (string.Equals(fullReference, fullTargetDllPath, StringComparison.OrdinalIgnoreCase)) + { + // Replaced by the publicized copy of the target. + continue; + } + + string referenceFileName = Path.GetFileNameWithoutExtension(fullReference); + bool usesRewrittenCopy = IsUnderDirectory(fullReference, scriptAssembliesDirectory) + && HotReloadConstants.IsPublicizableProjectAssemblyFileName(referenceFileName); + classified.Add(new ShimCompileReference(fullReference, usesRewrittenCopy)); } - return ReferencePublicizer.GetOrCreateShimReferenceCopy( - HotReloadTypeHome.ScriptAssemblies(referenceFileName, fullReference), - resolverSearchDirectories, - shimTargetAssemblyName); + return classified; } private static bool IsUnderDirectory(string fullPath, string directoryPath) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ShimCompileReference.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ShimCompileReference.cs new file mode 100644 index 0000000000..bc74cfa223 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ShimCompileReference.cs @@ -0,0 +1,19 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One of Unity's compile references of the edited assembly, resolved against the project root, + /// and whether the shim compile binds a rewritten copy of it instead of the file itself. + /// + internal readonly struct ShimCompileReference + { + internal ShimCompileReference(string fullPath, bool usesRewrittenCopy) + { + FullPath = fullPath; + UsesRewrittenCopy = usesRewrittenCopy; + } + + internal string FullPath { get; } + + internal bool UsesRewrittenCopy { get; } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ShimCompileReference.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ShimCompileReference.cs.meta new file mode 100644 index 0000000000..60d466b58a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ShimCompileReference.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aa8302ec483dd4c7ebbc6a3ed0bfa342 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/ToolContracts/SharedCompilerWarmUpCoordination.cs b/Packages/src/Editor/ToolContracts/SharedCompilerWarmUpCoordination.cs new file mode 100644 index 0000000000..b8becbf44f --- /dev/null +++ b/Packages/src/Editor/ToolContracts/SharedCompilerWarmUpCoordination.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace io.github.hatayama.UnityCliLoop.ToolContracts +{ + /// + /// Editor-domain coordination point through which the hot-reload tool tells the dynamic-code + /// tool which compile references to load into the shared compiler worker after a server + /// reset. The two tools live in sibling assemblies that must not reference each other. + /// + public static class SharedCompilerWarmUpCoordination + { + // Set by the hot-reload startup. Lists the compile references the next hot-reload shim + // compile binds as they are, for the most recently edited assembly that is compiled; + // empty when there is none. Callable from any thread: it switches to the main thread + // itself for the Unity API it needs. Null means the hot-reload tool has not initialized + // in this domain - callers treat null as nothing to warm. + public static Func>> CollectWarmUpReferencePaths { get; set; } + } +} diff --git a/Packages/src/Editor/ToolContracts/SharedCompilerWarmUpCoordination.cs.meta b/Packages/src/Editor/ToolContracts/SharedCompilerWarmUpCoordination.cs.meta new file mode 100644 index 0000000000..ffae585db7 --- /dev/null +++ b/Packages/src/Editor/ToolContracts/SharedCompilerWarmUpCoordination.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 04108c54ee764473ebe0a0238e381e28 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/hot-reload.md b/docs/hot-reload.md index f10c9f395f..7cd8c6e43e 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -96,9 +96,12 @@ does not start while the Editor compiles or imports, and a domain reload or a co it. A run that arrives while it works waits only for the unit (one dll) in flight, whose result it reuses; that item and the remaining ones are dropped; a run that arrives before the tick keeps it from starting. `hot_reload_warm_up_complete` and `hot_reload_warm_up_skipped` show what it did, and the run's -`warm_up_yield` step shows how long the run waited (see `docs/vibe-logs.md`). The transform -worker, the shared Roslyn worker and the copies of the other project assemblies a shim compile -references are not warmed up yet. +`warm_up_yield` step shows how long the run waited (see `docs/vibe-logs.md`). After each server +reset (a domain reload or a manual server start), when `targets.txt` names a compiled assembly, +the shared Roslyn worker is started in the background and compiles once against the references +the shim compile binds as they are, so the first shim compile finds it running (see +`dynamic_code_shared_worker_warm_up_complete`). The transform worker and the copies of the other +project assemblies a shim compile references are not warmed up yet. The shim compile references a fully publicized copy of the edited assembly, and of every other project assembly that grants the edited one its internals through `InternalsVisibleTo`. Any other project assembly is referenced through a `PublicizedExternalRefs` copy that keeps its top-level diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 64b0a80ccf..400916e398 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -66,6 +66,19 @@ command ran against: from `/UnityCliLoopCompilation/RoslynWorkerCache//`, keyed by the worker source and the compiler paths), or `built` (csc ran); `cachePublish` and `cachePublishError` say whether a built assembly was added to that cache. + After each server reset, the shared-worker warm-up writes one + `dynamic_code_shared_worker_warm_up_skipped` with its `reason` (`no_hot_reload`: the hot-reload + tool has not initialized in this domain; `no_targets`: `targets.txt` names no assembly whose dll + exists and whose compilation assembly Unity lists; `compiler_unavailable`: the external compiler + is missing, which only the next real compile reports; `stopped_for_tests`), or one + `dynamic_code_shared_worker_warm_up_complete` with `ms` (the whole warm-up), `referencesMs` + (listing the references), `mainThreadMs` (reading the package path on the main thread), + `warmMs` (starting the worker and its compile), `referenceCount`, `outcome`, `failureReason` + and `errorCount`. `outcome` is `answered` (the worker answered the compile; `errorCount` is the + errors it reported), `already_running` (a compile had started the worker first, so nothing was + sent), `start_failed` or `request_failed` (with `failureReason`; the worker is stopped and the + next compile starts it again), or `failed` (an exception, with `failureReason` set to its type + and `exceptionMessage`, logged as a warning). `hot_reload_source_snapshot_captured` appears once per domain when the source snapshot capture ran to completion, with its `trigger` (`domain_load` normally; `first_update_tick` or `apply` only when the capture at domain load threw or Unity listed no compilation assembly From 7a5711dd6c0dcb653ca97162fa8cc9b0a86476b0 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 9 Oct 2026 23:22:16 +0900 Subject: [PATCH 29/29] fix: The package compiles on Unity 6000.5 and newer, whose Editor ships its own System.Reflection.Metadata (#3260) --- .../unity-compile-check-and-test-runner.yml | 3 +- .../CodeAnalysisPluginImporterTests.cs | 15 ++++++++ ...HotReloadResolverSearchDirectoriesTests.cs | 35 +++++++++++++++++++ ...Loop.System.Collections.Immutable.dll.meta | 3 +- ...liLoop.System.Reflection.Metadata.dll.meta | 3 +- ...m.Runtime.CompilerServices.Unsafe.dll.meta | 3 +- Packages/src/THIRD-PARTY-NOTICES.md | 2 ++ 7 files changed, 60 insertions(+), 4 deletions(-) diff --git a/.github/workflows/unity-compile-check-and-test-runner.yml b/.github/workflows/unity-compile-check-and-test-runner.yml index 9b529173f4..d814cc1eae 100644 --- a/.github/workflows/unity-compile-check-and-test-runner.yml +++ b/.github/workflows/unity-compile-check-and-test-runner.yml @@ -61,7 +61,8 @@ jobs: echo "=== Changed files ===" echo "$CHANGED" - if echo "$CHANGED" | grep -qE '^(Packages/src/Editor/.*\.cs|Packages/src/Runtime/.*\.cs|Assets/.*\.cs|tests/.*\.cs|tests/.*\.csproj|Packages/.*\.asmdef|Assets/.*\.asmdef|Packages/manifest\.json|Packages/packages-lock\.json|ci/unity-projects/.*|\.github/workflows/unity-compile-check-and-test-runner\.yml)$'; then + # Plugin import settings decide which precompiled assemblies a script assembly references. + if echo "$CHANGED" | grep -qE '^(Packages/src/Editor/.*\.cs|Packages/src/Runtime/.*\.cs|Assets/.*\.cs|tests/.*\.cs|tests/.*\.csproj|Packages/.*\.asmdef|Assets/.*\.asmdef|Packages/src/.*\.dll\.meta|Packages/manifest\.json|Packages/packages-lock\.json|ci/unity-projects/.*|\.github/workflows/unity-compile-check-and-test-runner\.yml)$'; then echo "csharp=true" >> "$GITHUB_OUTPUT" else echo "csharp=false" >> "$GITHUB_OUTPUT" diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/CodeAnalysisPluginImporterTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/CodeAnalysisPluginImporterTests.cs index e2b64a5282..405a721cb6 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/CodeAnalysisPluginImporterTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/CodeAnalysisPluginImporterTests.cs @@ -88,6 +88,21 @@ public void CodeAnalysisPlugins_WhenLoaded_AreExplicitlyReferenced() } } + [Test] + public void CodeAnalysisPlugins_WhenLoaded_StepAsideOnUnity6000_5OrNewer() + { + // Tests that Editors from 6000.5 on, which ship these assemblies as BCL extensions and + // reference them from every script assembly, do not also reference the bundled copies. + for (int assemblyIndex = 0; assemblyIndex < PrivateAssemblies.Length; assemblyIndex++) + { + PrivateAssembly assembly = PrivateAssemblies[assemblyIndex]; + PluginImporter importer = AssetImporter.GetAtPath(assembly.AssetPath) as PluginImporter; + + Assert.That(importer, Is.Not.Null, assembly.AssetPath); + Assert.That(importer!.DefineConstraints, Is.EqualTo(new[] { "!UNITY_6000_5_OR_NEWER" }), assembly.AssetPath); + } + } + [Test] public void CodeAnalysisPlugins_WhenInspected_UsePrivateAssemblyIdentities() { diff --git a/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs index 028329b38c..ad87ab1ef6 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs @@ -97,6 +97,7 @@ public void Collect_OrdersOwnReferencesBeforeTransitiveOnes() Assert.That(pluginIndex, Is.GreaterThan(ownIndex)); } +#if !UNITY_6000_5_OR_NEWER /// /// Verifies that for this project's hot-reload test assembly, whose asmdef overrides its /// references, the directories reach the code analysis plugins that only a referenced tool @@ -116,6 +117,40 @@ public void Collect_ForHotReloadTestAssembly_ReachesTheCodeAnalysisPluginDirecto Path.GetFullPath(Path.Combine(Application.dataPath, "..")), testAssembly), Does.Contain(pluginDirectory)); } +#else + /// + /// Verifies that from Unity 6000.5 on, where the bundled code analysis plugins step aside + /// (the define constraint in their .meta), the directories reach the Editor's stock + /// System.Reflection.Metadata through the test assembly's own references and no longer + /// list the plugin directory at all. + /// + [Test] + public void Collect_ForHotReloadTestAssembly_OnUnity6000_5OrNewer_ReachesTheStockMetadataDirectoryWithoutThePlugin() + { + UnityCompilationAssembly testAssembly = PublicizerTestSearchDirectories.HotReloadTestAssembly(); + string pluginDirectory = Path.GetFullPath(Path.GetDirectoryName(CodeAnalysisPluginPath)); + string stockMetadataDirectory = null; + foreach (string reference in testAssembly.compiledAssemblyReferences) + { + if (string.Equals(Path.GetFileName(reference), "System.Reflection.Metadata.dll", StringComparison.Ordinal)) + { + stockMetadataDirectory = Path.GetDirectoryName(Path.GetFullPath(reference)); + break; + } + } + + Assert.That(stockMetadataDirectory, Is.Not.Null, + "Unity 6000.5+ lists the BCLExtensions System.Reflection.Metadata among every script assembly's compiled references."); + Assert.That(stockMetadataDirectory, Is.Not.EqualTo(pluginDirectory)); + + IReadOnlyCollection directories = HotReloadResolverSearchDirectories.Collect( + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + testAssembly); + Assert.That(directories, Does.Contain(stockMetadataDirectory)); + Assert.That(directories, Does.Not.Contain(pluginDirectory), + "The define constraint keeps the bundled plugins out of every script assembly's references on 6000.5+."); + } +#endif /// /// Verifies a publicized copy whose metadata needs an assembly that only a transitive diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Plugins/CodeAnalysis/UnityCliLoop.System.Collections.Immutable.dll.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Plugins/CodeAnalysis/UnityCliLoop.System.Collections.Immutable.dll.meta index e5504c5870..f4e8271669 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Plugins/CodeAnalysis/UnityCliLoop.System.Collections.Immutable.dll.meta +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Plugins/CodeAnalysis/UnityCliLoop.System.Collections.Immutable.dll.meta @@ -5,7 +5,8 @@ PluginImporter: serializedVersion: 2 iconMap: {} executionOrder: {} - defineConstraints: [] + defineConstraints: + - '!UNITY_6000_5_OR_NEWER' isPreloaded: 0 isOverridable: 1 isExplicitlyReferenced: 1 diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Plugins/CodeAnalysis/UnityCliLoop.System.Reflection.Metadata.dll.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Plugins/CodeAnalysis/UnityCliLoop.System.Reflection.Metadata.dll.meta index 3c9a45cae0..7b5a760512 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Plugins/CodeAnalysis/UnityCliLoop.System.Reflection.Metadata.dll.meta +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Plugins/CodeAnalysis/UnityCliLoop.System.Reflection.Metadata.dll.meta @@ -5,7 +5,8 @@ PluginImporter: serializedVersion: 2 iconMap: {} executionOrder: {} - defineConstraints: [] + defineConstraints: + - '!UNITY_6000_5_OR_NEWER' isPreloaded: 0 isOverridable: 1 isExplicitlyReferenced: 1 diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Plugins/CodeAnalysis/UnityCliLoop.System.Runtime.CompilerServices.Unsafe.dll.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Plugins/CodeAnalysis/UnityCliLoop.System.Runtime.CompilerServices.Unsafe.dll.meta index db3c0f5897..fa84d64a0f 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Plugins/CodeAnalysis/UnityCliLoop.System.Runtime.CompilerServices.Unsafe.dll.meta +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Plugins/CodeAnalysis/UnityCliLoop.System.Runtime.CompilerServices.Unsafe.dll.meta @@ -5,7 +5,8 @@ PluginImporter: serializedVersion: 2 iconMap: {} executionOrder: {} - defineConstraints: [] + defineConstraints: + - '!UNITY_6000_5_OR_NEWER' isPreloaded: 0 isOverridable: 1 isExplicitlyReferenced: 1 diff --git a/Packages/src/THIRD-PARTY-NOTICES.md b/Packages/src/THIRD-PARTY-NOTICES.md index 05e3b00db0..ed272c3d6c 100644 --- a/Packages/src/THIRD-PARTY-NOTICES.md +++ b/Packages/src/THIRD-PARTY-NOTICES.md @@ -9,6 +9,8 @@ Editor-only dynamic code metadata validation pipeline: | UnityCliLoop.System.Reflection.Metadata.dll | System.Reflection.Metadata | 8.0.1 | 8.0.0.1 | lib/net462/System.Reflection.Metadata.dll | | UnityCliLoop.System.Runtime.CompilerServices.Unsafe.dll | System.Runtime.CompilerServices.Unsafe | 6.1.1 | 6.0.2.0 | lib/net462/System.Runtime.CompilerServices.Unsafe.dll | +From Unity 6000.5 on, the Editor ships the same three assemblies as BCL extensions and references them from every script assembly. The bundled copies carry the define constraint `!UNITY_6000_5_OR_NEWER`, so on those Editors they are left out of compilation and the package binds to the Editor's copies instead. + These assemblies are distributed under the MIT License. Package metadata also contains the copyright notice