Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PROJECT_ROOT>/Library/VP/mppm<id> 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 <PROJECT_ROOT>/Library/VP/mppm<id> 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PROJECT_ROOT>/Library/VP/mppm<id> 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 <PROJECT_ROOT>/Library/VP/mppm<id> 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
169 changes: 169 additions & 0 deletions Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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));
}

/// <summary>
/// What: an ordinary project reads its compiled assemblies from its own Library/ScriptAssemblies.
/// </summary>
[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")));
}

/// <summary>
/// What: a Virtual Player root reads the main project's Library/ScriptAssemblies and keeps
/// its own root as ProjectRoot.
/// </summary>
[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")));
}

/// <summary>
/// What: a trailing separator on a Virtual Player root gives the same layout, and ProjectRoot
/// does not keep the separator.
/// </summary>
[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")));
}

/// <summary>
/// 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.
/// </summary>
[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")));
}

/// <summary>
/// What: a relative Virtual Player root is made absolute before it is recognized.
/// </summary>
[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")));
}

/// <summary>
/// What: a root with no parent (a drive or "/" alone) is an ordinary project and stays as given,
/// instead of throwing.
/// </summary>
[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));
}

/// <summary>
/// What: on Windows, a Virtual Player root is recognized with backslashes and with forward slashes.
/// </summary>
[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.");
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading