diff --git a/.agents/skills/uloop-hot-reload/references/output.md b/.agents/skills/uloop-hot-reload/references/output.md
index a80f2250a2..ef63118d76 100644
--- a/.agents/skills/uloop-hot-reload/references/output.md
+++ b/.agents/skills/uloop-hot-reload/references/output.md
@@ -40,5 +40,7 @@ Returns JSON with:
- `Message` (string): Short summary. When a run carries `IntroducedTypes` rows, Message reports them: a run that only introduced or only re-bound types says so instead of reporting the methods, a refused declaration is reported as the failure of the run and points at `IntroducedTypes`, and a run the methods decided ends with `IntroducedTypes=N`. On apply runs that pulled in sibling files, Message follows `PatchedTotal` and `Added` with how many of those Patched and Added rows re-applied the siblings' earlier changes (left out when 0). On apply runs, Message counts the patched rows that carry a `LifecycleNote` in one sentence and the added Unity messages a hot-reload proxy delivers in another, both pointing at `Methods[].LifecycleNote`; forwarded `Added` rows are not in the patched count, and each sentence is left out when its count is 0. On `--status`, Message opens with how many changes are currently active — patched methods, added members, and introduced types together, which is why it can exceed `ActivePatchTotal` — and when any `Active` or `Added` row has `InvocationCount` 0 it also appends how many such rows there are and points at `Methods[].Reason`. `--revert-all` appends how many introduced types stay loaded until the next Domain Reload, and when the hold is still armed for them, that Auto Refresh stays held until `uloop compile`. When the fallback compile succeeds, the CLI sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, and removes the hold sentence (`AutoRefreshHoldMessage`) from `Message`, because the compile released the hold.
- `RecommendedNextAction` (string): Present in three cases. (1) Any method or introduced-type outcome is `Failed`. A failure that needs a change in the source keeps the fix advice: a partial apply (some methods patched or added, or some types left active) says to fix and rerun, run `uloop compile`, or `uloop hot-reload --revert-all`; a failure with nothing applied says to fix and rerun or compile. When every failure came from the Editor compiling or importing while the reload ran, it says there is nothing to fix: wait until `uloop status` no longer reports that, then rerun, or run `uloop compile` (after a partial apply, `uloop hot-reload --revert-all` is offered as well); beside failures that need a fix, the fix advice comes first and adds that those rows need only a rerun. A missing compiled assembly adds that there is nothing to fix in the source and that `uloop compile` is the step; in a Multiplayer Play Mode Virtual Player, that compile has to run against the main Editor's project, because a patch applied in the main Editor does not reach the player. (2) Every method of the requested files was `Skipped`, which still answers `Success`: it points first at the fix each Skipped row's `Methods[].Reason` names and offers `uloop compile` as the alternative. (3) `CompileFallback` is `HeldForPlayMode` or `BlockedByPlayModeSetting`, whatever the outcomes: the reason no compile ran is appended after any advice from (1) or (2), and it opens by saying to do any fix a `Reason` names that needs no compile before compiling. Omitted otherwise.
- `CompileFallback` (string, always present): whether the CLI should run a compile after this run — `NotNeeded`, `Requested`, `HeldForPlayMode` (edits stayed unapplied but the Editor is in Play Mode and `--compile-on-skip` is `auto`), `BlockedByPlayModeSetting` (`--compile-on-skip on` during Play Mode while Unity's "Script Changes While Playing" is "Recompile After Finished Playing", which refuses the compile; `RecommendedNextAction` says to stop Play Mode first), or `Disabled` (`--compile-on-skip off`). `--status`, `--revert-all` and validation failures answer `NotNeeded`. `Skipped` rows of a sibling pulled in to re-bind its active patches do not count as unapplied edits: they are not this run's edits, and their earlier patches stay active. Its `Failed` rows do count, because a failed reload reverts those patches. A sibling retried after an earlier Skip, or brought in as a companion, still counts.
+- `RetryAfterEditorReady` (bool, apply runs only): `true` when every failure of the run is the Editor compiling or importing, whether the request arrived during the compile or the compile started before the reload could be applied. The CLI then waits for the Editor to settle (up to 10 minutes) and applies the same files again in the same command, so the response you read is the second apply's. `false` on every other failure, and on `--status` and `--revert-all`.
+- `SelectedFiles` (string[], apply runs only): the project-relative asset paths of the scripts the run selected, whether given as `--files` or chosen as the changed files. The retry sends exactly these, as an explicit list, so a file the default selection would leave out is not left out on the second apply. Empty on `--status` and `--revert-all`.
- `Compile` (object, present only when the CLI ran the fallback compile): the full `uloop compile` response; the top-level `Success` is then the compile's, and the command's exit code is the compile's. A successful compile drops `RecommendedNextAction`, sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, removes the hold sentence from `Message`, and ends `Message` with a sentence saying the compile succeeded; a failed one leaves `Outcome`, `AutoRefreshHeld`, and `Message` as the reload reported them, and sets `RecommendedNextAction` to the compile's own `NextActions` when it reports any, and otherwise to fixing `Compile.Errors`.
- `CompileFallbackNote` (string, present only with `Compile`): why the compile ran and how it ended. When it succeeded, `Outcome` is `ReplacedByCompile`, `AutoRefreshHeld` is `false`, and `Message` no longer carries the hold sentence.
diff --git a/.claude/skills/uloop-hot-reload/references/output.md b/.claude/skills/uloop-hot-reload/references/output.md
index a80f2250a2..ef63118d76 100644
--- a/.claude/skills/uloop-hot-reload/references/output.md
+++ b/.claude/skills/uloop-hot-reload/references/output.md
@@ -40,5 +40,7 @@ Returns JSON with:
- `Message` (string): Short summary. When a run carries `IntroducedTypes` rows, Message reports them: a run that only introduced or only re-bound types says so instead of reporting the methods, a refused declaration is reported as the failure of the run and points at `IntroducedTypes`, and a run the methods decided ends with `IntroducedTypes=N`. On apply runs that pulled in sibling files, Message follows `PatchedTotal` and `Added` with how many of those Patched and Added rows re-applied the siblings' earlier changes (left out when 0). On apply runs, Message counts the patched rows that carry a `LifecycleNote` in one sentence and the added Unity messages a hot-reload proxy delivers in another, both pointing at `Methods[].LifecycleNote`; forwarded `Added` rows are not in the patched count, and each sentence is left out when its count is 0. On `--status`, Message opens with how many changes are currently active — patched methods, added members, and introduced types together, which is why it can exceed `ActivePatchTotal` — and when any `Active` or `Added` row has `InvocationCount` 0 it also appends how many such rows there are and points at `Methods[].Reason`. `--revert-all` appends how many introduced types stay loaded until the next Domain Reload, and when the hold is still armed for them, that Auto Refresh stays held until `uloop compile`. When the fallback compile succeeds, the CLI sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, and removes the hold sentence (`AutoRefreshHoldMessage`) from `Message`, because the compile released the hold.
- `RecommendedNextAction` (string): Present in three cases. (1) Any method or introduced-type outcome is `Failed`. A failure that needs a change in the source keeps the fix advice: a partial apply (some methods patched or added, or some types left active) says to fix and rerun, run `uloop compile`, or `uloop hot-reload --revert-all`; a failure with nothing applied says to fix and rerun or compile. When every failure came from the Editor compiling or importing while the reload ran, it says there is nothing to fix: wait until `uloop status` no longer reports that, then rerun, or run `uloop compile` (after a partial apply, `uloop hot-reload --revert-all` is offered as well); beside failures that need a fix, the fix advice comes first and adds that those rows need only a rerun. A missing compiled assembly adds that there is nothing to fix in the source and that `uloop compile` is the step; in a Multiplayer Play Mode Virtual Player, that compile has to run against the main Editor's project, because a patch applied in the main Editor does not reach the player. (2) Every method of the requested files was `Skipped`, which still answers `Success`: it points first at the fix each Skipped row's `Methods[].Reason` names and offers `uloop compile` as the alternative. (3) `CompileFallback` is `HeldForPlayMode` or `BlockedByPlayModeSetting`, whatever the outcomes: the reason no compile ran is appended after any advice from (1) or (2), and it opens by saying to do any fix a `Reason` names that needs no compile before compiling. Omitted otherwise.
- `CompileFallback` (string, always present): whether the CLI should run a compile after this run — `NotNeeded`, `Requested`, `HeldForPlayMode` (edits stayed unapplied but the Editor is in Play Mode and `--compile-on-skip` is `auto`), `BlockedByPlayModeSetting` (`--compile-on-skip on` during Play Mode while Unity's "Script Changes While Playing" is "Recompile After Finished Playing", which refuses the compile; `RecommendedNextAction` says to stop Play Mode first), or `Disabled` (`--compile-on-skip off`). `--status`, `--revert-all` and validation failures answer `NotNeeded`. `Skipped` rows of a sibling pulled in to re-bind its active patches do not count as unapplied edits: they are not this run's edits, and their earlier patches stay active. Its `Failed` rows do count, because a failed reload reverts those patches. A sibling retried after an earlier Skip, or brought in as a companion, still counts.
+- `RetryAfterEditorReady` (bool, apply runs only): `true` when every failure of the run is the Editor compiling or importing, whether the request arrived during the compile or the compile started before the reload could be applied. The CLI then waits for the Editor to settle (up to 10 minutes) and applies the same files again in the same command, so the response you read is the second apply's. `false` on every other failure, and on `--status` and `--revert-all`.
+- `SelectedFiles` (string[], apply runs only): the project-relative asset paths of the scripts the run selected, whether given as `--files` or chosen as the changed files. The retry sends exactly these, as an explicit list, so a file the default selection would leave out is not left out on the second apply. Empty on `--status` and `--revert-all`.
- `Compile` (object, present only when the CLI ran the fallback compile): the full `uloop compile` response; the top-level `Success` is then the compile's, and the command's exit code is the compile's. A successful compile drops `RecommendedNextAction`, sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, removes the hold sentence from `Message`, and ends `Message` with a sentence saying the compile succeeded; a failed one leaves `Outcome`, `AutoRefreshHeld`, and `Message` as the reload reported them, and sets `RecommendedNextAction` to the compile's own `NextActions` when it reports any, and otherwise to fixing `Compile.Errors`.
- `CompileFallbackNote` (string, present only with `Compile`): why the compile ran and how it ended. When it succeeded, `Outcome` is `ReplacedByCompile`, `AutoRefreshHeld` is `false`, and `Message` no longer carries the hold sentence.
diff --git a/Assets/Tests/Editor/HotReload/HotReloadBusyEditorResponseE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadBusyEditorResponseE2ETests.cs
index ac62061e09..bef80971f8 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadBusyEditorResponseE2ETests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadBusyEditorResponseE2ETests.cs
@@ -13,9 +13,9 @@
namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
{
///
- /// Covers the next step a response recommends when the Editor's state stops a run at the
- /// commit boundary: a state that ends on its own asks for a wait, and one that leaves errors
- /// behind asks for a fix.
+ /// Covers the next step a response recommends when the Editor's state stops a run, at the
+ /// commit boundary or when the request arrives: a state that ends on its own asks for a wait
+ /// and a retry, and one that leaves errors behind asks for a fix.
///
public class HotReloadBusyEditorResponseE2ETests
{
@@ -23,6 +23,14 @@ public class HotReloadBusyEditorResponseE2ETests
private const string CallerBodyAnchor = "return host.Value();";
+ private const string CallerProjectRelativePath = "Assets/Tests/Editor/HotReload/" + CallerFileName;
+
+ private static readonly HotReloadEditorStateSnapshot IdleEditor =
+ new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: false, scriptCompilationFailed: false);
+
+ private static readonly HotReloadEditorStateSnapshot CompilingEditor =
+ new HotReloadEditorStateSnapshot(isCompiling: true, isUpdating: false, scriptCompilationFailed: false);
+
private HotReloadDomainTestScope _scope;
[SetUp]
@@ -40,20 +48,24 @@ public void TearDown()
}
///
- /// What: a compile the Editor is running at the commit boundary refuses the run, and the
- /// response asks the reader to wait and rerun rather than to fix anything.
+ /// What: a compile the Editor starts after the request arrived refuses the run at the commit
+ /// boundary, and the response asks for a wait and a retry rather than for a fix.
///
[Test]
public async Task Run_WhenTheEditorIsCompilingAtCommit_RecommendsWaitingNotFixing()
{
+ // The first capture is the early check while the file is resolved; the second is the
+ // commit boundary, so only the boundary sees the compile.
+ int captures = 0;
HotReloadResponse response = await RunEditingTheCallerBodyAsync(
- new HotReloadEditorStateSnapshot(isCompiling: true, isUpdating: false, scriptCompilationFailed: false));
+ () => ++captures == 1 ? IdleEditor : CompilingEditor);
AssertRefusedAtTheCommitBoundary(response);
Assert.That(
response.RecommendedNextAction,
Is.EqualTo(HotReloadConstants.EditorNotReadyRecommendedNextAction));
Assert.That(response.RecommendedNextAction, Does.Not.Contain("Fix the failed declarations"));
+ Assert.That(response.RetryAfterEditorReady, Is.True);
}
///
@@ -63,46 +75,133 @@ public async Task Run_WhenTheEditorIsCompilingAtCommit_RecommendsWaitingNotFixin
[Test]
public async Task Run_WhenTheLastCompileFailedAtCommit_KeepsTheFixAdvice()
{
- HotReloadResponse response = await RunEditingTheCallerBodyAsync(
- new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: false, scriptCompilationFailed: true));
+ HotReloadEditorStateSnapshot compileFailed =
+ new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: false, scriptCompilationFailed: true);
+ HotReloadResponse response = await RunEditingTheCallerBodyAsync(() => compileFailed);
AssertRefusedAtTheCommitBoundary(response);
Assert.That(
response.RecommendedNextAction,
Is.EqualTo(HotReloadConstants.FailedWithNoApplyRecommendedNextAction));
+ Assert.That(response.RetryAfterEditorReady, Is.False);
+ }
+
+ ///
+ /// What: a compile already running when the request arrives refuses the file before the
+ /// transform, and the response asks for a retry once the Editor settles.
+ ///
+ [Test]
+ public async Task Run_WhenTheEditorIsCompilingWhenTheRequestArrives_RefusesBeforeTheTransformAndAsksForARetry()
+ {
+ HotReloadResponse response = await RunEditingTheCallerBodyAsync(() => CompilingEditor);
+
+ AssertRefusedBeforeTheTransform(response, HotReloadConstants.EditorCompilingBeforeTransformReason);
+ }
+
+ ///
+ /// What: an asset import already running when the request arrives refuses the file before
+ /// the transform, and the response asks for a retry once the Editor settles.
+ ///
+ [Test]
+ public async Task Run_WhenTheEditorIsImportingWhenTheRequestArrives_RefusesBeforeTheTransformAndAsksForARetry()
+ {
+ HotReloadEditorStateSnapshot importing =
+ new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: true, scriptCompilationFailed: false);
+ HotReloadResponse response = await RunEditingTheCallerBodyAsync(() => importing);
+
+ AssertRefusedBeforeTheTransform(response, HotReloadConstants.EditorImportingBeforeTransformReason);
+ }
+
+ ///
+ /// What: a file whose patches are already active and whose source is unchanged still reports
+ /// AlreadyActive while the Editor compiles, because it has nothing to apply.
+ ///
+ [Test]
+ public async Task Run_WhenAnUnchangedAppliedFileArrivesWhileTheEditorIsCompiling_StaysAlreadyActive()
+ {
+ string editedPath = WriteEditedCaller();
+ HotReloadStubEditorStateSnapshotCapture capture =
+ new HotReloadStubEditorStateSnapshotCapture(() => IdleEditor);
+
+ // One scope for both runs: the second run has to see the applied-source record the first
+ // run left in the same domain.
+ using (HotReloadServicesTestScope.BeginWithEditorState(capture))
+ {
+ HotReloadResponse applied = await RunAndBuildAsync(editedPath);
+ Assert.That(applied.Outcome, Is.EqualTo("Applied"), "Precondition: the first run applies. " + applied.Message);
+ Assert.That(applied.PatchedTotal, Is.GreaterThan(0), "Precondition: the first run patches. " + applied.Message);
+
+ capture.Capture = () => CompilingEditor;
+ HotReloadResponse unchanged = await RunAndBuildAsync(editedPath);
+
+ Assert.That(unchanged.Methods.Where(row => row.Kind == "Failed"), Is.Empty, unchanged.Message);
+ Assert.That(unchanged.Success, Is.True, unchanged.Message);
+ Assert.That(unchanged.Outcome, Is.EqualTo("Applied"), unchanged.Message);
+ Assert.That(unchanged.AlreadyActiveTotal, Is.GreaterThan(0), unchanged.Message);
+ Assert.That(unchanged.PatchedTotal, Is.EqualTo(0), unchanged.Message);
+ Assert.That(unchanged.RetryAfterEditorReady, Is.False);
+ }
}
- // Why a state fixed for the whole run: a run that edits an existing file reads the Editor
- // state only at the commit boundary, so the boundary is what sees this state.
+ // Why a fresh services graph per call: these runs are refused or read only one state
+ // sequence, so nothing has to carry over from one run to the next.
private static async Task RunEditingTheCallerBodyAsync(
- HotReloadEditorStateSnapshot editorState)
+ Func editorState)
{
- string callerPath = FixturePath(CallerFileName);
- string callerSource = File.ReadAllText(callerPath);
- Assert.That(callerSource, Does.Contain(CallerBodyAnchor), "Precondition: caller body anchor must exist.");
- string editedPath = HotReloadTestSourceWriter.WriteEditedSource(
- "BusyEditorResponseCaller.cs",
- callerSource.Replace(CallerBodyAnchor, "return host.Value() + 3;", StringComparison.Ordinal));
+ string editedPath = WriteEditedCaller();
using (HotReloadCompositionRoot.BeginReplacement(HotReloadCompositionRoot.CreateProductionServices()))
using (HotReloadServicesTestScope.BeginWithEditorState(
- new HotReloadStubEditorStateSnapshotCapture(() => editorState)))
+ new HotReloadStubEditorStateSnapshotCapture(editorState)))
{
- HotReloadOrchestratorResult result = await HotReloadCompositionRoot.Services.Orchestrator.RunAsync(
- new[] { callerPath },
- editedPath,
- CancellationToken.None);
- return HotReloadApplyResponseBuilder.Build(
- HotReloadCompositionRoot.Services,
- result,
- null,
- Array.Empty(),
- Array.Empty(),
- isPlaying: false,
- isPaused: false);
+ return await RunAndBuildAsync(editedPath);
}
}
+ private static string WriteEditedCaller()
+ {
+ string callerSource = File.ReadAllText(FixturePath(CallerFileName));
+ Assert.That(callerSource, Does.Contain(CallerBodyAnchor), "Precondition: caller body anchor must exist.");
+ return HotReloadTestSourceWriter.WriteEditedSource(
+ "BusyEditorResponseCaller.cs",
+ callerSource.Replace(CallerBodyAnchor, "return host.Value() + 3;", StringComparison.Ordinal));
+ }
+
+ // Runs the caller with the edited copy on the installed services and builds the response
+ // the tool would, with the caller's absolute path as the selected file.
+ private static async Task RunAndBuildAsync(string editedPath)
+ {
+ string[] files = { FixturePath(CallerFileName) };
+ HotReloadOrchestratorResult result = await HotReloadCompositionRoot.Services.Orchestrator.RunAsync(
+ files,
+ editedPath,
+ CancellationToken.None);
+ return HotReloadApplyResponseBuilder.Build(
+ HotReloadCompositionRoot.Services,
+ result,
+ null,
+ Array.Empty(),
+ Array.Empty(),
+ isPlaying: false,
+ isPaused: false,
+ selectedFiles: files);
+ }
+
+ private static void AssertRefusedBeforeTheTransform(HotReloadResponse response, string expectedReason)
+ {
+ Assert.That(response.PatchedTotal, Is.EqualTo(0), "A refused run must patch nothing. " + response.Message);
+ HotReloadMethodResult[] failedRows = response.Methods.Where(row => row.Kind == "Failed").ToArray();
+ Assert.That(failedRows, Has.Length.EqualTo(1), response.Message);
+ Assert.That(failedRows[0].Reason, Is.EqualTo(expectedReason));
+ // Never reaching the boundary is what shows the transform was skipped.
+ Assert.That(failedRows[0].Reason, Does.Not.Contain("became busy"));
+ Assert.That(response.RetryAfterEditorReady, Is.True);
+ Assert.That(
+ response.RecommendedNextAction,
+ Is.EqualTo(HotReloadConstants.EditorNotReadyRecommendedNextAction));
+ Assert.That(response.SelectedFiles, Is.EqualTo(new[] { CallerProjectRelativePath }));
+ }
+
private static void AssertRefusedAtTheCommitBoundary(HotReloadResponse response)
{
Assert.That(response.PatchedTotal, Is.EqualTo(0), "A refused run must patch nothing. " + response.Message);
diff --git a/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs
index 738ab7e97f..065e265d76 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs
@@ -51,8 +51,8 @@ public void InstalledServices_UseProductionCollaborators()
}
///
- /// What: omitting --files retains existing warnings, appends selection warnings, and prefixes
- /// the exact selection message.
+ /// What: omitting --files retains existing warnings, appends selection warnings, prefixes
+ /// the exact selection message, and names the selected files in the response.
///
[Test]
public async Task ExecuteAsync_WhenFilesAreOmittedAndChangesExist_AppliesSelectedFilesAndPrefixesMessage()
@@ -92,6 +92,7 @@ public async Task ExecuteAsync_WhenFilesAreOmittedAndChangesExist_AppliesSelecte
Assert.That(
response.Warnings,
Is.EqualTo(new[] { "orchestrator warning", "scan limit warning" }));
+ Assert.That(response.SelectedFiles, Is.EqualTo(new[] { "Assets/Selected.cs" }));
}
///
@@ -301,8 +302,9 @@ await ExecuteAsync(
}
///
- /// What: a script listed twice in --files reaches the run once, as the first raw entry, and
- /// the response message starts with the sentence saying so.
+ /// What: a script listed twice in --files reaches the run once, as the first raw entry, the
+ /// response message starts with the sentence saying so, and the response names it once as a
+ /// project-relative path.
///
[Test]
public async Task ExecuteAsync_WhenFilesListAScriptTwice_RunsItOnceAndSaysSo()
@@ -320,6 +322,7 @@ public async Task ExecuteAsync_WhenFilesListAScriptTwice_RunsItOnceAndSaysSo()
Is.EqualTo(
"--files listed 'Assets/Explicit.cs' 2 times; it was processed once. "
+ AppliedMessageTail));
+ Assert.That(response.SelectedFiles, Is.EqualTo(new[] { "Assets/Explicit.cs" }));
}
///
diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorReadyRetryTests.cs b/Assets/Tests/Editor/HotReload/HotReloadEditorReadyRetryTests.cs
new file mode 100644
index 0000000000..e04b405486
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadEditorReadyRetryTests.cs
@@ -0,0 +1,68 @@
+using NUnit.Framework;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Covers when an apply response asks the CLI to wait for the Editor to settle and apply the
+ /// same request again: only when every failure of the run is the Editor compiling or importing.
+ ///
+ [TestFixture]
+ public sealed class HotReloadEditorReadyRetryTests
+ {
+ ///
+ /// What: a run refused only because the Editor was compiling or importing asks for the retry.
+ ///
+ [Test]
+ public void Decide_WithOnlyEditorNotReady_IsTrue()
+ {
+ Assert.That(HotReloadEditorReadyRetry.Decide(HotReloadFailureKinds.EditorNotReady), Is.True);
+ }
+
+ ///
+ /// What: a failure the reader has to fix next to the busy Editor gives the same result after
+ /// a wait, so the run does not ask for the retry.
+ ///
+ [Test]
+ public void Decide_WithEditorNotReadyAndDeclaration_IsFalse()
+ {
+ Assert.That(
+ HotReloadEditorReadyRetry.Decide(
+ HotReloadFailureKinds.EditorNotReady | HotReloadFailureKinds.Declaration),
+ Is.False);
+ }
+
+ ///
+ /// What: a failure the reader has to fix alone does not ask for the retry.
+ ///
+ [Test]
+ public void Decide_WithOnlyDeclaration_IsFalse()
+ {
+ Assert.That(HotReloadEditorReadyRetry.Decide(HotReloadFailureKinds.Declaration), Is.False);
+ }
+
+ ///
+ /// What: a missing compiled assembly needs a compile, which waiting does not run, so the run
+ /// does not ask for the retry even when the Editor was busy too.
+ ///
+ [Test]
+ public void Decide_WithEditorNotReadyAndCompiledAssemblyMissing_IsFalse()
+ {
+ Assert.That(
+ HotReloadEditorReadyRetry.Decide(
+ HotReloadFailureKinds.EditorNotReady | HotReloadFailureKinds.CompiledAssemblyMissing),
+ Is.False);
+ }
+
+ ///
+ /// What: no kind at all, from a run with no failure or a failure that lost its kind, does
+ /// not ask for the retry; the second is treated as one the reader fixes.
+ ///
+ [Test]
+ public void Decide_WithNoKind_IsFalse()
+ {
+ Assert.That(HotReloadEditorReadyRetry.Decide(HotReloadFailureKinds.None), Is.False);
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorReadyRetryTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadEditorReadyRetryTests.cs.meta
new file mode 100644
index 0000000000..93bcb30cf6
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadEditorReadyRetryTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c710883bababd413ba095b8aaa737605
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorStateSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadEditorStateSnapshotTests.cs
index 3d9909ae11..1f54b61ea5 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadEditorStateSnapshotTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadEditorStateSnapshotTests.cs
@@ -84,5 +84,63 @@ public void GetNotReadyFailure_WhenReady_IsNull()
Assert.That(failure, Is.Null);
}
+
+ ///
+ /// What: a compile in progress refuses a request before the transform, with the reason that
+ /// the domain reload after the compile would discard the reload.
+ ///
+ [Test]
+ public void GetBusyFailure_WhenCompiling_IsEditorNotReady()
+ {
+ HotReloadFailureDescription failure =
+ new HotReloadEditorStateSnapshot(isCompiling: true, isUpdating: false, scriptCompilationFailed: false)
+ .GetBusyFailure();
+
+ Assert.That(failure, Is.Not.Null);
+ Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.EditorNotReady));
+ Assert.That(failure.Message, Is.EqualTo(HotReloadConstants.EditorCompilingBeforeTransformReason));
+ }
+
+ ///
+ /// What: an asset import in progress refuses a request before the transform.
+ ///
+ [Test]
+ public void GetBusyFailure_WhenImporting_IsEditorNotReady()
+ {
+ HotReloadFailureDescription failure =
+ new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: true, scriptCompilationFailed: false)
+ .GetBusyFailure();
+
+ Assert.That(failure, Is.Not.Null);
+ Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.EditorNotReady));
+ Assert.That(failure.Message, Is.EqualTo(HotReloadConstants.EditorImportingBeforeTransformReason));
+ }
+
+ ///
+ /// What: a failed last compile is not a busy Editor: the loaded assemblies are the last good
+ /// build, and waiting does not clear the errors.
+ ///
+ [Test]
+ public void GetBusyFailure_WhenOnlyTheLastCompileFailed_IsNull()
+ {
+ HotReloadFailureDescription failure =
+ new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: false, scriptCompilationFailed: true)
+ .GetBusyFailure();
+
+ Assert.That(failure, Is.Null);
+ }
+
+ ///
+ /// What: an idle Editor refuses nothing before the transform.
+ ///
+ [Test]
+ public void GetBusyFailure_WhenReady_IsNull()
+ {
+ HotReloadFailureDescription failure =
+ new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: false, scriptCompilationFailed: false)
+ .GetBusyFailure();
+
+ Assert.That(failure, Is.Null);
+ }
}
}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeActivationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeActivationTests.cs
index 8f27ef76f7..6471d1806e 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeActivationTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeActivationTests.cs
@@ -372,7 +372,7 @@ public async Task Run_SameTypeDeclaredAgainByALaterReload_ReusesTheActiveTypeWit
"A reload that declares an already introduced type must not fail.");
AssertCallerIsPatched(second);
- HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, second, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false);
+ HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, second, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { hostPath, callerPath });
Assert.That(
response.IntroducedTypes.Count,
Is.EqualTo(1),
@@ -629,7 +629,7 @@ public async Task Build_IntroducedTypeCompileFails_ReportsTheCompilerErrorWithIt
CancellationToken.None);
}
- HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false);
+ HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { hostPath });
Assert.That(response.Success, Is.False, "A refused declaration must fail the run.");
string ownerProjectRelativePath = HotReloadPatchTargetSupport.ToProjectRelativeScriptPath(
HotReloadCompositionRoot.Services.PackageRootCapture,
diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeE2ETestBase.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeE2ETestBase.cs
index a76e1e30d2..e96a7887d4 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeE2ETestBase.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeE2ETestBase.cs
@@ -119,7 +119,7 @@ private protected static Task RunReloadAsync(
private protected static HotReloadResponse BuildResponse(HotReloadOrchestratorResult result)
{
- return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false);
+ return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: Array.Empty());
}
// Why the preparation stage is the only one wrapped: the test reads the edited body back
diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs
index 0a3da50857..17a66dab1a 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs
@@ -323,7 +323,7 @@ public async Task Build_RunIntroducesNoType_EmitsEmptyTypeListAndZeroTotalOnTheW
"IntroducedTypeAbsentCaller.cs",
EditTheCallerBody(File.ReadAllText(callerPath))),
CancellationToken.None);
- HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false);
+ HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { callerPath });
Assert.That(response.IntroducedTypes.Count, Is.EqualTo(0));
Assert.That(response.ActiveIntroducedTypeTotal, Is.EqualTo(0));
@@ -350,7 +350,7 @@ private static async Task RunIntroducingATypeAndEditingABodyA
"IntroducedTypeAndBodyHost.cs",
EditTheScaledBody(InsertIntroducedType(File.ReadAllText(hostPath)))),
CancellationToken.None);
- return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false);
+ return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { hostPath });
}
private static HotReloadIntroducedTypeOutcome CreateInjectedTypeFailure()
@@ -410,7 +410,7 @@ private static async Task RunEditingOnlyABodyAsync()
"RetainedTypeOnlyCaller.cs",
EditTheCallerBody(File.ReadAllText(callerPath))),
CancellationToken.None);
- return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false);
+ return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { callerPath });
}
// The production pipeline with the preparation reporting one retained declaration and the
@@ -515,7 +515,7 @@ public async Task Build_DeclarationCannotBeIntroduced_WarnsWithoutFailingTheRun(
"IntroducedTypeNoticeHost.cs",
InsertUnintroducibleDeclaration(File.ReadAllText(hostPath))),
CancellationToken.None);
- HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false);
+ HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { hostPath });
Assert.That(
response.Success,
@@ -559,7 +559,7 @@ public async Task Build_DeclarationCannotBeIntroducedInAFileWithoutABaseline_Add
"IntroducedTypeNoticeHostWithoutBaseline.cs",
InsertUnintroducibleDeclaration(File.ReadAllText(hostPath))),
CancellationToken.None);
- HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false);
+ HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { hostPath });
Assert.That(
FindWarning(response, "requires a compile"),
@@ -762,7 +762,7 @@ private static async Task RunAgainstTheHostAsync()
"IntroducedTypeFailureHost.cs",
InsertIntroducedType(File.ReadAllText(hostPath))),
CancellationToken.None);
- return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false);
+ return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { hostPath });
}
private static int CountTypeRows(HotReloadResponse response, string kind)
@@ -819,7 +819,7 @@ private static async Task RunIntroducingOnlyATypeAsync()
FindFailureReason(result),
Is.Null,
"Precondition: a reload that only introduces a type must not fail a method.");
- return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false);
+ return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { hostPath });
}
private static string FindFailureReason(HotReloadOrchestratorResult result)
@@ -890,7 +890,8 @@ public void Build_BodyEditedDeclarationBesideAnAddedMethod_DoesNotCountTheAdditi
Array.Empty(),
Array.Empty(),
isPlaying: false,
- isPaused: false);
+ isPaused: false,
+ selectedFiles: Array.Empty());
Assert.That(
response.Message,
diff --git a/Assets/Tests/Editor/HotReload/HotReloadPauseBeforeWiringWarningTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPauseBeforeWiringWarningTests.cs
index a37079b72a..5ab34fef0e 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadPauseBeforeWiringWarningTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadPauseBeforeWiringWarningTests.cs
@@ -154,7 +154,8 @@ private static HotReloadResponse Build(
rewireFields,
unrestoredWiredValues ?? Array.Empty(),
isPlaying,
- isPaused: false);
+ isPaused: false,
+ selectedFiles: Array.Empty());
}
private static int CountPauseWarnings(HotReloadResponse response)
diff --git a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs
index a50e847b5a..83eef59828 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs
@@ -2161,7 +2161,8 @@ public void Build_RewireFields_WarnsToWireOnlyThoseFieldsAgain()
new[] { "Ns.Host.Speed" },
Array.Empty(),
isPlaying: false,
- isPaused: false);
+ isPaused: false,
+ selectedFiles: Array.Empty());
string warning = response.Warnings.FirstOrDefault(
entry => entry.Contains(RewireAfterDomainReloadWarningMarker));
@@ -2184,7 +2185,8 @@ public void Build_NoRewireField_DoesNotWarnToWireAgain()
Array.Empty(),
Array.Empty(),
isPlaying: false,
- isPaused: false);
+ isPaused: false,
+ selectedFiles: Array.Empty());
Assert.That(
response.Warnings.Any(entry => entry.Contains(RewireAfterDomainReloadWarningMarker)),
@@ -2207,7 +2209,8 @@ public void Build_RewireWarningBesideTwoOrchestratorWarnings_OmitsSingleCompileR
new[] { "Ns.Host.Speed" },
Array.Empty(),
isPlaying: false,
- isPaused: false);
+ isPaused: false,
+ selectedFiles: Array.Empty());
HotReloadResponse withoutRewire = HotReloadApplyResponseBuilder.Build(
HotReloadCompositionRoot.Services,
CreatePatchedResultWithTwoWarningsAndAddedField(),
@@ -2215,7 +2218,8 @@ public void Build_RewireWarningBesideTwoOrchestratorWarnings_OmitsSingleCompileR
Array.Empty(),
Array.Empty(),
isPlaying: false,
- isPaused: false);
+ isPaused: false,
+ selectedFiles: Array.Empty());
Assert.That(
withRewire.Message,
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs
index 00c9ffdd9c..45d9568c04 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs
@@ -20,11 +20,13 @@ public static HotReloadResponse Build(
IReadOnlyList rewireFields,
IReadOnlyList unrestoredWiredValues,
bool isPlaying,
- bool isPaused)
+ bool isPaused,
+ IReadOnlyList selectedFiles)
{
Debug.Assert(services != null, "services must not be null.");
Debug.Assert(result != null, "result must not be null.");
Debug.Assert(unrestoredWiredValues != null, "unrestoredWiredValues must not be null.");
+ Debug.Assert(selectedFiles != null, "selectedFiles must not be null.");
Func toProjectRelativeScriptPath =
path => HotReloadPatchTargetSupport.ToProjectRelativeScriptPath(
@@ -137,10 +139,27 @@ public static HotReloadResponse Build(
HotReloadIntroducedTypeResponseSection.CountIntroducedTypes(result.IntroducedTypes),
allRequestedSkipped,
failureKinds),
- Timing = ToTimingResponse(result.Timing)
+ Timing = ToTimingResponse(result.Timing),
+ RetryAfterEditorReady = HotReloadEditorReadyRetry.Decide(failureKinds),
+ SelectedFiles = ToProjectRelativePaths(selectedFiles, toProjectRelativeScriptPath)
};
}
+ // Why normalized here: --files keeps each entry as the caller typed it, so the response
+ // names the scripts the same way whether they were given or chosen as the changed files.
+ private static string[] ToProjectRelativePaths(
+ IReadOnlyList paths,
+ Func toProjectRelativeScriptPath)
+ {
+ string[] projectRelativePaths = new string[paths.Count];
+ for (int index = 0; index < paths.Count; index++)
+ {
+ projectRelativePaths[index] = toProjectRelativeScriptPath(paths[index]);
+ }
+
+ return projectRelativePaths;
+ }
+
private static HotReloadTimingResponse ToTimingResponse(HotReloadTimingBreakdown timing)
{
if (timing == null)
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorReadyRetry.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorReadyRetry.cs
new file mode 100644
index 0000000000..5ba643b267
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorReadyRetry.cs
@@ -0,0 +1,20 @@
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// Decides whether an apply response asks the CLI to wait for the Editor to settle and apply
+ /// the same request again.
+ ///
+ internal static class HotReloadEditorReadyRetry
+ {
+ ///
+ /// True only when every failure of the run is the Editor compiling or importing.
+ /// is the union over the run's failed rows, so a run with no
+ /// failure passes None. A failure of any other kind, alone or mixed in, gives the same result
+ /// after a wait, and a failure that lost its kind is treated as one the reader fixes.
+ ///
+ internal static bool Decide(HotReloadFailureKinds failureKinds)
+ {
+ return failureKinds == HotReloadFailureKinds.EditorNotReady;
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorReadyRetry.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorReadyRetry.cs.meta
new file mode 100644
index 0000000000..f67fa8983c
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorReadyRetry.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 7375da355101346d2a53b7029f82c87a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStateSnapshotCapture.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStateSnapshotCapture.cs
index a7ad7cbf9b..791828b95c 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStateSnapshotCapture.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStateSnapshotCapture.cs
@@ -48,6 +48,27 @@ internal HotReloadFailureDescription GetNotReadyFailure()
return null;
}
+
+ ///
+ /// Why a request that arrives now is refused before the transform, or null when it is not.
+ /// Only a compile or an import in progress counts: both end on their own, so the CLI waits
+ /// and applies again. A failed last compile is not busy, because the loaded assemblies are
+ /// the last good build and waiting does not clear the errors.
+ ///
+ internal HotReloadFailureDescription GetBusyFailure()
+ {
+ if (IsCompiling)
+ {
+ return HotReloadFailureDescription.EditorNotReady(HotReloadConstants.EditorCompilingBeforeTransformReason);
+ }
+
+ if (IsUpdating)
+ {
+ return HotReloadFailureDescription.EditorNotReady(HotReloadConstants.EditorImportingBeforeTransformReason);
+ }
+
+ return null;
+ }
}
///
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs
index cf914bbf86..ddb491c099 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs
@@ -161,6 +161,14 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget(
projectRelativePath));
}
+ HotReloadFailureDescription busyFailure = DescribeBusyRefusal(editorStateSnapshotCapture, unchangedDecision);
+ if (busyFailure != null)
+ {
+ outcomes.Add(HotReloadMethodOutcome.FailedBecause("(file)", busyFailure, assemblyResolvePath));
+ return HotReloadPatchTargetResolution.EarlyExit(
+ new HotReloadFileProcessResult(outcomes, warnings, 0));
+ }
+
return HotReloadPatchTargetResolution.Resolved(
projectRelativePath,
assemblyName,
@@ -171,6 +179,23 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget(
newSourceMembershipEvidence);
}
+ // Why last and not for a short-circuited file: every earlier exit (assembly resolution,
+ // missing DLL, MVID guard, new-source membership) keeps its own, more specific
+ // classification, and a file whose patches are already active and unchanged has nothing
+ // to apply, so a compile in flight does not stop it. Why before the transform: a compile
+ // that is already running ends in a domain reload that would discard this reload anyway.
+ private static HotReloadFailureDescription DescribeBusyRefusal(
+ IHotReloadEditorStateSnapshotCapture editorStateSnapshotCapture,
+ HotReloadUnchangedSourceDecision unchangedDecision)
+ {
+ if (unchangedDecision == HotReloadUnchangedSourceDecision.ShortCircuited)
+ {
+ return null;
+ }
+
+ return editorStateSnapshotCapture.CaptureCurrent().GetBusyFailure();
+ }
+
private static UnityCompilationAssembly FindCompilationAssembly(string assemblyName)
{
foreach (UnityCompilationAssembly assembly in CompilationPipeline.GetAssemblies())
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs
index 2509b40844..54cd817ecc 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs
@@ -217,6 +217,20 @@ public bool ShouldSerializeRecommendedNextAction()
public string CompileFallback { get; set; } =
HotReloadCompileFallbackDecision.NotNeeded.ToString();
+ ///
+ /// True when every failure of this apply run is the Editor compiling or importing. The CLI
+ /// then waits for the Editor to settle and applies the same request again in the same
+ /// command; false on status and revert runs and on every other failure.
+ ///
+ public bool RetryAfterEditorReady { get; set; }
+
+ ///
+ /// The project-relative asset paths of the scripts this apply run selected, whether given
+ /// as Files or chosen as the changed files; the CLI sends them as Files when it applies the
+ /// same request again. Empty on status and revert runs.
+ ///
+ public string[] SelectedFiles { get; set; } = Array.Empty();
+
///
/// Milliseconds the apply run spent per phase. Written on apply runs only; --status,
/// --revert-all, and requests refused before the run leave it out.
@@ -364,7 +378,8 @@ protected override async Task ExecuteAsync(
rewireFields,
unrestoredWiredValues,
isPlaying,
- EditorApplication.isPaused);
+ EditorApplication.isPaused,
+ selection.Files);
ApplyCompileFallbackDecision(
response,
result,
@@ -413,7 +428,7 @@ internal static HotReloadValidationFailure ValidateApplyParameters(HotReloadSche
// Why this reads the services itself: the apply path above passes the services it read
// at its own entry, and this shim exists only for callers that hold a result but not the
- // run that produced it.
+ // run that produced it, so it has no selected files to name either.
internal static HotReloadResponse BuildApplyResponse(
HotReloadOrchestratorResult result,
IReadOnlyList additionalWarnings = null)
@@ -425,7 +440,8 @@ internal static HotReloadResponse BuildApplyResponse(
Array.Empty(),
Array.Empty(),
isPlaying: false,
- isPaused: false);
+ isPaused: false,
+ selectedFiles: Array.Empty());
}
// Records the compile-fallback decision on an apply response. isPlaying and
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs
index 0b2a6d2043..e277cf713f 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs
@@ -697,6 +697,15 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE
public const string EditorNotReadyRecommendedNextAction =
"The Editor compiled or imported while this reload ran, so there is nothing to fix in the source. Wait until 'uloop status' no longer reports compiling or importing, then rerun; or run 'uloop compile' to apply every edit.";
+ // Why a sentence apart from the new-source ones: this refusal happens for any file the
+ // moment the request arrives, before the transform, and the CLI retries once the Editor
+ // settles. The ending matches the other Editor-state reasons.
+ public const string EditorCompilingBeforeTransformReason =
+ "The Editor is compiling, so the domain reload that follows would discard this reload. Wait for the compile to finish, then retry hot reload.";
+
+ public const string EditorImportingBeforeTransformReason =
+ "The Editor is importing assets, so this reload cannot be applied until the import finishes. Wait for it, then retry hot reload.";
+
public const string EditorNotReadyAfterPartialApplyRecommendedNextAction =
"Partially applied. The remaining files were not patched because the Editor compiled or imported while this reload ran: wait until 'uloop status' no longer reports that, then rerun; or run 'uloop compile' to apply every edit; or run 'uloop hot-reload --revert-all' to discard the applied patches.";
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md
index a80f2250a2..ef63118d76 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md
@@ -40,5 +40,7 @@ Returns JSON with:
- `Message` (string): Short summary. When a run carries `IntroducedTypes` rows, Message reports them: a run that only introduced or only re-bound types says so instead of reporting the methods, a refused declaration is reported as the failure of the run and points at `IntroducedTypes`, and a run the methods decided ends with `IntroducedTypes=N`. On apply runs that pulled in sibling files, Message follows `PatchedTotal` and `Added` with how many of those Patched and Added rows re-applied the siblings' earlier changes (left out when 0). On apply runs, Message counts the patched rows that carry a `LifecycleNote` in one sentence and the added Unity messages a hot-reload proxy delivers in another, both pointing at `Methods[].LifecycleNote`; forwarded `Added` rows are not in the patched count, and each sentence is left out when its count is 0. On `--status`, Message opens with how many changes are currently active — patched methods, added members, and introduced types together, which is why it can exceed `ActivePatchTotal` — and when any `Active` or `Added` row has `InvocationCount` 0 it also appends how many such rows there are and points at `Methods[].Reason`. `--revert-all` appends how many introduced types stay loaded until the next Domain Reload, and when the hold is still armed for them, that Auto Refresh stays held until `uloop compile`. When the fallback compile succeeds, the CLI sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, and removes the hold sentence (`AutoRefreshHoldMessage`) from `Message`, because the compile released the hold.
- `RecommendedNextAction` (string): Present in three cases. (1) Any method or introduced-type outcome is `Failed`. A failure that needs a change in the source keeps the fix advice: a partial apply (some methods patched or added, or some types left active) says to fix and rerun, run `uloop compile`, or `uloop hot-reload --revert-all`; a failure with nothing applied says to fix and rerun or compile. When every failure came from the Editor compiling or importing while the reload ran, it says there is nothing to fix: wait until `uloop status` no longer reports that, then rerun, or run `uloop compile` (after a partial apply, `uloop hot-reload --revert-all` is offered as well); beside failures that need a fix, the fix advice comes first and adds that those rows need only a rerun. A missing compiled assembly adds that there is nothing to fix in the source and that `uloop compile` is the step; in a Multiplayer Play Mode Virtual Player, that compile has to run against the main Editor's project, because a patch applied in the main Editor does not reach the player. (2) Every method of the requested files was `Skipped`, which still answers `Success`: it points first at the fix each Skipped row's `Methods[].Reason` names and offers `uloop compile` as the alternative. (3) `CompileFallback` is `HeldForPlayMode` or `BlockedByPlayModeSetting`, whatever the outcomes: the reason no compile ran is appended after any advice from (1) or (2), and it opens by saying to do any fix a `Reason` names that needs no compile before compiling. Omitted otherwise.
- `CompileFallback` (string, always present): whether the CLI should run a compile after this run — `NotNeeded`, `Requested`, `HeldForPlayMode` (edits stayed unapplied but the Editor is in Play Mode and `--compile-on-skip` is `auto`), `BlockedByPlayModeSetting` (`--compile-on-skip on` during Play Mode while Unity's "Script Changes While Playing" is "Recompile After Finished Playing", which refuses the compile; `RecommendedNextAction` says to stop Play Mode first), or `Disabled` (`--compile-on-skip off`). `--status`, `--revert-all` and validation failures answer `NotNeeded`. `Skipped` rows of a sibling pulled in to re-bind its active patches do not count as unapplied edits: they are not this run's edits, and their earlier patches stay active. Its `Failed` rows do count, because a failed reload reverts those patches. A sibling retried after an earlier Skip, or brought in as a companion, still counts.
+- `RetryAfterEditorReady` (bool, apply runs only): `true` when every failure of the run is the Editor compiling or importing, whether the request arrived during the compile or the compile started before the reload could be applied. The CLI then waits for the Editor to settle (up to 10 minutes) and applies the same files again in the same command, so the response you read is the second apply's. `false` on every other failure, and on `--status` and `--revert-all`.
+- `SelectedFiles` (string[], apply runs only): the project-relative asset paths of the scripts the run selected, whether given as `--files` or chosen as the changed files. The retry sends exactly these, as an explicit list, so a file the default selection would leave out is not left out on the second apply. Empty on `--status` and `--revert-all`.
- `Compile` (object, present only when the CLI ran the fallback compile): the full `uloop compile` response; the top-level `Success` is then the compile's, and the command's exit code is the compile's. A successful compile drops `RecommendedNextAction`, sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, removes the hold sentence from `Message`, and ends `Message` with a sentence saying the compile succeeded; a failed one leaves `Outcome`, `AutoRefreshHeld`, and `Message` as the reload reported them, and sets `RecommendedNextAction` to the compile's own `NextActions` when it reports any, and otherwise to fixing `Compile.Errors`.
- `CompileFallbackNote` (string, present only with `Compile`): why the compile ran and how it ended. When it succeeded, `Outcome` is `ReplacedByCompile`, `AutoRefreshHeld` is `false`, and `Message` no longer carries the hold sentence.