diff --git a/.agents/skills/uloop-compile/SKILL.md b/.agents/skills/uloop-compile/SKILL.md index 7263aadc28..537db387f7 100644 --- a/.agents/skills/uloop-compile/SKILL.md +++ b/.agents/skills/uloop-compile/SKILL.md @@ -57,7 +57,7 @@ Returns JSON: - `WarningCount`: number or null - `Warning` (string, optional): set when the compile was requested during Play Mode (the Play session is discarded), while pause points were armed in either Play Mode or Edit Mode (the domain reload drops every pause point patch; those enabled with `--persist` are counted separately and re-armed from their saved enable request after the reload), or while hot-reload changes were active (a successful compile drops every patch; the edited sources are compiled in). - `Message`: string -- `ErrorCode`: string or null. `COMPILE_ALREADY_IN_PROGRESS` when Unity is already compiling, `COMPILE_EDITOR_UPDATING` when the editor is updating, `COMPILE_RESULT_UNKNOWN` after a forced recompile that did not return a definitive result. +- `ErrorCode`: string or null. `COMPILE_ALREADY_IN_PROGRESS` / `COMPILE_EDITOR_UPDATING` when Unity was still compiling or updating after the CLI waited for it and sent the compile again (it does so twice before giving up; run `uloop compile` again), `COMPILE_RESULT_UNKNOWN` after a forced recompile that did not return a definitive result. - `NextActions`: string array or null. Corrective steps derived from the errors, e.g. the assembly that declares an unresolved namespace (CS0234), or, for CS0246 in a script under an asmdef, a reminder to check that asmdef's references (the type may instead be a typo — decide from the error). When Unity stops compiling before the finish callback (`Success: null`, indeterminate), `Message` keeps the get-logs pointer and appends `Recent Console errors:` with the last few Console errors (typically the asmdef or compiler error that aborted the compile), so fix from that list before reaching for `uloop get-logs`. 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 df26774fe1..b715e2aefe 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 @@ -47,6 +47,20 @@ uloop --project-path /Library/VP/mppm simulate-keyboard --acti uloop's one-command-at-a-time rule is per Editor, so commands to different players (or to the 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. + ## Known limitations - `Stop` sent while Virtual Players are still starting (the main Editor is not yet in Play Mode) diff --git a/.agents/skills/uloop-hot-reload/SKILL.md b/.agents/skills/uloop-hot-reload/SKILL.md index 865e43479e..78ea33a883 100644 --- a/.agents/skills/uloop-hot-reload/SKILL.md +++ b/.agents/skills/uloop-hot-reload/SKILL.md @@ -12,6 +12,8 @@ member access, static methods, return values, async methods, and iterators all w the limits below, including private access inside async, iterator, lambda, local-function, and LINQ-query bodies. Methods that cannot be patched are reported as `Skipped` or `Failed`; one unpatchable method never aborts the rest of the run. +`Outcome` says whether the edit is live (`ReplacedByCompile`: a fallback compile succeeded, so +it is compiled in and no patch is active). ## Usage @@ -106,4 +108,4 @@ All files live in `references/` beside this skill; read the one whose trigger ma - `references/pause-point-interaction.md` — how patches re-target or suppress armed pause points; one-way reachability checks. - `references/introduced-types.md` — types a reload can introduce: supported shapes, internal access, refusal wording, identity and lifetime, why a new file is never selected automatically. - `references/added-field-wiring.md` — putting a value into an added field without a compile. -- `references/output.md` — every response field: `ErrorCode`, `NextActions`, `Methods` rows, `Warnings`, totals. +- `references/output.md` — "Is my edit live?" (`Outcome`), `Timing`, every response field: `ErrorCode`, `NextActions`, `Methods` rows, `Warnings`, totals. 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 60d403d48b..c0be60faa4 100644 --- a/.agents/skills/uloop-hot-reload/references/mechanism-and-lifecycle.md +++ b/.agents/skills/uloop-hot-reload/references/mechanism-and-lifecycle.md @@ -3,7 +3,7 @@ ## How a Reload Applies 1. Resolves each file to its compiled assembly via `CompilationPipeline`, then groups the files by that assembly. Each group runs the worker once and produces one shim assembly, so the members one file adds are visible to the bodies edited in its siblings; groups are processed one after another and never affect each other. -2. Rewrites each editable method body of the group into a static shim in an out-of-process Roslyn worker. When an async, iterator, lambda, local-function, or LINQ-query body touches private/internal members, those accesses are rewritten to accessor delegates so the body can compile and run from the shim assembly (the delegation shape in step 4). +2. Rewrites each editable method body of the group into a static shim in an out-of-process Roslyn worker. When an async, iterator, lambda, local-function, or LINQ-query body touches private/internal members, those accesses are rewritten to accessor delegates so the body can compile and run from the shim assembly (the delegation shape in step 4). An `internal` member of a type the reload was not given cannot be rewritten this way, so such a body is `Skipped` (`scope-and-limits.md`). 3. Compiles the group's shims into one assembly against publicized reference copies, loads the result into the Editor domain, and binds every shim type's accessor delegates (`__BindAccessors`) before any patch is applied. 4. Patches each original method with a Harmony transpiler (ID `io.github.hatayama.uloop.hot-reload`) in one of two shapes: transplant copies the shim's IL into the original method, while delegation rewrites the original to forward its arguments to the shim, which runs as normally compiled code. @@ -24,7 +24,7 @@ patch binds to the newest shim. Edit the file and reload again to apply new chan behavior converges by construction. - Patches and loaded shim assemblies are static Editor state and disappear on the next domain reload — that includes entering Play Mode with Domain Reload enabled (the - default for projects created before Unity 6.6), `uloop compile`, and `uloop run-tests`. `uloop control-play-mode --action Play` warns with + 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. - Never reflected by hot reload: initializer changes on compiled fields and new @@ -42,6 +42,19 @@ patch binds to the newest shim. Edit the file and reload again to apply new chan before any method of that assembly is transformed, so the run applies no method body from any file of that assembly; files in other assemblies still apply. +## Running tests while patches are live + +- `uloop run-tests` compiles first by default. That compile reloads the domain and drops every live + patch; the edited source is compiled in, so the code changes stay without re-applying them, but + values wired into fields that hot reload added are not kept (they return to their initializer or + default values). The response's `CompileNote` carries the count, in the form + `1 active hot-reload change(s) were live when this compile was requested.` +- `--skip-compile` runs the tests against the live patches; added `[Test]` methods are not enumerated + that way, so compile first for those. Its `Warning` is a fixed notice that changes were live when the + run started and does not say whether they survived; read `uloop hot-reload --status` for that. While + hot reload holds Auto Refresh (the state an apply leaves), a verified run kept the patches live. +- There is no default switch: choose per call. + ## Editor-Code Iteration Without PlayMode Hot reload also patches static methods in Editor assemblies. Combined with diff --git a/.agents/skills/uloop-hot-reload/references/output.md b/.agents/skills/uloop-hot-reload/references/output.md index b40dd86a84..586599bc4e 100644 --- a/.agents/skills/uloop-hot-reload/references/output.md +++ b/.agents/skills/uloop-hot-reload/references/output.md @@ -1,18 +1,35 @@ # Hot Reload Output Fields +## Is my edit live? Read `Outcome` first + +- `Applied` — the edits of the files you asked about are live (`Patched`, `Added`, or `AlreadyActive` rows, or introduced types), and none was `Skipped`. Nothing else to do. +- `PartiallyApplied` — some of them are live, some were `Skipped`; read the `Methods[]` rows with `Kind: "Skipped"`. +- `NothingApplied` — none of them is live, and some were `Skipped`. `Warnings` (or `Methods[].Reason`) says why. +- `NothingToApply` — nothing changed against the compiled code (see `UnchangedTotal`), the files hold no method bodies, or their only rows are `Stale`. +- `Failed` — at least one `Failed` row, so `Success` is `false` — unless a fallback compile then succeeded, which turns the whole answer into `ReplacedByCompile`. +- `ReplacedByCompile` — a fallback compile ran in this same command and succeeded (`Compile` holds its response): every edit is compiled in, and `Success` is the compile's. The compile reloaded the domain, so none of this run's patches survive; the totals and `Methods[]` describe the reload that ran before the compile — read `--status` for the state after it. + +`Outcome` is written on apply runs only and judges the files you asked about; rows of a sibling file the run re-applied on its own (`ReappliedFromSibling: true`) do not change it, while `CompileFallback` may still be `Requested` for a retried sibling row. The totals beside it (`PatchedTotal`, `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal`) count every `Methods[]` row by `Kind`, siblings included. When some `Skipped` rows are sibling rows, `Message` says how many (`Skipped: 3 (2 in sibling files …)`). + +## Fields + Returns JSON with: - `Success` (boolean): `false` on parameter validation failure or when any method outcome is `Failed`, or when any `IntroducedTypes` row is `Failed`. `Skipped` outcomes alone never force `false` - `ErrorCode` (string, optional): Present on parameter validation failure. Values are `HOT_RELOAD_FILES_REQUIRED` when an omitted apply has no compile snapshots, `HOT_RELOAD_NO_CHANGED_FILES` when snapshots contain no changed `.cs` files, `HOT_RELOAD_INVALID_FILES` when `--files` contains a null or empty path, and `HOT_RELOAD_STATUS_CONFLICT` when `--status` is combined with `--files` or `--revert-all`. - `NextActions` (array, optional): Ordered recovery steps, present only with `ErrorCode` on a parameter validation failure. Omitted from every other response, including successful apply, plain `--status`, and `--revert-all` runs. -- `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` +- `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. On an `Added` row whose method carries a test attribute (`[Test]`, `[UnityTest]`, `[SetUp]`, and the like), `LifecycleNote` says the Unity Test Runner will not discover it until `uloop compile`. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` - `Warnings` (array): Non-fatal notes — one aggregated line listing the patched methods at risk of being already JIT-inlined into existing callers — those marked `[AggressiveInlining]`, plus (only when Code Optimization is Release) those with tiny pre-patch bodies — meaning the change may not show at those call sites, the pause-point interaction (see [pause-point-interaction.md](pause-point-interaction.md)), and the const drift, outside-body drift, missing-baseline, and left-out enum file entries described in [scope-and-limits.md](scope-and-limits.md). Skipped outcomes are echoed here as `Skipped : `, or as one `Skipped N methods: ()` line per reason when several share it, so checking `Warnings` alone is enough to see that an edit was not applied. When a reload re-applies unchanged files so their patches bind to this run's shim, Warnings includes `Also re-applied N unchanged file(s) with active patches in assembly '...' so their patches bind to this reload's shim: ...`. When a pulled-in sibling fails in that reload, Warnings includes `'...' was pulled in to re-bind its active patches but this reload failed for it; see its rows for which patches changed and run uloop compile to clear the run.` instead of the re-applied line. When every row of that sibling was `Skipped` and none failed, Warnings instead includes `'...' was pulled in to re-bind its active patches, but every method there was Skipped this time; see its rows for the reasons. Any earlier patches there stay active until uloop compile clears the run.` When the reload stopped before re-applying anything at all, so that sibling has no rows, Warnings instead includes `'...' was pulled in to re-bind its active patches, but this reload stopped before re-applying them, so its active patches are unchanged. Fix the refused declaration and rerun, or run uloop compile to clear the run.` When the whole reload was refused, so that sibling's only rows are `Method` = `(file)` `Failed` rows repeating the refusal and none of its unchanged patches were reverted, Warnings instead includes `'...' was pulled in to re-bind its active patches, but the whole reload was refused before re-applying them, so its active patches are unchanged; its rows repeat the refusal reason. Fix that and rerun, or run uloop compile to clear the run.` When a sibling still has active patches but its source changed since they were applied, Warnings includes `'...' has active patches but its source changed since they were applied, so it was not re-applied; pass it to hot-reload to update it.` When a patch or added member that an earlier reload applied still calls an added member that is no longer registered — a later reload changed its signature, deleted it, or skipped it while the caller did not apply again — Warnings includes `Methods that earlier hot reloads patched or added still call added members that are no longer registered: calls , .... Those calls still run the members' earlier bodies, which match neither the compiled assembly nor the source on disk. Reload until the calling methods apply again, or run 'uloop compile'.` on every reload that includes the caller's file or the member's file; see [troubleshooting.md](troubleshooting.md). When a run carries two or more warnings and all of them are hot reload warnings, the Message ends with "A single 'uloop compile' clears all of them at once when you want them gone; none of them has to be cleared before you keep working." — it is the shortest recovery, not an obligation to compile immediately. Pause-point warnings carry their own recovery steps, so that line does not appear when they are present. Nor does it appear when an `IntroducedTypes` row is `Failed` or a warning says a declared type requires a compile, because that type does not exist until one. It is also left off when any `Methods` row is `Failed`, or when a `Methods` row of a file you passed, or of a sibling retried after an earlier Skip, is `Skipped`: that body is not running yet, so it needs a fix or a compile before you keep working. A `Skipped` row of a sibling pulled in only to re-bind its active patches does not leave it off, because the earlier patches there keep running. +- `Outcome` (string, apply runs only): `Applied`, `PartiallyApplied`, `NothingApplied`, `NothingToApply`, `Failed`, or `ReplacedByCompile` — whether the edits of the files you asked about are live; see "Is my edit live?" above. Omitted on `--status`, `--revert-all`, and validation failures. - `PatchedTotal` (number): Methods patched in this run +- `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal` (number): `Methods[]` rows of this run whose `Kind` is `Skipped`, `Added`, `Failed`, `AlreadyActive`, or `Stale`, sibling rows included, as in `PatchedTotal`. `0` on `--status`, `--revert-all`, and validation failures. - `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. It is always empty on `--status` and `--revert-all` runs; the live list is the `Methods` rows with `Kind` `AddedField`, counted by `AddedFieldTotal`. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. - `AddedConsts` (array): source-level names ("Type.const") of consts this reload added. They are folded into edited bodies as literals, so they are not listed in AddedFields and do not emit the added-field lifetime warning. - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `ActivePatchTotal` (number): Active changes after this run — patched methods plus added members. Introduced types are not counted here; `ActiveIntroducedTypeTotal` reports those. `--revert-all` clears the patched methods and added members counted here and reports their combined count in `ClearedCount`; introduced types stay loaded until the next Domain Reload and remain in `ActiveIntroducedTypeTotal`. Does not include `AddedField` rows. Validation failures (`HOT_RELOAD_NO_CHANGED_FILES` and the other `ErrorCode` cases) also report the live ledger value, not the default 0. - `AutoRefreshHeld` (boolean): True while Auto Refresh is held because at least one hot-reload change is still active. The first apply that arms the hold appends a Message sentence telling the caller to run `uloop compile` to release it, and that `--revert-all` releases it only when no introduced type remains — a revert cannot unload the assembly carrying an introduced type. A release during Play adds a Warning that pending script edits import on the next focus return or `uloop compile`. If the post-release Refresh is skipped because an open dirty scene also changed on disk, Warnings include the sentence telling the caller to resolve that scene and then run `uloop compile`. `--status` and `--revert-all` report the live value. +- `AutoRefreshHoldMessage` (string, optional): The hold sentence this run appended to `Message` when it armed the hold; omitted when the run did not arm it. After a successful fallback compile, the CLI removes that sentence from `Message` and keeps this field as the record of what it removed. +- `Timing` (object, apply runs only): milliseconds this run spent per phase — `AnalysisMs` (transform worker runs, introduced-type preparation included), `ShimCompileMs` (signature gate and shim compile, isolation retries included), `PatchMs` (patch application), `OtherMs` (`TotalMs` minus the three phases: file resolution, planning, and the checks that find unchanged methods), `TotalMs` (the whole run inside the Editor). When the CLI ran the fallback compile, it adds `FallbackCompileMs`, the wall time of that compile, success or failure. Omitted on `--status` and `--revert-all`. - `AddedFieldTotal` (number): Live added-field ledger rows after this run or on `--status`, added field-like events included. Those rows appear as `Kind` `AddedField` on `--status` only; they are not counted in `ActivePatchTotal` - `DroppedByPlayModeEntryCount` (number): Remaining patched-method, added-member, and introduced-type identities discarded by the Play-entry domain reload that have not been recovered by a later apply (`Patched` / `Added` methods, `Introduced` / `AlreadyActive` types — a recovered type also recovers the patches and added members inside it), `--revert-all`, or a successful compile. Omitted when the count is 0. Re-apply `uloop hot-reload`, or edit the files and run `uloop compile` - `RestoredWiredValueCount` (number, `--status` only): Values written through the added-field wiring call that the last scene reload (entering or leaving Play Mode with domain reload disabled) gave back to the rebuilt objects. It also counts the values `--status` itself gave back by reading them for a host that is back at its place. Omitted when 0. See [added-field-wiring.md](added-field-wiring.md) @@ -20,8 +37,8 @@ Returns JSON with: - `ClearedCount` (number): Patches removed by `--revert-all`, or stale patches reverted because their source matched the compiled baseline again - `IntroducedTypes` (array): Per-type `{ Kind, TypeName, AssemblyName, FilePath, Reason }` rows for the type declarations a reload met, always present and empty when there are none. On apply runs `Kind` is `Introduced` (this reload compiled the declaration into a retained assembly and it is now loaded), `AlreadyActive` (the declaration is bound from an assembly an earlier reload retained, so this reload introduced nothing for it), or `Failed` (the declaration was refused — a redefinition of a type already active, the same type declared in more than one file of the group, or a failed artifact compilation; `Reason` says which, and a `Failed` row alone makes `Success` false). On `--status` every row is `Kind` `Active` and lists a type this domain still holds. Declarations a reload simply cannot introduce are reported as `Warnings`, not rows. These rows are never counted in `PatchedTotal`, `ActivePatchTotal`, `AddedFieldTotal`, or `ClearedCount` - `ActiveIntroducedTypeTotal` (number): Introduced types this domain holds after this run or on `--status`, counted per type rather than per compiled artifact; always present and `0` when there is none. `--revert-all` cannot unload them, so its Message says how many stay loaded until the next Domain Reload, and that Auto Refresh stays held for them until `uloop compile` -- `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` +- `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 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. (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. -- `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` and ends `Message` with a sentence saying the compile succeeded; a failed one 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. +- `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/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md index 86a71ae1f0..be4772af36 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -9,6 +9,8 @@ implementations) are never scanned: **edits** to them produce **no per-method entry at all** and are silently not applied — use `uloop compile` for those. Adding a constructor, operator, or explicit event accessor is reported as `Skipped` as well, same as an edit to an existing one. +Methods and property getters of `partial` types are patched like any other; the +other parts of the type are read from the assembly's source files. ## Added methods and fields @@ -175,7 +177,10 @@ baseline is available (next paragraph), other outside-body drift — existing-fi initializers, attributes, and other declaration edits — is reported as a `Warnings` entry as well (handled added members and reported removed members are excluded from this generic warning); without a baseline it stays silent. Either way, use -`uloop compile` for such edits. +`uloop compile` for such edits. The comparison is against the source the loaded +assembly was compiled from, so an earlier edit outside method bodies keeps this +warning on every reload until `uloop compile`. Comment-only edits (line, block, and +XML documentation comments) do not count. ## Signature changes: return type, rename, parameters @@ -396,7 +401,12 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | Condition | Why | |-----------|-----| -| Method on a `partial` type (including a type nested inside a partial outer type) | A single file cannot provide a complete semantic model | +| Method on a `partial` type when another part of the type changed since the last compile and was not passed, or when a file that names the type has syntax errors (passed or not) | Hot reload binds against the compiled type; pass that file with `--files` too, or run `uloop compile`. For a file with syntax errors, fix it and run hot reload again | +| Method on a `partial` type when the other parts could not be checked against the last compile (no source snapshot yet, or more than 50 changed files in the assembly) | Run `uloop compile` | +| Method on a `partial` type whose body names a member no source file of the assembly declares | A part generated at compile time (a source generator's output) is not visible to hot reload; run `uloop compile` | +| Method or getter whose body uses an `internal` member of a type the reload was not given, by its bare name, inside a lambda, local function, query, iterator or async method, in a body where a lambda, local function or query works with a value hot reload could not resolve (such as the member's result), or in a body patched through a delegating shim | Hot reload reaches such a member only in the method's own statements, written with its receiver (`this.Name`, `Type.Name`, `value.Name`); qualify a bare name, or run `uloop compile` | +| Method or getter on a `partial` type whose body passes an `internal` method of such a type as a delegate, uses an `internal` event of it, or names an `internal` member of it in an object initializer or a property pattern | On a `partial` type hot reload patches such a member only as a field, a property or a method call written with its receiver; run `uloop compile` | +| Method or getter on a `partial` type whose body uses an `internal` member of a type in another assembly (through `InternalsVisibleTo`) | Reported as a name no source file of the `partial` type declares; hot reload does not patch this use from a `partial` type yet. Run `uloop compile` | | Method on a struct (value type) | Value-type patching is out of scope | | Generic method, or method on a generic type | Harmony cannot safely patch open generics | | Explicit interface implementation | Dotted metadata names cannot be expressed as shim identifiers | @@ -420,6 +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) | | Loaded assembly differs from the one on disk (pending compile) | Run `uloop compile` first, then retry | | Source file fails to parse | Per-file `Failed` entry with `Method` = `(file)` carrying the parse errors; nothing from that file is applied, its earlier patches stay active, and `Success` is false | | Method signature not found in the loaded assembly | Usually a stale assembly; run `uloop compile`. In-file renames and signature changes are classified as added members before reaching this point | diff --git a/.agents/skills/uloop-run-tests/SKILL.md b/.agents/skills/uloop-run-tests/SKILL.md index 2b20b507cd..08e4579b22 100644 --- a/.agents/skills/uloop-run-tests/SKILL.md +++ b/.agents/skills/uloop-run-tests/SKILL.md @@ -8,13 +8,13 @@ description: "Run Unity Test Runner and report detailed results. Use for EditMod Execute Unity Test Runner. When a test or suite fails or a test ends inconclusive, NUnit XML results with failure messages, stack traces, and inconclusive reasons are automatically saved. Read the XML file at `XmlPath` for detailed diagnosis. -`uloop run-tests` automatically compiles pending script changes before running tests. Pass `--skip-compile` only while validating active hot-reload patches, because the compile clears those patches; otherwise let the default compile surface errors and run against current scripts. `--skip-compile` skips only the CLI-side compile: Unity still imports script edits saved since the last compile, and that import reloads the domain as soon as the run releases its assembly lock, discarding active patches and ending the request. +`uloop run-tests` automatically compiles pending script changes before running tests. Pass `--skip-compile` only while validating active hot-reload patches, because the compile clears those patches; otherwise let the default compile surface errors and run against current scripts. `--skip-compile` skips only the CLI-side compile: script edits saved since the last compile can still be imported during the run; such an import reloads the domain as soon as the run releases its assembly lock, discarding active patches and ending the request. While hot reload holds Auto Refresh (the state an apply leaves), a verified run kept the patches live. Check `uloop hot-reload --status` after the run instead of assuming either outcome. Before executing tests, `uloop run-tests` handles unsaved loaded Scene and Prefab Stage changes according to `--unsaved-changes` (default `save`): `save` writes them first, `fail` stops if any remain, and `discard` reloads disk state so tests run against saved files. Untitled scenes cannot be discarded and fail. If the chosen mode cannot proceed, it returns `Success: false`, keeps `TestCount` at `0`, lists the items in `Message`, and does not start the Unity Test Runner. Active pause points are automatically cleared (the underlying code patches are removed as well) before test execution begins. Cleared IDs are reported in the response's `ClearedPausePointIds` field. -A test run can end by discarding active hot-reload changes: script edits imported during the run are compiled when the test runner releases its assembly-reload lock, and that deferred domain reload wipes the patches even though the tests themselves ran patched. The response's Warning field reports this; re-apply 'uloop hot-reload' or bake the edits in with 'uloop compile' before the next Play or test run. +A test run can end by discarding active hot-reload changes: script edits imported during the run are compiled when the test runner releases its assembly-reload lock, and that deferred domain reload wipes the patches even though the tests themselves ran patched. The response's Warning field appears whenever changes were live when the run started and does not say whether they survived; check 'uloop hot-reload --status', then re-apply 'uloop hot-reload' or bake the edits in with 'uloop compile' before the next Play or test run. `NoTestsFound` means zero tests matched — not a test failure. Check `NoTestsFoundExplanation` and `Message` for asmdef hints. When an unfiltered run finds no tests and the project has no test assembly for the TestMode, `ProposedTestAsmdef` carries a ready-to-write `.asmdef`: save `Content` at `AssetPath`, move the test scripts under that folder, then compile and rerun. @@ -34,7 +34,7 @@ uloop run-tests [options] | `--filter-type` | string | `all` | Filter type: `all`, `exact`, `regex`, `assembly`, `class` | | `--filter-value` | string | - | Filter value (test name, pattern, assembly, or class name) | | `--unsaved-changes` | string | `save` | `save` writes unsaved Scene/Prefab Stage changes; `fail` stops if any remain; `discard` reloads disk state (Untitled scenes fail) | -| `--skip-compile` | flag | - | Skip the CLI-side compile before running tests; use only while validating active hot-reload patches. Unity still imports script edits saved since the last compile. | +| `--skip-compile` | flag | - | Skip the CLI-side compile before running tests; use only while validating active hot-reload patches. Script edits saved since the last compile can still be imported during the run. | | `--timeout-seconds` | integer | `600` | Maximum seconds to wait for RunFinished before canceling the await (max `1500`). Increase for long suites; on timeout the Test Runner may still be running until stop handling lands | | `--respect-enter-play-mode-settings` | flag | - | PlayMode only: keep the project's Enter Play Mode settings instead of forcing Domain Reload off. A Domain Reload during the run is survived; the result is recovered after the reload. Use for projects whose libraries require a Domain Reload on Play entry. | | `--rerun-failed` | flag | - | Rerun only the tests that failed or were inconclusive in the most recent completed run of the same --test-mode (whole fixtures for a failed OneTimeSetUp/OneTimeTearDown). Cannot be combined with --filter-type or --filter-value | diff --git a/.claude/skills/uloop-compile/SKILL.md b/.claude/skills/uloop-compile/SKILL.md index 7263aadc28..537db387f7 100644 --- a/.claude/skills/uloop-compile/SKILL.md +++ b/.claude/skills/uloop-compile/SKILL.md @@ -57,7 +57,7 @@ Returns JSON: - `WarningCount`: number or null - `Warning` (string, optional): set when the compile was requested during Play Mode (the Play session is discarded), while pause points were armed in either Play Mode or Edit Mode (the domain reload drops every pause point patch; those enabled with `--persist` are counted separately and re-armed from their saved enable request after the reload), or while hot-reload changes were active (a successful compile drops every patch; the edited sources are compiled in). - `Message`: string -- `ErrorCode`: string or null. `COMPILE_ALREADY_IN_PROGRESS` when Unity is already compiling, `COMPILE_EDITOR_UPDATING` when the editor is updating, `COMPILE_RESULT_UNKNOWN` after a forced recompile that did not return a definitive result. +- `ErrorCode`: string or null. `COMPILE_ALREADY_IN_PROGRESS` / `COMPILE_EDITOR_UPDATING` when Unity was still compiling or updating after the CLI waited for it and sent the compile again (it does so twice before giving up; run `uloop compile` again), `COMPILE_RESULT_UNKNOWN` after a forced recompile that did not return a definitive result. - `NextActions`: string array or null. Corrective steps derived from the errors, e.g. the assembly that declares an unresolved namespace (CS0234), or, for CS0246 in a script under an asmdef, a reminder to check that asmdef's references (the type may instead be a typo — decide from the error). When Unity stops compiling before the finish callback (`Success: null`, indeterminate), `Message` keeps the get-logs pointer and appends `Recent Console errors:` with the last few Console errors (typically the asmdef or compiler error that aborted the compile), so fix from that list before reaching for `uloop get-logs`. 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 df26774fe1..b715e2aefe 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 @@ -47,6 +47,20 @@ uloop --project-path /Library/VP/mppm simulate-keyboard --acti uloop's one-command-at-a-time rule is per Editor, so commands to different players (or to the 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. + ## 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-hot-reload/SKILL.md b/.claude/skills/uloop-hot-reload/SKILL.md index 865e43479e..78ea33a883 100644 --- a/.claude/skills/uloop-hot-reload/SKILL.md +++ b/.claude/skills/uloop-hot-reload/SKILL.md @@ -12,6 +12,8 @@ member access, static methods, return values, async methods, and iterators all w the limits below, including private access inside async, iterator, lambda, local-function, and LINQ-query bodies. Methods that cannot be patched are reported as `Skipped` or `Failed`; one unpatchable method never aborts the rest of the run. +`Outcome` says whether the edit is live (`ReplacedByCompile`: a fallback compile succeeded, so +it is compiled in and no patch is active). ## Usage @@ -106,4 +108,4 @@ All files live in `references/` beside this skill; read the one whose trigger ma - `references/pause-point-interaction.md` — how patches re-target or suppress armed pause points; one-way reachability checks. - `references/introduced-types.md` — types a reload can introduce: supported shapes, internal access, refusal wording, identity and lifetime, why a new file is never selected automatically. - `references/added-field-wiring.md` — putting a value into an added field without a compile. -- `references/output.md` — every response field: `ErrorCode`, `NextActions`, `Methods` rows, `Warnings`, totals. +- `references/output.md` — "Is my edit live?" (`Outcome`), `Timing`, every response field: `ErrorCode`, `NextActions`, `Methods` rows, `Warnings`, totals. 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 60d403d48b..c0be60faa4 100644 --- a/.claude/skills/uloop-hot-reload/references/mechanism-and-lifecycle.md +++ b/.claude/skills/uloop-hot-reload/references/mechanism-and-lifecycle.md @@ -3,7 +3,7 @@ ## How a Reload Applies 1. Resolves each file to its compiled assembly via `CompilationPipeline`, then groups the files by that assembly. Each group runs the worker once and produces one shim assembly, so the members one file adds are visible to the bodies edited in its siblings; groups are processed one after another and never affect each other. -2. Rewrites each editable method body of the group into a static shim in an out-of-process Roslyn worker. When an async, iterator, lambda, local-function, or LINQ-query body touches private/internal members, those accesses are rewritten to accessor delegates so the body can compile and run from the shim assembly (the delegation shape in step 4). +2. Rewrites each editable method body of the group into a static shim in an out-of-process Roslyn worker. When an async, iterator, lambda, local-function, or LINQ-query body touches private/internal members, those accesses are rewritten to accessor delegates so the body can compile and run from the shim assembly (the delegation shape in step 4). An `internal` member of a type the reload was not given cannot be rewritten this way, so such a body is `Skipped` (`scope-and-limits.md`). 3. Compiles the group's shims into one assembly against publicized reference copies, loads the result into the Editor domain, and binds every shim type's accessor delegates (`__BindAccessors`) before any patch is applied. 4. Patches each original method with a Harmony transpiler (ID `io.github.hatayama.uloop.hot-reload`) in one of two shapes: transplant copies the shim's IL into the original method, while delegation rewrites the original to forward its arguments to the shim, which runs as normally compiled code. @@ -24,7 +24,7 @@ patch binds to the newest shim. Edit the file and reload again to apply new chan behavior converges by construction. - Patches and loaded shim assemblies are static Editor state and disappear on the next domain reload — that includes entering Play Mode with Domain Reload enabled (the - default for projects created before Unity 6.6), `uloop compile`, and `uloop run-tests`. `uloop control-play-mode --action Play` warns with + 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. - Never reflected by hot reload: initializer changes on compiled fields and new @@ -42,6 +42,19 @@ patch binds to the newest shim. Edit the file and reload again to apply new chan before any method of that assembly is transformed, so the run applies no method body from any file of that assembly; files in other assemblies still apply. +## Running tests while patches are live + +- `uloop run-tests` compiles first by default. That compile reloads the domain and drops every live + patch; the edited source is compiled in, so the code changes stay without re-applying them, but + values wired into fields that hot reload added are not kept (they return to their initializer or + default values). The response's `CompileNote` carries the count, in the form + `1 active hot-reload change(s) were live when this compile was requested.` +- `--skip-compile` runs the tests against the live patches; added `[Test]` methods are not enumerated + that way, so compile first for those. Its `Warning` is a fixed notice that changes were live when the + run started and does not say whether they survived; read `uloop hot-reload --status` for that. While + hot reload holds Auto Refresh (the state an apply leaves), a verified run kept the patches live. +- There is no default switch: choose per call. + ## Editor-Code Iteration Without PlayMode Hot reload also patches static methods in Editor assemblies. Combined with diff --git a/.claude/skills/uloop-hot-reload/references/output.md b/.claude/skills/uloop-hot-reload/references/output.md index b40dd86a84..586599bc4e 100644 --- a/.claude/skills/uloop-hot-reload/references/output.md +++ b/.claude/skills/uloop-hot-reload/references/output.md @@ -1,18 +1,35 @@ # Hot Reload Output Fields +## Is my edit live? Read `Outcome` first + +- `Applied` — the edits of the files you asked about are live (`Patched`, `Added`, or `AlreadyActive` rows, or introduced types), and none was `Skipped`. Nothing else to do. +- `PartiallyApplied` — some of them are live, some were `Skipped`; read the `Methods[]` rows with `Kind: "Skipped"`. +- `NothingApplied` — none of them is live, and some were `Skipped`. `Warnings` (or `Methods[].Reason`) says why. +- `NothingToApply` — nothing changed against the compiled code (see `UnchangedTotal`), the files hold no method bodies, or their only rows are `Stale`. +- `Failed` — at least one `Failed` row, so `Success` is `false` — unless a fallback compile then succeeded, which turns the whole answer into `ReplacedByCompile`. +- `ReplacedByCompile` — a fallback compile ran in this same command and succeeded (`Compile` holds its response): every edit is compiled in, and `Success` is the compile's. The compile reloaded the domain, so none of this run's patches survive; the totals and `Methods[]` describe the reload that ran before the compile — read `--status` for the state after it. + +`Outcome` is written on apply runs only and judges the files you asked about; rows of a sibling file the run re-applied on its own (`ReappliedFromSibling: true`) do not change it, while `CompileFallback` may still be `Requested` for a retried sibling row. The totals beside it (`PatchedTotal`, `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal`) count every `Methods[]` row by `Kind`, siblings included. When some `Skipped` rows are sibling rows, `Message` says how many (`Skipped: 3 (2 in sibling files …)`). + +## Fields + Returns JSON with: - `Success` (boolean): `false` on parameter validation failure or when any method outcome is `Failed`, or when any `IntroducedTypes` row is `Failed`. `Skipped` outcomes alone never force `false` - `ErrorCode` (string, optional): Present on parameter validation failure. Values are `HOT_RELOAD_FILES_REQUIRED` when an omitted apply has no compile snapshots, `HOT_RELOAD_NO_CHANGED_FILES` when snapshots contain no changed `.cs` files, `HOT_RELOAD_INVALID_FILES` when `--files` contains a null or empty path, and `HOT_RELOAD_STATUS_CONFLICT` when `--status` is combined with `--files` or `--revert-all`. - `NextActions` (array, optional): Ordered recovery steps, present only with `ErrorCode` on a parameter validation failure. Omitted from every other response, including successful apply, plain `--status`, and `--revert-all` runs. -- `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` +- `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. On an `Added` row whose method carries a test attribute (`[Test]`, `[UnityTest]`, `[SetUp]`, and the like), `LifecycleNote` says the Unity Test Runner will not discover it until `uloop compile`. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` - `Warnings` (array): Non-fatal notes — one aggregated line listing the patched methods at risk of being already JIT-inlined into existing callers — those marked `[AggressiveInlining]`, plus (only when Code Optimization is Release) those with tiny pre-patch bodies — meaning the change may not show at those call sites, the pause-point interaction (see [pause-point-interaction.md](pause-point-interaction.md)), and the const drift, outside-body drift, missing-baseline, and left-out enum file entries described in [scope-and-limits.md](scope-and-limits.md). Skipped outcomes are echoed here as `Skipped : `, or as one `Skipped N methods: ()` line per reason when several share it, so checking `Warnings` alone is enough to see that an edit was not applied. When a reload re-applies unchanged files so their patches bind to this run's shim, Warnings includes `Also re-applied N unchanged file(s) with active patches in assembly '...' so their patches bind to this reload's shim: ...`. When a pulled-in sibling fails in that reload, Warnings includes `'...' was pulled in to re-bind its active patches but this reload failed for it; see its rows for which patches changed and run uloop compile to clear the run.` instead of the re-applied line. When every row of that sibling was `Skipped` and none failed, Warnings instead includes `'...' was pulled in to re-bind its active patches, but every method there was Skipped this time; see its rows for the reasons. Any earlier patches there stay active until uloop compile clears the run.` When the reload stopped before re-applying anything at all, so that sibling has no rows, Warnings instead includes `'...' was pulled in to re-bind its active patches, but this reload stopped before re-applying them, so its active patches are unchanged. Fix the refused declaration and rerun, or run uloop compile to clear the run.` When the whole reload was refused, so that sibling's only rows are `Method` = `(file)` `Failed` rows repeating the refusal and none of its unchanged patches were reverted, Warnings instead includes `'...' was pulled in to re-bind its active patches, but the whole reload was refused before re-applying them, so its active patches are unchanged; its rows repeat the refusal reason. Fix that and rerun, or run uloop compile to clear the run.` When a sibling still has active patches but its source changed since they were applied, Warnings includes `'...' has active patches but its source changed since they were applied, so it was not re-applied; pass it to hot-reload to update it.` When a patch or added member that an earlier reload applied still calls an added member that is no longer registered — a later reload changed its signature, deleted it, or skipped it while the caller did not apply again — Warnings includes `Methods that earlier hot reloads patched or added still call added members that are no longer registered: calls , .... Those calls still run the members' earlier bodies, which match neither the compiled assembly nor the source on disk. Reload until the calling methods apply again, or run 'uloop compile'.` on every reload that includes the caller's file or the member's file; see [troubleshooting.md](troubleshooting.md). When a run carries two or more warnings and all of them are hot reload warnings, the Message ends with "A single 'uloop compile' clears all of them at once when you want them gone; none of them has to be cleared before you keep working." — it is the shortest recovery, not an obligation to compile immediately. Pause-point warnings carry their own recovery steps, so that line does not appear when they are present. Nor does it appear when an `IntroducedTypes` row is `Failed` or a warning says a declared type requires a compile, because that type does not exist until one. It is also left off when any `Methods` row is `Failed`, or when a `Methods` row of a file you passed, or of a sibling retried after an earlier Skip, is `Skipped`: that body is not running yet, so it needs a fix or a compile before you keep working. A `Skipped` row of a sibling pulled in only to re-bind its active patches does not leave it off, because the earlier patches there keep running. +- `Outcome` (string, apply runs only): `Applied`, `PartiallyApplied`, `NothingApplied`, `NothingToApply`, `Failed`, or `ReplacedByCompile` — whether the edits of the files you asked about are live; see "Is my edit live?" above. Omitted on `--status`, `--revert-all`, and validation failures. - `PatchedTotal` (number): Methods patched in this run +- `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal` (number): `Methods[]` rows of this run whose `Kind` is `Skipped`, `Added`, `Failed`, `AlreadyActive`, or `Stale`, sibling rows included, as in `PatchedTotal`. `0` on `--status`, `--revert-all`, and validation failures. - `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. It is always empty on `--status` and `--revert-all` runs; the live list is the `Methods` rows with `Kind` `AddedField`, counted by `AddedFieldTotal`. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. - `AddedConsts` (array): source-level names ("Type.const") of consts this reload added. They are folded into edited bodies as literals, so they are not listed in AddedFields and do not emit the added-field lifetime warning. - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `ActivePatchTotal` (number): Active changes after this run — patched methods plus added members. Introduced types are not counted here; `ActiveIntroducedTypeTotal` reports those. `--revert-all` clears the patched methods and added members counted here and reports their combined count in `ClearedCount`; introduced types stay loaded until the next Domain Reload and remain in `ActiveIntroducedTypeTotal`. Does not include `AddedField` rows. Validation failures (`HOT_RELOAD_NO_CHANGED_FILES` and the other `ErrorCode` cases) also report the live ledger value, not the default 0. - `AutoRefreshHeld` (boolean): True while Auto Refresh is held because at least one hot-reload change is still active. The first apply that arms the hold appends a Message sentence telling the caller to run `uloop compile` to release it, and that `--revert-all` releases it only when no introduced type remains — a revert cannot unload the assembly carrying an introduced type. A release during Play adds a Warning that pending script edits import on the next focus return or `uloop compile`. If the post-release Refresh is skipped because an open dirty scene also changed on disk, Warnings include the sentence telling the caller to resolve that scene and then run `uloop compile`. `--status` and `--revert-all` report the live value. +- `AutoRefreshHoldMessage` (string, optional): The hold sentence this run appended to `Message` when it armed the hold; omitted when the run did not arm it. After a successful fallback compile, the CLI removes that sentence from `Message` and keeps this field as the record of what it removed. +- `Timing` (object, apply runs only): milliseconds this run spent per phase — `AnalysisMs` (transform worker runs, introduced-type preparation included), `ShimCompileMs` (signature gate and shim compile, isolation retries included), `PatchMs` (patch application), `OtherMs` (`TotalMs` minus the three phases: file resolution, planning, and the checks that find unchanged methods), `TotalMs` (the whole run inside the Editor). When the CLI ran the fallback compile, it adds `FallbackCompileMs`, the wall time of that compile, success or failure. Omitted on `--status` and `--revert-all`. - `AddedFieldTotal` (number): Live added-field ledger rows after this run or on `--status`, added field-like events included. Those rows appear as `Kind` `AddedField` on `--status` only; they are not counted in `ActivePatchTotal` - `DroppedByPlayModeEntryCount` (number): Remaining patched-method, added-member, and introduced-type identities discarded by the Play-entry domain reload that have not been recovered by a later apply (`Patched` / `Added` methods, `Introduced` / `AlreadyActive` types — a recovered type also recovers the patches and added members inside it), `--revert-all`, or a successful compile. Omitted when the count is 0. Re-apply `uloop hot-reload`, or edit the files and run `uloop compile` - `RestoredWiredValueCount` (number, `--status` only): Values written through the added-field wiring call that the last scene reload (entering or leaving Play Mode with domain reload disabled) gave back to the rebuilt objects. It also counts the values `--status` itself gave back by reading them for a host that is back at its place. Omitted when 0. See [added-field-wiring.md](added-field-wiring.md) @@ -20,8 +37,8 @@ Returns JSON with: - `ClearedCount` (number): Patches removed by `--revert-all`, or stale patches reverted because their source matched the compiled baseline again - `IntroducedTypes` (array): Per-type `{ Kind, TypeName, AssemblyName, FilePath, Reason }` rows for the type declarations a reload met, always present and empty when there are none. On apply runs `Kind` is `Introduced` (this reload compiled the declaration into a retained assembly and it is now loaded), `AlreadyActive` (the declaration is bound from an assembly an earlier reload retained, so this reload introduced nothing for it), or `Failed` (the declaration was refused — a redefinition of a type already active, the same type declared in more than one file of the group, or a failed artifact compilation; `Reason` says which, and a `Failed` row alone makes `Success` false). On `--status` every row is `Kind` `Active` and lists a type this domain still holds. Declarations a reload simply cannot introduce are reported as `Warnings`, not rows. These rows are never counted in `PatchedTotal`, `ActivePatchTotal`, `AddedFieldTotal`, or `ClearedCount` - `ActiveIntroducedTypeTotal` (number): Introduced types this domain holds after this run or on `--status`, counted per type rather than per compiled artifact; always present and `0` when there is none. `--revert-all` cannot unload them, so its Message says how many stay loaded until the next Domain Reload, and that Auto Refresh stays held for them until `uloop compile` -- `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` +- `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 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. (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. -- `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` and ends `Message` with a sentence saying the compile succeeded; a failed one 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. +- `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/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md index 86a71ae1f0..be4772af36 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -9,6 +9,8 @@ implementations) are never scanned: **edits** to them produce **no per-method entry at all** and are silently not applied — use `uloop compile` for those. Adding a constructor, operator, or explicit event accessor is reported as `Skipped` as well, same as an edit to an existing one. +Methods and property getters of `partial` types are patched like any other; the +other parts of the type are read from the assembly's source files. ## Added methods and fields @@ -175,7 +177,10 @@ baseline is available (next paragraph), other outside-body drift — existing-fi initializers, attributes, and other declaration edits — is reported as a `Warnings` entry as well (handled added members and reported removed members are excluded from this generic warning); without a baseline it stays silent. Either way, use -`uloop compile` for such edits. +`uloop compile` for such edits. The comparison is against the source the loaded +assembly was compiled from, so an earlier edit outside method bodies keeps this +warning on every reload until `uloop compile`. Comment-only edits (line, block, and +XML documentation comments) do not count. ## Signature changes: return type, rename, parameters @@ -396,7 +401,12 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | Condition | Why | |-----------|-----| -| Method on a `partial` type (including a type nested inside a partial outer type) | A single file cannot provide a complete semantic model | +| Method on a `partial` type when another part of the type changed since the last compile and was not passed, or when a file that names the type has syntax errors (passed or not) | Hot reload binds against the compiled type; pass that file with `--files` too, or run `uloop compile`. For a file with syntax errors, fix it and run hot reload again | +| Method on a `partial` type when the other parts could not be checked against the last compile (no source snapshot yet, or more than 50 changed files in the assembly) | Run `uloop compile` | +| Method on a `partial` type whose body names a member no source file of the assembly declares | A part generated at compile time (a source generator's output) is not visible to hot reload; run `uloop compile` | +| Method or getter whose body uses an `internal` member of a type the reload was not given, by its bare name, inside a lambda, local function, query, iterator or async method, in a body where a lambda, local function or query works with a value hot reload could not resolve (such as the member's result), or in a body patched through a delegating shim | Hot reload reaches such a member only in the method's own statements, written with its receiver (`this.Name`, `Type.Name`, `value.Name`); qualify a bare name, or run `uloop compile` | +| Method or getter on a `partial` type whose body passes an `internal` method of such a type as a delegate, uses an `internal` event of it, or names an `internal` member of it in an object initializer or a property pattern | On a `partial` type hot reload patches such a member only as a field, a property or a method call written with its receiver; run `uloop compile` | +| Method or getter on a `partial` type whose body uses an `internal` member of a type in another assembly (through `InternalsVisibleTo`) | Reported as a name no source file of the `partial` type declares; hot reload does not patch this use from a `partial` type yet. Run `uloop compile` | | Method on a struct (value type) | Value-type patching is out of scope | | Generic method, or method on a generic type | Harmony cannot safely patch open generics | | Explicit interface implementation | Dotted metadata names cannot be expressed as shim identifiers | @@ -420,6 +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) | | Loaded assembly differs from the one on disk (pending compile) | Run `uloop compile` first, then retry | | Source file fails to parse | Per-file `Failed` entry with `Method` = `(file)` carrying the parse errors; nothing from that file is applied, its earlier patches stay active, and `Success` is false | | Method signature not found in the loaded assembly | Usually a stale assembly; run `uloop compile`. In-file renames and signature changes are classified as added members before reaching this point | diff --git a/.claude/skills/uloop-run-tests/SKILL.md b/.claude/skills/uloop-run-tests/SKILL.md index 2b20b507cd..08e4579b22 100644 --- a/.claude/skills/uloop-run-tests/SKILL.md +++ b/.claude/skills/uloop-run-tests/SKILL.md @@ -8,13 +8,13 @@ description: "Run Unity Test Runner and report detailed results. Use for EditMod Execute Unity Test Runner. When a test or suite fails or a test ends inconclusive, NUnit XML results with failure messages, stack traces, and inconclusive reasons are automatically saved. Read the XML file at `XmlPath` for detailed diagnosis. -`uloop run-tests` automatically compiles pending script changes before running tests. Pass `--skip-compile` only while validating active hot-reload patches, because the compile clears those patches; otherwise let the default compile surface errors and run against current scripts. `--skip-compile` skips only the CLI-side compile: Unity still imports script edits saved since the last compile, and that import reloads the domain as soon as the run releases its assembly lock, discarding active patches and ending the request. +`uloop run-tests` automatically compiles pending script changes before running tests. Pass `--skip-compile` only while validating active hot-reload patches, because the compile clears those patches; otherwise let the default compile surface errors and run against current scripts. `--skip-compile` skips only the CLI-side compile: script edits saved since the last compile can still be imported during the run; such an import reloads the domain as soon as the run releases its assembly lock, discarding active patches and ending the request. While hot reload holds Auto Refresh (the state an apply leaves), a verified run kept the patches live. Check `uloop hot-reload --status` after the run instead of assuming either outcome. Before executing tests, `uloop run-tests` handles unsaved loaded Scene and Prefab Stage changes according to `--unsaved-changes` (default `save`): `save` writes them first, `fail` stops if any remain, and `discard` reloads disk state so tests run against saved files. Untitled scenes cannot be discarded and fail. If the chosen mode cannot proceed, it returns `Success: false`, keeps `TestCount` at `0`, lists the items in `Message`, and does not start the Unity Test Runner. Active pause points are automatically cleared (the underlying code patches are removed as well) before test execution begins. Cleared IDs are reported in the response's `ClearedPausePointIds` field. -A test run can end by discarding active hot-reload changes: script edits imported during the run are compiled when the test runner releases its assembly-reload lock, and that deferred domain reload wipes the patches even though the tests themselves ran patched. The response's Warning field reports this; re-apply 'uloop hot-reload' or bake the edits in with 'uloop compile' before the next Play or test run. +A test run can end by discarding active hot-reload changes: script edits imported during the run are compiled when the test runner releases its assembly-reload lock, and that deferred domain reload wipes the patches even though the tests themselves ran patched. The response's Warning field appears whenever changes were live when the run started and does not say whether they survived; check 'uloop hot-reload --status', then re-apply 'uloop hot-reload' or bake the edits in with 'uloop compile' before the next Play or test run. `NoTestsFound` means zero tests matched — not a test failure. Check `NoTestsFoundExplanation` and `Message` for asmdef hints. When an unfiltered run finds no tests and the project has no test assembly for the TestMode, `ProposedTestAsmdef` carries a ready-to-write `.asmdef`: save `Content` at `AssetPath`, move the test scripts under that folder, then compile and rerun. @@ -34,7 +34,7 @@ uloop run-tests [options] | `--filter-type` | string | `all` | Filter type: `all`, `exact`, `regex`, `assembly`, `class` | | `--filter-value` | string | - | Filter value (test name, pattern, assembly, or class name) | | `--unsaved-changes` | string | `save` | `save` writes unsaved Scene/Prefab Stage changes; `fail` stops if any remain; `discard` reloads disk state (Untitled scenes fail) | -| `--skip-compile` | flag | - | Skip the CLI-side compile before running tests; use only while validating active hot-reload patches. Unity still imports script edits saved since the last compile. | +| `--skip-compile` | flag | - | Skip the CLI-side compile before running tests; use only while validating active hot-reload patches. Script edits saved since the last compile can still be imported during the run. | | `--timeout-seconds` | integer | `600` | Maximum seconds to wait for RunFinished before canceling the await (max `1500`). Increase for long suites; on timeout the Test Runner may still be running until stop handling lands | | `--respect-enter-play-mode-settings` | flag | - | PlayMode only: keep the project's Enter Play Mode settings instead of forcing Domain Reload off. A Domain Reload during the run is survived; the result is recovered after the reload. Use for projects whose libraries require a Domain Reload on Play entry. | | `--rerun-failed` | flag | - | Rerun only the tests that failed or were inconclusive in the most recent completed run of the same --test-mode (whole fixtures for a failed OneTimeSetUp/OneTimeTearDown). Cannot be combined with --filter-type or --filter-value | diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedMemberAccessE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedMemberAccessE2ETests.cs index d8cbeb93af..be43967cd4 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedMemberAccessE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedMemberAccessE2ETests.cs @@ -14,9 +14,11 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// - /// End-to-end EditMode coverage for added private members that compiled-member accessor - /// delegates cannot reach: a static property the compiled type lacks, and ref/out arguments. - /// The reload must emit them and the patched runtime must return their values. + /// End-to-end EditMode coverage for private member use in reloaded bodies: added private + /// members that compiled-member accessor delegates cannot reach (a static property the + /// compiled type lacks, ref/out arguments), nameof of a compiled private field, and a property + /// pattern on a compiled private field. The reload must apply them and the patched runtime + /// must return their values. /// public class HotReloadAddedMemberAccessE2ETests { @@ -93,6 +95,79 @@ public async Task Run_AddedMethodsUsingRefOutAndAddedStaticProperty_ReturnTheirV Assert.That(new HotReloadCrossFileAddedMemberHost().Scaled(3), Is.EqualTo(211), FormatOutcomes(result)); } + /// + /// What: an added static method that names a compiled instance field with nameof is added + /// rather than failing its shim compile, and the patched caller returns the name's length. + /// + [Test] + public async Task Run_AddedStaticMethodNamingInstanceFieldWithNameof_IsAddedAndReturnsTheNameLength() + { + string source = ReadFixture(HostFileName); + source = ReplaceInSource( + source, + HostScaledBodyAnchor, + " return AddedStoredNameLength() + factor;\n"); + source = ReplaceInSource( + source, + HostValueAnchor, + " private static int AddedStoredNameLength()\n {\n" + + " return nameof(_stored).Length;\n }\n\n" + + HostValueAnchor); + + string hostPath = FixturePath(HostFileName); + HotReloadOrchestratorResult result = await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { hostPath }, + contentPathOverride: null, + CancellationToken.None, + new Dictionary + { + [hostPath] = HotReloadTestSourceWriter.WriteEditedSource( + "AddedStaticNameofHost.cs", + source) + }); + + AssertNoKind(result, HotReloadMethodOutcomeKind.Failed); + AssertNoKind(result, HotReloadMethodOutcomeKind.Skipped); + AssertKind(result, HotReloadMethodOutcomeKind.Patched, "Scaled"); + AssertKind(result, HotReloadMethodOutcomeKind.Added, "AddedStoredNameLength"); + // "_stored".Length + 3 = 7 + 3. + Assert.That(new HotReloadCrossFileAddedMemberHost().Scaled(3), Is.EqualTo(10), FormatOutcomes(result)); + } + + /// + /// What: a compiled method whose new body matches property patterns against a compiled + /// private field is patched, and the patched call tells the matching pattern from the + /// non-matching one. + /// + [Test] + public async Task Run_PropertyPatternOnCompiledPrivateField_IsPatchedAndEvaluatesThePatterns() + { + string source = ReadFixture(HostFileName); + source = ReplaceInSource( + source, + HostScaledBodyAnchor, + " return (this is { _stored: 0 } ? 100 : 0) + (this is { _stored: 1 } ? 10 : 0) + factor;\n"); + + string hostPath = FixturePath(HostFileName); + HotReloadOrchestratorResult result = await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { hostPath }, + contentPathOverride: null, + CancellationToken.None, + new Dictionary + { + [hostPath] = HotReloadTestSourceWriter.WriteEditedSource( + "PropertyPatternPrivateFieldHost.cs", + source) + }); + + AssertNoKind(result, HotReloadMethodOutcomeKind.Failed); + AssertNoKind(result, HotReloadMethodOutcomeKind.Skipped); + AssertKind(result, HotReloadMethodOutcomeKind.Patched, "Scaled"); + // A new instance's _stored is 0, so only the first pattern matches: 100 + 0 + 3. A shim + // that read the wrong value, or matched both or neither, returns something else. + Assert.That(new HotReloadCrossFileAddedMemberHost().Scaled(3), Is.EqualTo(103), FormatOutcomes(result)); + } + private static void AssertNoKind(HotReloadOrchestratorResult result, HotReloadMethodOutcomeKind kind) { foreach (HotReloadMethodOutcome outcome in result.Methods) diff --git a/Assets/Tests/Editor/HotReload/HotReloadApplyOutcomeTests.cs b/Assets/Tests/Editor/HotReload/HotReloadApplyOutcomeTests.cs new file mode 100644 index 0000000000..b2f5a4456b --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadApplyOutcomeTests.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers the one-word answer to "are the requested edits live now?" for an apply run, judged + /// on the requested files with the rows of re-applied sibling files left out. + /// + public sealed class HotReloadApplyOutcomeTests + { + private const string RequestedPath = "Assets/Requested.cs"; + private const string SiblingPath = "Assets/Sibling.cs"; + + /// + /// What: a run with a Failed row answers Failed even though another requested method was + /// patched, the same condition that turns Success false. + /// + [Test] + public void Decide_HasFailure_ReturnsFailed() + { + HotReloadApplyOutcomeKind outcome = HotReloadApplyOutcome.Decide( + new List + { + HotReloadMethodOutcome.Patched("Requested.M", RequestedPath), + HotReloadMethodOutcome.Failed("Requested.N", "reason", RequestedPath) + }, + Array.Empty(), + NoSiblings(), + hasFailure: true); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.Failed)); + } + + /// + /// What: a requested method that is Patched, Added, or AlreadyActive each counts as live, + /// so a run of only that row answers Applied. + /// + [Test] + public void Decide_RequestedRowPatchedAddedOrAlreadyActive_ReturnsApplied() + { + Assert.That( + DecideMethods(NoSiblings(), HotReloadMethodOutcome.Patched("Requested.M", RequestedPath)), + Is.EqualTo(HotReloadApplyOutcomeKind.Applied), + "Patched"); + Assert.That( + DecideMethods(NoSiblings(), HotReloadMethodOutcome.Added("Requested.M", RequestedPath)), + Is.EqualTo(HotReloadApplyOutcomeKind.Applied), + "Added"); + Assert.That( + DecideMethods(NoSiblings(), HotReloadMethodOutcome.AlreadyActive("Requested.M", RequestedPath)), + Is.EqualTo(HotReloadApplyOutcomeKind.Applied), + "AlreadyActive"); + } + + /// + /// What: a Skipped row of a re-applied sibling does not count against the requested file, + /// so a requested Patched row still answers Applied. + /// + [Test] + public void Decide_RequestedPatchedAndSiblingSkipped_ReturnsApplied() + { + HotReloadApplyOutcomeKind outcome = DecideMethods( + WithSibling(), + HotReloadMethodOutcome.Patched("Requested.M", RequestedPath), + HotReloadMethodOutcome.Skipped("Sibling.N", "reason", SiblingPath)); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.Applied)); + } + + /// + /// What: requested methods of which some are live and some Skipped answer PartiallyApplied. + /// + [Test] + public void Decide_RequestedPatchedAndSkipped_ReturnsPartiallyApplied() + { + HotReloadApplyOutcomeKind outcome = DecideMethods( + NoSiblings(), + HotReloadMethodOutcome.Patched("Requested.M", RequestedPath), + HotReloadMethodOutcome.Skipped("Requested.N", "reason", RequestedPath)); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.PartiallyApplied)); + } + + /// + /// What: requested methods that were all Skipped answer NothingApplied. + /// + [Test] + public void Decide_RequestedAllSkipped_ReturnsNothingApplied() + { + HotReloadApplyOutcomeKind outcome = DecideMethods( + NoSiblings(), + HotReloadMethodOutcome.Skipped("Requested.M", "reason", RequestedPath), + HotReloadMethodOutcome.Skipped("Requested.N", "reason", RequestedPath)); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.NothingApplied)); + } + + /// + /// What: an Added row of a re-applied sibling does not make the requested file live, so a + /// requested file whose only row was Skipped answers NothingApplied, not PartiallyApplied. + /// + [Test] + public void Decide_RequestedSkippedAndSiblingAdded_ReturnsNothingApplied() + { + HotReloadApplyOutcomeKind outcome = DecideMethods( + WithSibling(), + HotReloadMethodOutcome.Skipped("Requested.M", "reason", RequestedPath), + HotReloadMethodOutcome.Added("Sibling.N", SiblingPath)); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.NothingApplied)); + } + + /// + /// What: a run with no rows at all, as when every method is unchanged or the files hold + /// no method bodies, answers NothingToApply. + /// + [Test] + public void Decide_NoRows_ReturnsNothingToApply() + { + HotReloadApplyOutcomeKind outcome = DecideMethods(NoSiblings()); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.NothingToApply)); + } + + /// + /// What: Stale rows are neither live nor Skipped, so a run of only Stale rows answers + /// NothingToApply. + /// + [Test] + public void Decide_OnlyStaleRows_ReturnsNothingToApply() + { + HotReloadApplyOutcomeKind outcome = DecideMethods( + NoSiblings(), + HotReloadMethodOutcome.Stale("Requested.M", RequestedPath)); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.NothingToApply)); + } + + /// + /// What: a Skipped row without a FilePath, such as a file-level row, counts as a requested + /// row even when the run re-applied a sibling, so it answers NothingApplied. + /// + [Test] + public void Decide_SkippedRowWithoutFilePath_ReturnsNothingApplied() + { + HotReloadApplyOutcomeKind outcome = DecideMethods( + WithSibling(), + HotReloadMethodOutcome.Skipped("Requested.M", "reason", string.Empty)); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.NothingApplied)); + } + + /// + /// What: a requested type row that is Introduced or AlreadyActive each counts as live, so a + /// run of only that row and no method rows answers Applied. + /// + [Test] + public void Decide_OnlyRequestedIntroducedTypes_ReturnsApplied() + { + Assert.That( + DecideTypes( + NoSiblings(), + HotReloadIntroducedTypeOutcome.Introduced("Example.NewType", "Assembly-CSharp", RequestedPath)), + Is.EqualTo(HotReloadApplyOutcomeKind.Applied), + "Introduced"); + Assert.That( + DecideTypes( + NoSiblings(), + HotReloadIntroducedTypeOutcome.AlreadyActive( + "Example.OtherType", + "Assembly-CSharp", + RequestedPath, + bodyEdited: false)), + Is.EqualTo(HotReloadApplyOutcomeKind.Applied), + "AlreadyActive"); + } + + /// + /// What: an Introduced type row whose owner is a re-applied sibling does not make the + /// requested files live, so a run of only that row answers NothingToApply. + /// + [Test] + public void Decide_OnlySiblingOwnedIntroducedType_ReturnsNothingToApply() + { + HotReloadApplyOutcomeKind outcome = DecideTypes( + WithSibling(), + HotReloadIntroducedTypeOutcome.Introduced("Example.NewType", "Assembly-CSharp", SiblingPath)); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.NothingToApply)); + } + + private static HotReloadApplyOutcomeKind DecideMethods( + HotReloadReappliedSiblingFiles siblingFiles, + params HotReloadMethodOutcome[] methods) + { + return HotReloadApplyOutcome.Decide( + methods, + Array.Empty(), + siblingFiles, + hasFailure: false); + } + + private static HotReloadApplyOutcomeKind DecideTypes( + HotReloadReappliedSiblingFiles siblingFiles, + params HotReloadIntroducedTypeOutcome[] introducedTypes) + { + return HotReloadApplyOutcome.Decide( + Array.Empty(), + introducedTypes, + siblingFiles, + hasFailure: false); + } + + private static HotReloadReappliedSiblingFiles NoSiblings() + { + return new HotReloadReappliedSiblingFiles(Array.Empty(), path => path); + } + + private static HotReloadReappliedSiblingFiles WithSibling() + { + return new HotReloadReappliedSiblingFiles(new[] { SiblingPath }, path => path); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadApplyOutcomeTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadApplyOutcomeTests.cs.meta new file mode 100644 index 0000000000..94cb53c4cc --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadApplyOutcomeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 086bf716c90a74792b62edd56d5614b2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs index 6fdc51745f..ae81a69f5f 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs @@ -228,6 +228,108 @@ public void DetectFromSnapshotDirectory_WhenMoreThanLimitChanged_TruncatesAndWar } } + /// + /// What: without a snapshot directory nothing was compared, so the empty list is reported as + /// incomplete rather than as "no sibling changed". + /// + [Test] + public void DetectFromSnapshotDirectory_WhenSnapshotDirectoryMissing_IsNotComplete() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string editedRelative = "Assets/Edited.cs"; + string siblingRelative = "Assets/Sibling.cs"; + WriteProjectFile(projectRoot, editedRelative, "edited-disk"); + WriteProjectFile(projectRoot, siblingRelative, "sibling-disk"); + + HotReloadChangedSiblingScanResult result = + HotReloadChangedSiblingSourceDetector.DetectFromSnapshotDirectory( + projectRoot, + "Asm-mvid", + new[] { editedRelative, siblingRelative }, + new[] { editedRelative }); + + Assert.That(result.IsComplete, Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a list cut down to the scan limit is reported as incomplete, because a changed + /// sibling past the limit is missing from it. + /// + [Test] + public void DetectFromSnapshotDirectory_WhenMoreThanLimitChanged_IsNotComplete() + { + string projectRoot = CreateTempProjectRoot(); + try + { + const int changedSiblingCount = 51; + string editedRelative = "Assets/Edited.cs"; + WriteProjectFile(projectRoot, editedRelative, "edited-disk"); + WriteSnapshot(projectRoot, "Asm-mvid", editedRelative, "edited-snapshot"); + + string[] sourceFiles = new string[changedSiblingCount + 1]; + sourceFiles[0] = editedRelative; + for (int index = 0; index < changedSiblingCount; index++) + { + string relative = "Assets/Sibling" + index.ToString(CultureInfo.InvariantCulture) + ".cs"; + sourceFiles[index + 1] = relative; + WriteProjectFile(projectRoot, relative, "disk-" + index.ToString(CultureInfo.InvariantCulture)); + WriteSnapshot(projectRoot, "Asm-mvid", relative, "snap-" + index.ToString(CultureInfo.InvariantCulture)); + } + + HotReloadChangedSiblingScanResult result = + HotReloadChangedSiblingSourceDetector.DetectFromSnapshotDirectory( + projectRoot, + "Asm-mvid", + sourceFiles, + new[] { editedRelative }); + + Assert.That(result.IsComplete, Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: when every sibling was compared with its snapshot and the list stayed under the + /// limit, the list is reported as complete. + /// + [Test] + public void DetectFromSnapshotDirectory_WhenEverySiblingWasCompared_IsComplete() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string editedRelative = "Assets/Edited.cs"; + string siblingRelative = "Assets/Sibling.cs"; + WriteProjectFile(projectRoot, editedRelative, "edited-disk"); + WriteProjectFile(projectRoot, siblingRelative, "sibling-disk"); + WriteSnapshot(projectRoot, "Asm-mvid", editedRelative, "edited-snapshot"); + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-snapshot"); + + HotReloadChangedSiblingScanResult result = + HotReloadChangedSiblingSourceDetector.DetectFromSnapshotDirectory( + projectRoot, + "Asm-mvid", + new[] { editedRelative, siblingRelative }, + new[] { editedRelative }); + + Assert.That(result.IsComplete, Is.True); + } + 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 diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs index 4d4217516d..f08dfee029 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs @@ -181,7 +181,8 @@ private static HotReloadServices CreateServicesSharing(HotReloadDomain domain) installed.EditorStateSnapshotCapture, TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, - new HotReloadApplicationPlayModeQuery()); + new HotReloadApplicationPlayModeQuery(), + new HotReloadSourceSnapshotCapture(() => { })); } private static HotReloadServices CreateServicesOwning(HotReloadDomain domain) @@ -193,7 +194,8 @@ private static HotReloadServices CreateServicesOwning(HotReloadDomain domain) new HotReloadEditorStateSnapshotCapture(), TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, - new HotReloadApplicationPlayModeQuery()); + new HotReloadApplicationPlayModeQuery(), + new HotReloadSourceSnapshotCapture(() => { })); } private static HotReloadIntroducedTypeArtifact CreateArtifact(string metadataName) diff --git a/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs index d37d988cee..738ab7e97f 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs @@ -345,6 +345,75 @@ public async Task ExecuteAsync_WhenNoBaselineExists_FailsEvenWithADiscardedFile( Assert.That(response.ErrorCode, Is.EqualTo(HotReloadValidationErrorCodes.FilesRequired)); } + /// + /// What: with --files omitted, the tool captures the source snapshot before it detects the + /// changed files, so a request that waited out a domain reload detects them against the + /// snapshot of the compile that reload loaded. + /// + [Test] + public async Task ExecuteAsync_WhenFilesAreOmitted_CapturesTheSourceSnapshotBeforeDetectingChanges() + { + int captureCount = 0; + using IDisposable captureScope = HotReloadServicesTestScope.BeginWithSourceSnapshotCapture( + new HotReloadSourceSnapshotCapture(() => captureCount++)); + int captureCountAtDetection = -1; + using IDisposable detectorScope = HotReloadServicesTestScope.BeginWithChangeDetector( + new HotReloadStubChangeDetector(() => + { + captureCountAtDetection = captureCount; + return new HotReloadChangedFileAggregationResult( + hasBaseline: true, + changedProjectRelativePaths: new List { "Assets/Changed1.cs" }, + scanLimitWarnings: new List()); + })); + List appliedFiles = new List(); + using IDisposable orchestratorScope = BeginRecordingOrchestrator(appliedFiles); + + await ExecuteAsync(new JObject()); + + Assert.That(captureCountAtDetection, Is.EqualTo(1)); + } + + /// + /// What: with --files, the tool captures the source snapshot before the run starts, so a + /// request that waited out a domain reload runs against the snapshot of the compile that + /// reload loaded. + /// + [Test] + public async Task ExecuteAsync_WithFiles_CapturesTheSourceSnapshotBeforeTheRun() + { + int captureCount = 0; + using IDisposable captureScope = HotReloadServicesTestScope.BeginWithSourceSnapshotCapture( + new HotReloadSourceSnapshotCapture(() => captureCount++)); + int captureCountAtRun = -1; + using IDisposable orchestratorScope = HotReloadServicesTestScope.BeginWithOrchestrator( + new HotReloadStubOrchestrator((files, ignoredCt) => + { + captureCountAtRun = captureCount; + return Task.FromResult(CreateAppliedResult()); + })); + + await ExecuteAsync(new JObject { ["Files"] = new JArray(ExistingScriptPath) }); + + Assert.That(captureCountAtRun, Is.EqualTo(1)); + } + + /// + /// What: --status reads no source snapshot, so it does not run the capture. + /// + [Test] + public async Task ExecuteAsync_ForStatus_DoesNotCaptureTheSourceSnapshot() + { + int captureCount = 0; + using IDisposable captureScope = HotReloadServicesTestScope.BeginWithSourceSnapshotCapture( + new HotReloadSourceSnapshotCapture(() => captureCount++)); + + HotReloadResponse response = await ExecuteAsync(new JObject { ["Status"] = true }); + + Assert.That(response.Success, Is.True); + Assert.That(captureCount, Is.EqualTo(0)); + } + private static void RecordDroppedIntroducedSource(string projectRelativePath) { if (projectRelativePath == ExistingDroppedSourcePath) @@ -384,18 +453,22 @@ private static IDisposable BeginRecordingOrchestrator(List appliedFiles) new HotReloadStubOrchestrator((files, ignoredCt) => { appliedFiles.AddRange(files); - return Task.FromResult( - new HotReloadOrchestratorResult( - new List - { - HotReloadMethodOutcome.Patched("Host.Selected()", "Assets/Changed1.cs") - }, - new List(), - patchedTotal: 1, - activePatchTotal: 1)); + return Task.FromResult(CreateAppliedResult()); })); } + private static HotReloadOrchestratorResult CreateAppliedResult() + { + return new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Patched("Host.Selected()", "Assets/Changed1.cs") + }, + new List(), + patchedTotal: 1, + activePatchTotal: 1); + } + private static Task FailIfApplyRuns( IReadOnlyList files, CancellationToken ct) @@ -413,7 +486,11 @@ private static async Task ExecuteAsync(JObject parameters) return response; } - private const string ExistingDroppedSourcePath = "Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs"; + // This test file: a script that is on disk, so a run given it through --files names a file + // that exists. + private const string ExistingScriptPath = "Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs"; + + private const string ExistingDroppedSourcePath = ExistingScriptPath; private const string AppliedMessageTail = "Hot reload applied. PatchedTotal=1, ActivePatchTotal=1."; diff --git a/Assets/Tests/Editor/HotReload/HotReloadEntryResolutionTests.cs b/Assets/Tests/Editor/HotReload/HotReloadEntryResolutionTests.cs index 386579953b..7358220a6f 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadEntryResolutionTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadEntryResolutionTests.cs @@ -26,16 +26,19 @@ public class HotReloadEntryResolutionTests private static Assembly ShimAssembly => typeof(HotReloadHandwrittenShims).Assembly; private HotReloadDomainTestScope _scope; + private HotReloadMethodMatcher _matcher; [SetUp] public void SetUp() { _scope = new HotReloadDomainTestScope(); + _matcher = HotReloadMethodMatcher.CreateReadingFromDisk(); } [TearDown] public void TearDown() { + _matcher.Dispose(); _scope.Dispose(); } @@ -80,6 +83,7 @@ public void ResolveEntries_WhenEveryEntryResolves_ReportsAllResolved() HotReloadEntryResolution.Result result = HotReloadEntryResolution.ResolveEntries( TestAssemblyHome, FileHomeResolver, + _matcher.Resolve, FilePath, ShimAssembly, entries, @@ -91,6 +95,45 @@ public void ResolveEntries_WhenEveryEntryResolves_ReportsAllResolved() Assert.That(result.FailureOutcomes, Is.Empty); } + /// + /// What: every entry that patches an existing method is resolved through the resolver the + /// caller passes in, which is how one matcher of the run answers all of a group's entries. + /// + [Test] + public void ResolveEntries_ResolvesEachExistingMethodEntryThroughTheGivenResolver() + { + TransformWorkerEntryDto[] entries = + { + BuildExistingMethodEntry( + nameof(HotReloadCoreFixture.StaticPing), + new string[0], + "StaticPing__shim0"), + BuildExistingMethodEntry( + nameof(HotReloadCoreFixture.ReplaceableCompute), + new[] { "System.Int32" }, + "ReplaceableCompute__shim0") + }; + int resolveCalls = 0; + HotReloadMethodResolver counting = (home, typeMetadataName, methodName, parameterTypeFullNames, genericArity) => + { + resolveCalls++; + return _matcher.Resolve(home, typeMetadataName, methodName, parameterTypeFullNames, genericArity); + }; + + HotReloadEntryResolution.Result result = HotReloadEntryResolution.ResolveEntries( + TestAssemblyHome, + FileHomeResolver, + counting, + FilePath, + ShimAssembly, + entries, + new Dictionary(), + new HotReloadAddedCalleeIndex(entries)); + + Assert.That(result.AllResolved, Is.True); + Assert.That(resolveCalls, Is.EqualTo(2)); + } + /// /// What: an entry naming a shim method the shim assembly does not declare fails the whole /// file — the result is not all-resolved, the failing row is reported Failed, and every @@ -114,6 +157,7 @@ public void ResolveEntries_WhenShimMethodIsMissing_FailsTheFileAtomically() HotReloadEntryResolution.Result result = HotReloadEntryResolution.ResolveEntries( TestAssemblyHome, FileHomeResolver, + _matcher.Resolve, FilePath, ShimAssembly, entries, @@ -158,6 +202,7 @@ public void ResolveEntries_WhenAddedMethodHasNoUsableCounter_FailsTheFileAtomica HotReloadEntryResolution.Result result = HotReloadEntryResolution.ResolveEntries( TestAssemblyHome, FileHomeResolver, + _matcher.Resolve, FilePath, ShimAssembly, entries, @@ -197,6 +242,7 @@ public void ResolveEntries_WhenEntryIsAnAddedMethod_ResolvesWithoutAnOriginalMet HotReloadEntryResolution.Result result = HotReloadEntryResolution.ResolveEntries( TestAssemblyHome, FileHomeResolver, + _matcher.Resolve, FilePath, ShimAssembly, entries, @@ -233,6 +279,7 @@ public void ResolveEntries_RecordsTheAddedMembersEachEntryCalls() HotReloadEntryResolution.Result result = HotReloadEntryResolution.ResolveEntries( TestAssemblyHome, FileHomeResolver, + _matcher.Resolve, FilePath, ShimAssembly, entries, @@ -273,6 +320,7 @@ public void ResolveEntries_WhenACallNamesNoAddedEntry_FailsTheFileAtomically() HotReloadEntryResolution.Result result = HotReloadEntryResolution.ResolveEntries( TestAssemblyHome, FileHomeResolver, + _matcher.Resolve, FilePath, ShimAssembly, entries, @@ -310,6 +358,7 @@ public void ResolveEntries_WhenATransplantShimTypeFailedToBind_FailsTheEntry() HotReloadEntryResolution.Result result = HotReloadEntryResolution.ResolveEntries( TestAssemblyHome, FileHomeResolver, + _matcher.Resolve, FilePath, ShimAssembly, entries, diff --git a/Assets/Tests/Editor/HotReload/HotReloadEnumMemberOnlyLeaveOutTests.cs b/Assets/Tests/Editor/HotReload/HotReloadEnumMemberOnlyLeaveOutTests.cs index 177fc0bd28..ff1eb89414 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadEnumMemberOnlyLeaveOutTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadEnumMemberOnlyLeaveOutTests.cs @@ -328,6 +328,7 @@ public void BuildRetryInput_LeavesOutOnlyTheNamedSourcesAndCopiesTheRest() excludedAddedMethodKeys = new[] { "excludedAdded" }, assemblySourcePaths = new[] { "/project/" + EnumPath }, changedSiblingSourcePaths = new[] { "/project/Assets/Scripts/Sibling.cs" }, + changedSiblingScanComplete = true, introducedTypeArtifacts = new[] { new TransformWorkerIntroducedTypeArtifactDto() }, activeMethodLabels = new[] { "Game.Caller.Run()" } }; @@ -346,6 +347,7 @@ public void BuildRetryInput_LeavesOutOnlyTheNamedSourcesAndCopiesTheRest() Assert.That(retry.excludedAddedMethodKeys, Is.SameAs(first.excludedAddedMethodKeys)); Assert.That(retry.assemblySourcePaths, Is.SameAs(first.assemblySourcePaths)); Assert.That(retry.changedSiblingSourcePaths, Is.SameAs(first.changedSiblingSourcePaths)); + Assert.That(retry.changedSiblingScanComplete, Is.True); Assert.That(retry.introducedTypeArtifacts, Is.SameAs(first.introducedTypeArtifacts)); Assert.That(retry.activeMethodLabels, Is.SameAs(first.activeMethodLabels)); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingBaseHost.cs b/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingBaseHost.cs new file mode 100644 index 0000000000..8d2be0869c --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingBaseHost.cs @@ -0,0 +1,12 @@ +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.GlobalUsingBase +{ + /// + /// Base type reachable only through the test assembly's global using, so a derived + /// fixture binds it without a file-level using directive. Public because the worker + /// compilation cannot see internal types of the compiled assembly. + /// + public class HotReloadGlobalUsingBaseHost + { + public int BaseOffset => 10; + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingBaseHost.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingBaseHost.cs.meta new file mode 100644 index 0000000000..9ec56f9eac --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingBaseHost.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5e777802bc17149728c71bc32c3a0585 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingBehaviourBase.cs b/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingBehaviourBase.cs new file mode 100644 index 0000000000..0017c36d1b --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingBehaviourBase.cs @@ -0,0 +1,13 @@ +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.GlobalUsingBase +{ + /// + /// Unity object base type reachable only through the test assembly's global using, so an + /// introduced type that derives from it without a using directive is refused as a Unity + /// object only when the planning compilation carries that using. + /// + public class HotReloadGlobalUsingBehaviourBase : MonoBehaviour + { + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingBehaviourBase.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingBehaviourBase.cs.meta new file mode 100644 index 0000000000..15f7e6c55e --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingBehaviourBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4d42eacf1460148119dc195f324fbb7c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingMode.cs b/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingMode.cs new file mode 100644 index 0000000000..18df4fac67 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingMode.cs @@ -0,0 +1,12 @@ +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.GlobalUsingBase +{ + /// + /// Enum reachable only through the test assembly's global using, so a const of this type + /// declared in another namespace has a value only in a compilation that carries that using. + /// + public enum HotReloadGlobalUsingMode + { + First = 1, + Second = 2 + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingMode.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingMode.cs.meta new file mode 100644 index 0000000000..1a54719cc1 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadGlobalUsingMode.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fb523ddc1f7084453bab0c2f3f1600e5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadGlobalUsings.cs b/Assets/Tests/Editor/HotReload/HotReloadGlobalUsings.cs index c3c9a19bcd..33c3c75a95 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadGlobalUsings.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadGlobalUsings.cs @@ -2,3 +2,4 @@ // assembly, so the name must not collide with existing test identifiers. // Why sibling csc.rsp -langversion:10: the project default is C# 9, which cannot parse global using. global using HotReloadGlobalAlias = System.Text.StringBuilder; +global using io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.GlobalUsingBase; diff --git a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorLeaveOutTests.cs b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorLeaveOutTests.cs index dcb1859a57..07e0f1dac2 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorLeaveOutTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorLeaveOutTests.cs @@ -292,6 +292,7 @@ private async Task> RunGroupAsync( return await HotReloadCompositionRoot.Services.GroupProcessor.ProcessGroupAsync( files, "leave-out-test", + new HotReloadRunTiming(), CancellationToken.None); } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs index 9fdaf29ccb..5bd2e560be 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs @@ -9,6 +9,7 @@ using UnityEditor.Compilation; using UnityEngine; +using Stopwatch = System.Diagnostics.Stopwatch; using io.github.hatayama.UnityCliLoop.FirstPartyTools; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -641,6 +642,7 @@ public async Task ProcessGroupAsync_WhenTheWorkerFails_BuildsUnappliedResultsWit results = await HotReloadCompositionRoot.Services.GroupProcessor.ProcessGroupAsync( new[] { file }, "worker-failure-test", + new HotReloadRunTiming(), CancellationToken.None); } @@ -650,6 +652,122 @@ public async Task ProcessGroupAsync_WhenTheWorkerFails_BuildsUnappliedResultsWit Assert.That(CountFileFailedRows(file), Is.EqualTo(1)); } + /// + /// What: each phase of a group run records the time of its own step. Every stubbed step + /// takes a different time, so a step recorded under another phase, or not recorded at + /// all, leaves some phase short of what its own steps measured. + /// + [Test] + public async Task ProcessGroupAsync_RecordsEachPhaseFromItsOwnStep() + { + PhaseStubs stubs = new PhaseStubs(CreatePhaseFile()) + { + PreparationDelayMs = 40, + WorkerDelayMs = 300, + GateDelayMs = 100 + }; + + HotReloadTimingBreakdown breakdown = await RunGroupWithPhaseStubsAsync(stubs); + + Assert.That(stubs.ApplyCalls, Is.EqualTo(1), "The run must reach the apply step."); + Assert.That( + breakdown.AnalysisMs, + Is.GreaterThanOrEqualTo(stubs.PreparationMs + stubs.WorkerMs), + "AnalysisMs"); + Assert.That(breakdown.ShimCompileMs, Is.GreaterThanOrEqualTo(stubs.GateMs), "ShimCompileMs"); + Assert.That(breakdown.PatchMs, Is.GreaterThanOrEqualTo(stubs.ApplyMs), "PatchMs"); + } + + /// + /// What: a gate or compile that fails still records the time it took, and the run that + /// stops there records no patch time. + /// + [Test] + public async Task ProcessGroupAsync_WhenGateAndCompileFails_RecordsShimCompileAndNoPatch() + { + PhaseStubs stubs = new PhaseStubs(CreatePhaseFile()) + { + GateDelayMs = 100, + GateFails = true + }; + + HotReloadTimingBreakdown breakdown = await RunGroupWithPhaseStubsAsync(stubs); + + Assert.That(stubs.GateCalls, Is.EqualTo(1), "The run must reach the gate."); + Assert.That(stubs.ApplyCalls, Is.EqualTo(0), "A failed gate must not apply."); + Assert.That(breakdown.ShimCompileMs, Is.GreaterThanOrEqualTo(stubs.GateMs), "ShimCompileMs"); + Assert.That(breakdown.PatchMs, Is.EqualTo(0), "PatchMs"); + } + + /// + /// What: a transform worker that fails records its time as analysis, and the run that + /// stops there records no shim compile or patch time. + /// + [Test] + public async Task ProcessGroupAsync_WhenWorkerFails_RecordsAnalysisOnly() + { + PhaseStubs stubs = new PhaseStubs(CreatePhaseFile()) + { + WorkerDelayMs = 100, + WorkerFails = true + }; + + HotReloadTimingBreakdown breakdown = await RunGroupWithPhaseStubsAsync(stubs); + + Assert.That(stubs.WorkerCalls, Is.EqualTo(1), "The run must reach the worker."); + Assert.That(stubs.GateCalls, Is.EqualTo(0), "A failed worker must not reach the gate."); + Assert.That(breakdown.AnalysisMs, Is.GreaterThanOrEqualTo(stubs.WorkerMs), "AnalysisMs"); + Assert.That(breakdown.ShimCompileMs, Is.EqualTo(0), "ShimCompileMs"); + Assert.That(breakdown.PatchMs, Is.EqualTo(0), "PatchMs"); + } + + private static HotReloadGroupFile CreatePhaseFile() + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + return CreateFile(HealthySourcePath, projectRoot, FindCompilationAssembly()); + } + + /// + /// Runs one group through the production processor with its collaborators replaced by + /// the stubs, and returns what the run recorded per phase. + /// + private static async Task RunGroupWithPhaseStubsAsync(PhaseStubs stubs) + { + HotReloadRunTiming timing = new HotReloadRunTiming(); + // Why time the call instead of passing a fixed total: the stubs really wait, so the + // phases hold measured times, and only the time around the call is a total that + // covers them, as in the production run. + Stopwatch total = new Stopwatch(); + using (HotReloadServicesTestScope.BeginWithDependencies(collaborators => + HotReloadGroupProcessorDependencies.Create( + files => true, + stubs.PrepareAsync, + stubs.RunWorkerAsync, + stubs.GateAndCompileAsync, + (context, compileResult, entriesToPatch) => Array.Empty(), + stubs.ApplyPreparedEntries))) + { + try + { + total.Start(); + await HotReloadCompositionRoot.Services.GroupProcessor.ProcessGroupAsync( + new[] { stubs.GroupFile }, + "phase-timing-test", + timing, + CancellationToken.None); + total.Stop(); + } + 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 group run was canceled: " + exception.Message); + } + } + + return timing.Complete(total.ElapsedMilliseconds); + } + private static TransformWorkerEntryDto CreateAtomicEntry(string projectRelativePath) { return new TransformWorkerEntryDto @@ -1062,6 +1180,143 @@ internal IDisposable Install() } } + /// + /// Stands in for each step of a group run with a wait of its own length, and measures + /// that wait itself, so a test can tell which phase each step's time was recorded under. + /// + private sealed class PhaseStubs + { + private const int ApplySpinMs = 20; + + private readonly string _sourceContentSha256; + + internal PhaseStubs(HotReloadGroupFile file) + { + GroupFile = file; + // Why the hash the file was written with: the commit boundary re-hashes the + // request source and refuses a run whose worker reported a different one. + _sourceContentSha256 = file.FileOutput.sourceContentSha256; + } + + internal HotReloadGroupFile GroupFile { get; } + + internal int PreparationDelayMs { get; set; } + + internal int WorkerDelayMs { get; set; } + + internal int GateDelayMs { get; set; } + + internal bool WorkerFails { get; set; } + + internal bool GateFails { get; set; } + + internal long PreparationMs { get; private set; } + + internal long WorkerMs { get; private set; } + + internal long GateMs { get; private set; } + + internal long ApplyMs { get; private set; } + + internal int WorkerCalls { get; private set; } + + internal int GateCalls { get; private set; } + + internal int ApplyCalls { get; private set; } + + internal async Task PrepareAsync( + IReadOnlyList files, + TransformWorkerInputDto input, + CancellationToken ct) + { + Stopwatch waited = Stopwatch.StartNew(); + await Task.Delay(PreparationDelayMs); + PreparationMs = waited.ElapsedMilliseconds; + return HotReloadIntroducedTypePreparationResult.NoIntroducedTypes(); + } + + internal async Task RunWorkerAsync( + TransformWorkerInputDto input, + CancellationToken ct) + { + WorkerCalls++; + Stopwatch waited = Stopwatch.StartNew(); + await Task.Delay(WorkerDelayMs); + WorkerMs = waited.ElapsedMilliseconds; + return WorkerFails + ? TransformWorkerClientResult.Failure("transform worker failed") + : TransformWorkerClientResult.SuccessResult(BuildWorkerOutput()); + } + + internal async Task GateAndCompileAsync( + HotReloadApplyContext context, + CancellationToken ct) + { + GateCalls++; + Stopwatch waited = Stopwatch.StartNew(); + await Task.Delay(GateDelayMs); + GateMs = waited.ElapsedMilliseconds; + return GateFails + ? HotReloadGroupGateAndCompileResult.Failed() + : HotReloadGroupGateAndCompileResult.Ready( + CreateEmptyGateResult(), + CreateCompile(CreateAtomicEntry(GroupFile.ProjectRelativePath))); + } + + internal IReadOnlyList ApplyPreparedEntries( + HotReloadApplyContext context, + HotReloadShimCompileResult compileResult, + IReadOnlyList preparedFiles) + { + ApplyCalls++; + // Why a busy loop: applying patches is synchronous work on the main thread in + // production too, and this stands in for its duration. It waits on nothing, so it + // cannot cause the deadlock the guardrails ban Thread.Sleep for, a main thread + // blocked on a continuation only the editor thread can run. + Stopwatch spun = Stopwatch.StartNew(); + while (spun.ElapsedMilliseconds < ApplySpinMs) + { + } + + ApplyMs = spun.ElapsedMilliseconds; + return new[] + { + new HotReloadFileProcessResult(new List(), new List(), 1) + }; + } + + private TransformWorkerOutputDto BuildWorkerOutput() + { + return new TransformWorkerOutputDto + { + shimSource = string.Empty, + entries = Array.Empty(), + skipped = Array.Empty(), + files = new[] + { + new TransformWorkerFileOutputDto + { + projectRelativePath = GroupFile.ProjectRelativePath, + sourceContentSha256 = _sourceContentSha256, + parseErrors = Array.Empty(), + declarationDriftWarnings = Array.Empty(), + removedMembers = Array.Empty(), + removedMethodSignatures = Array.Empty(), + addedFieldNames = Array.Empty(), + addedConstNames = Array.Empty(), + addedEnumMemberNames = Array.Empty(), + introducedTypes = Array.Empty(), + introducedTypeDiagnostics = Array.Empty(), + introducedTypeReuses = Array.Empty() + } + }, + parseErrors = Array.Empty(), + siblingConstDriftWarnings = Array.Empty(), + unchangedMethods = Array.Empty() + }; + } + } + private static HotReloadSignatureChangeGate.SignatureChangeGateResult CreateGateResultWithoutExemptions() { return HotReloadSignatureChangeGate.SignatureChangeGateResult.WarningsOnly( diff --git a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs new file mode 100644 index 0000000000..56b2b9e326 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs @@ -0,0 +1,29 @@ +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// A plain type that calls an internal member of another plain type. The visibility repro tests + /// pass it on its own, next to an edit of a partial type, or as a sibling a run brought back. + /// + public class HotReloadInternalMemberCaller + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int CallsInternal() + { + return HotReloadInternalMemberHost.InternalStaticValue(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int PlainValue() + { + return 1; + } + + public int CallerProperty + { + [MethodImpl(MethodImplOptions.NoInlining)] + get { return 30; } + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs.meta new file mode 100644 index 0000000000..795aa51f20 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fb228d70ed39d446e87083c77cbfb0a2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs new file mode 100644 index 0000000000..922679a62d --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs @@ -0,0 +1,116 @@ +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// A plain type whose non-public members the visibility repro tests call from edited bodies. + /// No test passes this file to a run, so the worker can only see the type as compiled. + /// + public class HotReloadInternalMemberHost + { + internal int InternalField = 3; + + internal int InternalProperty + { + get { return 4; } + } + + internal int InternalSettableProperty { get; set; } = 20; + + [MethodImpl(MethodImplOptions.NoInlining)] + internal static int InternalStaticValue() + { + return 1; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + internal int InternalInstanceValue() + { + return 2; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + protected int ProtectedValue() + { + return 5; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + protected internal int ProtectedInternalValue() + { + return 6; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private protected int PrivateProtectedValue() + { + return 7; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static int PrivateStaticValue() + { + return 13; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + internal static HotReloadInternalMemberHost InternalSelf() + { + return new HotReloadInternalMemberHost(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + internal static HotReloadInternalMemberHost[] InternalHosts() + { + return new HotReloadInternalMemberHost[] { new HotReloadInternalMemberHost() }; + } + + internal event System.Action InternalEvent; + + /// + /// Calls the handlers subscribed to and returns how many there + /// were, so a test can tell that a subscription took effect. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + public int RaiseInternalEvent() + { + System.Action handlers = InternalEvent; + if (handlers == null) + { + return 0; + } + + handlers(); + return handlers.GetInvocationList().Length; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static void NoOp() + { + } + + /// + /// A type nested in the host, so a test can name an internal member through the nested type. + /// + public class Nested + { + [MethodImpl(MethodImplOptions.NoInlining)] + internal static int NestedInternalValue() + { + return 11; + } + } + } + + /// + /// An internal type the visibility repro tests name from edited bodies. + /// + internal static class HotReloadInternalOnlyType + { + [MethodImpl(MethodImplOptions.NoInlining)] + public static int Value() + { + return 8; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs.meta new file mode 100644 index 0000000000..d1617955de --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d10bb384a8c545c5b44131477f562be +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadInternalSignatureProbe.cs b/Assets/Tests/Editor/HotReload/HotReloadInternalSignatureProbe.cs new file mode 100644 index 0000000000..a24fa3a467 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadInternalSignatureProbe.cs @@ -0,0 +1,10 @@ +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Internal type declared outside the edited file, so a signature that names it binds + /// as an inaccessible error type in the worker compilation. + /// + internal sealed class HotReloadInternalSignatureProbe + { + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadInternalSignatureProbe.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadInternalSignatureProbe.cs.meta new file mode 100644 index 0000000000..59848bc64a --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadInternalSignatureProbe.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c2d371dffaa32424aa47cb58399cfa27 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactReferenceBuilderTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactReferenceBuilderTests.cs index c3395e5131..b6e3d6ae33 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactReferenceBuilderTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactReferenceBuilderTests.cs @@ -238,6 +238,46 @@ public void Build_ReferenceResolutionFailure_ReturnsFailed(string failingImage) Assert.That(ExposedCopiesOf(unresolvable), Is.Empty); } + /// + /// Verifies the build writes the exposed copies with the search directories it is given, not + /// with ones derived from the worker references, which do not reach an assembly the target's + /// metadata needs. + /// + [Test] + public void Build_UsesTheResolverSearchDirectoriesItIsGiven() + { + string externalName = "GivenSearchEnumFixture_" + Guid.NewGuid().ToString("N"); + string externalDirectory = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N")); + try + { + InternalsExposureTestImage target = + InternalsExposureTestImage.CreateWithConstantOfEnumIn(externalDirectory, externalName, HideCandidate); + _images.Add(target); + TransformWorkerInputDto input = CreateInput(target); + IReadOnlyCollection workerDirectories = + ReferencePublicizer.CollectResolverSearchDirectories(input.referencePaths); + List givenDirectories = new List(workerDirectories) { externalDirectory }; + + // Why the failing build goes first: a failed write caches nothing, while a successful + // one would satisfy the second build from the cache before Cecil resolves anything. + HotReloadArtifactCompileReferences fromWorkerDirectories = + BuildSearching(input, target, workerDirectories, true); + HotReloadArtifactCompileReferences fromGivenDirectories = + BuildSearching(input, target, givenDirectories, true); + + Assert.That(fromWorkerDirectories.Success, Is.False); + Assert.That(fromWorkerDirectories.ErrorMessage, Does.StartWith(ExposureFailurePrefix)); + Assert.That(fromGivenDirectories.Success, Is.True); + } + finally + { + if (Directory.Exists(externalDirectory)) + { + Directory.Delete(externalDirectory, true); + } + } + } + /// /// Verifies building internals-only references leaves the shared worker input untouched, so /// the worker runs and the shim compilation keep binding against the raw references. @@ -407,16 +447,31 @@ public void Failed_EmptyReason_IsRejected(string reason) Assert.That(failed.ErrorMessage, Is.EqualTo("Resolution failed.")); } + // Searches the worker references' directories, as Build did before the caller supplied them. private HotReloadArtifactCompileReferences Build( TransformWorkerInputDto input, InternalsExposureTestImage target, bool exposeInternals) + { + return BuildSearching( + input, + target, + ReferencePublicizer.CollectResolverSearchDirectories(input.referencePaths), + exposeInternals); + } + + private HotReloadArtifactCompileReferences BuildSearching( + TransformWorkerInputDto input, + InternalsExposureTestImage target, + IReadOnlyCollection resolverSearchDirectories, + bool exposeInternals) { return HotReloadIntroducedTypeArtifactReferenceBuilder.Build( input, target.Home, HotReloadCompositionRoot.Services.Domain, _projectRoot, + resolverSearchDirectories, exposeInternals); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs index 36105404af..0a3da50857 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs @@ -625,6 +625,14 @@ public async Task Build_TypePreparationReportsATypeFailure_FailsTheRunWithATypeR response.Success, Is.False, "A refused type declaration must fail the run."); + Assert.That( + response.FailedTotal, + Is.EqualTo(0), + "No method row failed, so only the refused declaration can make the Outcome Failed."); + Assert.That( + response.Outcome, + Is.EqualTo("Failed"), + "A refused declaration alone must answer Failed."); Assert.That(response.IntroducedTypes.Count, Is.EqualTo(1)); Assert.That(response.IntroducedTypes[0].Kind, Is.EqualTo("Failed")); Assert.That(response.IntroducedTypes[0].Reason, Is.EqualTo(InjectedTypeFailureReason)); @@ -682,6 +690,10 @@ public async Task Build_TypeFailureBesideOtherFindings_KeepsTheReusesAndNotices( HotReloadResponse response = await RunAgainstTheHostAsync(); Assert.That(response.Success, Is.False, "A refused declaration must fail the run."); + Assert.That( + response.Outcome, + Is.EqualTo("Failed"), + "A refused declaration must answer Failed even beside a bound declaration, which counts as live."); Assert.That( CountTypeRows(response, "AlreadyActive"), Is.EqualTo(1), @@ -712,6 +724,7 @@ public async Task Build_PreparationWorkerFails_ReportsTheFailureWithoutAnyTypeRo HotReloadResponse response = await RunAgainstTheHostAsync(); Assert.That(response.Success, Is.False, "A failed preparation must fail the run."); + Assert.That(response.Outcome, Is.EqualTo("Failed"), "A failed preparation must answer Failed."); Assert.That( response.IntroducedTypes.Count, Is.EqualTo(0), diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeRetainedInternalReferrerE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeRetainedInternalReferrerE2ETests.cs index 448c6661b3..2bc431b658 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeRetainedInternalReferrerE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeRetainedInternalReferrerE2ETests.cs @@ -523,6 +523,37 @@ await RunInIntroducedTypeDomainAsync(async _ => }); } + /// + /// Verifies that a retained introduced type with a method whose signature names a type + /// reached only through another file's global using still has a body edit patched: the + /// reload that edits the body checks the declaration against its record with that using + /// in scope, as the reload that introduced it did. + /// + [Test] + public async Task Run_RetainedTypeWithGlobalAliasInASignature_BodyEditIsPatched() + { + const string callerExpression = "new RetainedGlobalAliasSignature().Read()"; + await RunInIntroducedTypeDomainAsync(async _ => + { + HotReloadOrchestratorResult first = await RunAsync( + "GlobalAliasSignatureFirst", + callerExpression, + Owner(OwnerPath, RetainedGlobalAliasSignature(1))); + AssertIntroduced(first, "RetainedGlobalAliasSignature"); + Assert.That(CallTheCaller(), Is.EqualTo(1), DescribeOutcomes(first)); + + HotReloadOrchestratorResult edited = await RunAsync( + "GlobalAliasSignatureSecond", + callerExpression, + Owner(OwnerPath, RetainedGlobalAliasSignature(2))); + + AssertAppliedWithoutSkips(edited); + AssertAlreadyActive(edited, "RetainedGlobalAliasSignature"); + AssertOutcome(edited, HotReloadMethodOutcomeKind.Patched, Namespace + ".RetainedGlobalAliasSignature.Read"); + Assert.That(CallTheCaller(), Is.EqualTo(2), DescribeOutcomes(edited)); + }); + } + // The modifier-less shape puts a doc comment and an attribute above the header, so the // header the worker rewrites is not the first token of the declaration. Why not // [Serializable]: a serializable type is never introduced at all. @@ -636,6 +667,18 @@ private static string RetainedInternalSignature(int value) + " }"; } + // HotReloadGlobalAlias resolves only through the test assembly's global using, and this + // file has no using directive of its own. + private static string RetainedGlobalAliasSignature(int value) + { + return "internal sealed class RetainedGlobalAliasSignature\n" + + " {\n" + + " " + NoInlining + "public int Read() { return " + value.ToString() + "; }\n" + + "\n" + + " public int Take(HotReloadGlobalAlias builder) { return 0; }\n" + + " }"; + } + // The compiled host file with an internal introduced type declared above the host class, // and the given members added to the host. private static Dictionary HostWithNeighbour(int readValue, string hostMembers) @@ -693,6 +736,20 @@ private static void AssertOutcome( Assert.Fail("No " + kind + " row mentions " + methodFragment + ".\n" + DescribeOutcomes(result)); } + private static void AssertAlreadyActive(HotReloadOrchestratorResult result, string simpleName) + { + foreach (HotReloadIntroducedTypeOutcome outcome in result.IntroducedTypes) + { + if (outcome.Kind == HotReloadIntroducedTypeOutcomeKind.AlreadyActive + && outcome.MetadataName == Namespace + "." + simpleName) + { + return; + } + } + + Assert.Fail(simpleName + " must be reported as already active.\n" + DescribeOutcomes(result)); + } + // Each method calling a non-public accessor is skipped for the given reason, and the methods // calling only public accessors are added and return what the caller sums. private static void AssertAccessorCallers(HotReloadOrchestratorResult result, string skipReasonFragment) diff --git a/Assets/Tests/Editor/HotReload/HotReloadMethodMatcherTests.cs b/Assets/Tests/Editor/HotReload/HotReloadMethodMatcherTests.cs index 5ffbb1f0b9..f32b3a7a3d 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadMethodMatcherTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadMethodMatcherTests.cs @@ -1,6 +1,9 @@ +using System; using System.IO; using System.Reflection; +using Mono.Cecil; + using NUnit.Framework; using UnityEngine; @@ -10,13 +13,41 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// - /// EditMode coverage for resolution and overload selection. + /// EditMode coverage for resolution and overload selection, + /// and for how often one matcher reads a compiled image. /// public class HotReloadMethodMatcherTests { private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; private const string FixtureTypeMetadataName = "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadCoreFixture"; + private const string SameNameFixtureTypeMetadataName = + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadPeelSameNameFixture"; + // Cecil names a nested type with '/', the separator the worker's rows carry. + private const string NestedFixtureTypeMetadataName = + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadBindingSplitNestedRegistry/Inner"; + private const string DomainTypeMetadataName = "io.github.hatayama.UnityCliLoop.FirstPartyTools.HotReloadDomain"; + private const string NotLoadedAssemblyName = "NotLoaded.Assembly.ForTest"; + + private string _tempDirectory; + + [SetUp] + public void SetUp() + { + _tempDirectory = Path.Combine( + Path.GetTempPath(), + "uloop-method-matcher-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDirectory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + } // The test assembly is itself a project assembly, so its compiled image is the one the // matcher reads: a ScriptAssemblies home naming it is what production passes in. @@ -32,13 +63,37 @@ private static string TestAssemblyDllPath } } + // The matcher's own assembly, Patching, is a second project assembly with an image of its + // own, so an entry whose home names it must be answered from that image and not from the + // test assembly's. + private static HotReloadTypeHome PatchingAssemblyHome + { + get + { + Assembly patchingAssembly = typeof(HotReloadMethodMatcher).Assembly; + return HotReloadTypeHome.ScriptAssemblies(patchingAssembly.GetName().Name, patchingAssembly.Location); + } + } + + private static MethodInfo StaticPingMethod => + typeof(HotReloadCoreFixture).GetMethod(nameof(HotReloadCoreFixture.StaticPing)); + + private static MethodInfo AddMethod => + typeof(HotReloadCoreFixture).GetMethod( + nameof(HotReloadCoreFixture.Add), + new[] { typeof(int), typeof(int) }); + + private string TempImagePath => Path.Combine(_tempDirectory, TestAssemblyName + ".dll"); + /// /// What: a known instance method resolves to the live MethodBase with matching MetadataToken identity. /// [Test] public void Resolve_KnownInstanceMethod_ReturnsLiveMethodBase() { - HotReloadMethodMatchResult result = HotReloadMethodMatcher.Resolve( + using HotReloadMethodMatcher matcher = HotReloadMethodMatcher.CreateReadingFromDisk(); + + HotReloadMethodMatchResult result = matcher.Resolve( TestAssemblyHome, FixtureTypeMetadataName, nameof(HotReloadCoreFixture.Add), @@ -62,7 +117,9 @@ public void Resolve_KnownInstanceMethod_ReturnsLiveMethodBase() [Test] public void Resolve_Overload_SelectsMatchingParameterTypes() { - HotReloadMethodMatchResult result = HotReloadMethodMatcher.Resolve( + using HotReloadMethodMatcher matcher = HotReloadMethodMatcher.CreateReadingFromDisk(); + + HotReloadMethodMatchResult result = matcher.Resolve( TestAssemblyHome, FixtureTypeMetadataName, nameof(HotReloadCoreFixture.Add), @@ -84,7 +141,9 @@ public void Resolve_Overload_SelectsMatchingParameterTypes() [Test] public void Resolve_ParameterTypeMismatch_ReturnsMethodNotFound() { - HotReloadMethodMatchResult result = HotReloadMethodMatcher.Resolve( + using HotReloadMethodMatcher matcher = HotReloadMethodMatcher.CreateReadingFromDisk(); + + HotReloadMethodMatchResult result = matcher.Resolve( TestAssemblyHome, FixtureTypeMetadataName, nameof(HotReloadCoreFixture.Add), @@ -95,13 +154,34 @@ public void Resolve_ParameterTypeMismatch_ReturnsMethodNotFound() Assert.That(result.FailureReason, Is.EqualTo(HotReloadMethodMatchFailureReason.MethodNotFound)); } + /// + /// What: a type the compiled image does not declare yields TypeNotFound. + /// + [Test] + public void Resolve_UnknownType_ReturnsTypeNotFound() + { + using HotReloadMethodMatcher matcher = HotReloadMethodMatcher.CreateReadingFromDisk(); + + HotReloadMethodMatchResult result = matcher.Resolve( + TestAssemblyHome, + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.NoSuchFixtureType", + nameof(HotReloadCoreFixture.StaticPing), + new string[0], + 0); + + Assert.That(result.Success, Is.False); + Assert.That(result.FailureReason, Is.EqualTo(HotReloadMethodMatchFailureReason.TypeNotFound)); + } + /// /// What: a static method with no parameters resolves correctly. /// [Test] public void Resolve_StaticMethod_ReturnsLiveMethodBase() { - HotReloadMethodMatchResult result = HotReloadMethodMatcher.Resolve( + using HotReloadMethodMatcher matcher = HotReloadMethodMatcher.CreateReadingFromDisk(); + + HotReloadMethodMatchResult result = matcher.Resolve( TestAssemblyHome, FixtureTypeMetadataName, nameof(HotReloadCoreFixture.StaticPing), @@ -124,14 +204,15 @@ public void Resolve_SameNameAndParameters_SelectsByGenericArity() "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload" + ".HotReloadSignatureChangeGenericCallerFixture"; string[] parameterTypeFullNames = { "System.Int32" }; + using HotReloadMethodMatcher matcher = HotReloadMethodMatcher.CreateReadingFromDisk(); - HotReloadMethodMatchResult nonGeneric = HotReloadMethodMatcher.Resolve( + HotReloadMethodMatchResult nonGeneric = matcher.Resolve( TestAssemblyHome, typeMetadataName, nameof(HotReloadSignatureChangeGenericCallerFixture.Caller), parameterTypeFullNames, 0); - HotReloadMethodMatchResult generic = HotReloadMethodMatcher.Resolve( + HotReloadMethodMatchResult generic = matcher.Resolve( TestAssemblyHome, typeMetadataName, nameof(HotReloadSignatureChangeGenericCallerFixture.Caller), @@ -169,5 +250,281 @@ public void ResolveLoadedMethod_MvidMismatch_ReturnsStaleAssembly() Assert.That(result.FailureReason, Is.EqualTo(HotReloadMethodMatchFailureReason.StaleAssembly)); Assert.That(result.Method, Is.Null); } + + /// + /// What: entries of one compiled image, on two types and one of them nested, are all + /// answered from a single read, each with the live method reflection finds. + /// + [Test] + public void Resolve_SeveralEntriesOfOneImage_ReadsTheImageOnce() + { + CountingLoader loader = new CountingLoader(); + using HotReloadMethodMatcher matcher = new HotReloadMethodMatcher(loader.Load); + + HotReloadMethodMatchResult add = ResolveAdd(matcher, TestAssemblyHome); + HotReloadMethodMatchResult otherType = matcher.Resolve( + TestAssemblyHome, + SameNameFixtureTypeMetadataName, + nameof(HotReloadPeelSameNameFixture.StaticPing), + new string[0], + 0); + HotReloadMethodMatchResult nested = matcher.Resolve( + TestAssemblyHome, + NestedFixtureTypeMetadataName, + nameof(HotReloadBindingSplitNestedRegistry.Inner.Raise), + new[] { "System.Int32" }, + 0); + + AssertResolvedTo(add, AddMethod); + AssertResolvedTo( + otherType, + typeof(HotReloadPeelSameNameFixture).GetMethod(nameof(HotReloadPeelSameNameFixture.StaticPing))); + AssertResolvedTo( + nested, + typeof(HotReloadBindingSplitNestedRegistry.Inner).GetMethod( + nameof(HotReloadBindingSplitNestedRegistry.Inner.Raise))); + Assert.That(loader.Loads, Is.EqualTo(1)); + } + + /// + /// What: a run that resolves entries of two compiled images, alternating between them, + /// reads each image once and answers every entry from the image its home names. + /// + [Test] + public void Resolve_TwoImages_ReadsEachOnceAndAnswersFromItsOwn() + { + CountingLoader loader = new CountingLoader(); + using HotReloadMethodMatcher matcher = new HotReloadMethodMatcher(loader.Load); + MethodInfo domainDispose = typeof(HotReloadDomain).GetMethod(nameof(HotReloadDomain.Dispose)); + + for (int round = 0; round < 2; round++) + { + AssertResolvedTo(ResolveStaticPing(matcher, TestAssemblyHome), StaticPingMethod); + HotReloadMethodMatchResult fromPatchingImage = matcher.Resolve( + PatchingAssemblyHome, + DomainTypeMetadataName, + nameof(HotReloadDomain.Dispose), + new string[0], + 0); + AssertResolvedTo(fromPatchingImage, domainDispose); + } + + Assert.That(loader.Loads, Is.EqualTo(2)); + } + + /// + /// What: an image that is not there yet is reported missing without being remembered, so + /// once the file appears the same matcher reads it and resolves the next entry. + /// + [Test] + public void Resolve_MissingImage_IsLookedForAgainOnTheNextEntry() + { + HotReloadTypeHome home = HotReloadTypeHome.ScriptAssemblies(TestAssemblyName, TempImagePath); + CountingLoader loader = new CountingLoader(); + using HotReloadMethodMatcher matcher = new HotReloadMethodMatcher(loader.Load); + + HotReloadMethodMatchResult missing = ResolveStaticPing(matcher, home); + Assert.That(missing.FailureReason, Is.EqualTo(HotReloadMethodMatchFailureReason.CompiledAssemblyNotFound)); + Assert.That(loader.Loads, Is.EqualTo(0)); + + File.Copy(TestAssemblyDllPath, TempImagePath); + AssertResolvedTo(ResolveStaticPing(matcher, home), StaticPingMethod); + Assert.That(loader.Loads, Is.EqualTo(1)); + } + + /// + /// What: a read that throws reaches the caller and leaves nothing behind, so the next entry + /// of the same matcher reads the image again and resolves. + /// + [Test] + public void Resolve_ImageThatFailedToRead_IsReadAgainOnTheNextEntry() + { + CountingLoader loader = new CountingLoader { FailsReads = true }; + using HotReloadMethodMatcher matcher = new HotReloadMethodMatcher(loader.Load); + + Assert.Throws(() => ResolveStaticPing(matcher, TestAssemblyHome)); + + loader.FailsReads = false; + AssertResolvedTo(ResolveStaticPing(matcher, TestAssemblyHome), StaticPingMethod); + Assert.That(loader.Loads, Is.EqualTo(2)); + } + + /// + /// What: once a matcher has read an image, deleting the file does not change its answers: + /// a later entry of the same run still resolves from that read. + /// + [Test] + public void Resolve_ImageDeletedAfterTheFirstRead_StillAnswersFromThatRead() + { + HotReloadTypeHome home = CopyTestAssemblyHome(); + CountingLoader loader = new CountingLoader(); + using HotReloadMethodMatcher matcher = new HotReloadMethodMatcher(loader.Load); + AssertResolvedTo(ResolveStaticPing(matcher, home), StaticPingMethod); + + File.Delete(home.DllPath); + + AssertResolvedTo(ResolveAdd(matcher, home), AddMethod); + Assert.That(loader.Loads, Is.EqualTo(1)); + } + + /// + /// What: a matcher keeps answering from the image it read after the file is replaced by + /// another image, while a new matcher, as the next run makes, reads the replacement. + /// + [Test] + public void Resolve_ImageReplacedAfterTheFirstRead_SameMatcherKeepsItsRead_AndANewMatcherReadsTheNewImage() + { + HotReloadTypeHome home = CopyTestAssemblyHome(); + using HotReloadMethodMatcher matcher = HotReloadMethodMatcher.CreateReadingFromDisk(); + AssertResolvedTo(ResolveStaticPing(matcher, home), StaticPingMethod); + + File.Copy(typeof(HotReloadMethodMatcher).Assembly.Location, home.DllPath, overwrite: true); + + AssertResolvedTo(ResolveAdd(matcher, home), AddMethod); + using HotReloadMethodMatcher nextRunMatcher = HotReloadMethodMatcher.CreateReadingFromDisk(); + HotReloadMethodMatchResult fromReplacement = ResolveAdd(nextRunMatcher, home); + Assert.That(fromReplacement.FailureReason, Is.EqualTo(HotReloadMethodMatchFailureReason.TypeNotFound)); + } + + /// + /// What: disposing a matcher twice is harmless, and a disposed matcher refuses to resolve + /// instead of answering from a read it has released. + /// + [Test] + public void Resolve_AfterDispose_Throws() + { + HotReloadMethodMatcher matcher = HotReloadMethodMatcher.CreateReadingFromDisk(); + AssertResolvedTo(ResolveStaticPing(matcher, TestAssemblyHome), StaticPingMethod); + + matcher.Dispose(); + Assert.DoesNotThrow(matcher.Dispose); + + Assert.Throws(() => ResolveStaticPing(matcher, TestAssemblyHome)); + } + + /// + /// What: an image whose assembly is not loaded reports AssemblyNotLoaded for every entry, + /// because the loaded-assembly check runs per entry, while the image itself is read once. + /// + [Test] + public void Resolve_AssemblyNotLoaded_IsReportedForEveryEntry_WithOneRead() + { + HotReloadTypeHome home = HotReloadTypeHome.ScriptAssemblies(NotLoadedAssemblyName, CopyTestAssembly()); + CountingLoader loader = new CountingLoader(); + using HotReloadMethodMatcher matcher = new HotReloadMethodMatcher(loader.Load); + + HotReloadMethodMatchResult first = ResolveStaticPing(matcher, home); + HotReloadMethodMatchResult second = ResolveStaticPing(matcher, home); + + Assert.That(first.FailureReason, Is.EqualTo(HotReloadMethodMatchFailureReason.AssemblyNotLoaded)); + Assert.That(second.FailureReason, Is.EqualTo(HotReloadMethodMatchFailureReason.AssemblyNotLoaded)); + Assert.That(loader.Loads, Is.EqualTo(1)); + } + + /// + /// What: two homes that name the same image under different assembly names share one read, + /// but each is checked against the loaded assemblies on its own, so the home whose assembly + /// is not loaded reports AssemblyNotLoaded after the other home resolved. + /// + [Test] + public void Resolve_SameImageUnderTwoHomes_ChecksTheLoadedAssemblyForEachHome() + { + HotReloadTypeHome notLoadedHome = + HotReloadTypeHome.ScriptAssemblies(NotLoadedAssemblyName, TestAssemblyDllPath); + CountingLoader loader = new CountingLoader(); + using HotReloadMethodMatcher matcher = new HotReloadMethodMatcher(loader.Load); + + AssertResolvedTo(ResolveStaticPing(matcher, TestAssemblyHome), StaticPingMethod); + HotReloadMethodMatchResult underNotLoadedName = ResolveStaticPing(matcher, notLoadedHome); + + Assert.That(underNotLoadedName.FailureReason, Is.EqualTo(HotReloadMethodMatchFailureReason.AssemblyNotLoaded)); + Assert.That(loader.Loads, Is.EqualTo(1)); + } + + /// + /// What: a loader that returns no image breaks its contract, so the matcher throws instead + /// of resolving against nothing, and remembers nothing: the next entry reads the image again. + /// + [Test] + public void Resolve_LoaderReturningNull_Throws_AndTheImageIsReadAgainOnTheNextEntry() + { + CountingLoader loader = new CountingLoader { ReturnsNoImage = true }; + using HotReloadMethodMatcher matcher = new HotReloadMethodMatcher(loader.Load); + + Assert.Throws(() => ResolveStaticPing(matcher, TestAssemblyHome)); + + loader.ReturnsNoImage = false; + AssertResolvedTo(ResolveStaticPing(matcher, TestAssemblyHome), StaticPingMethod); + Assert.That(loader.Loads, Is.EqualTo(2)); + } + + private static HotReloadMethodMatchResult ResolveStaticPing(HotReloadMethodMatcher matcher, HotReloadTypeHome home) + { + return matcher.Resolve( + home, + FixtureTypeMetadataName, + nameof(HotReloadCoreFixture.StaticPing), + new string[0], + 0); + } + + private static HotReloadMethodMatchResult ResolveAdd(HotReloadMethodMatcher matcher, HotReloadTypeHome home) + { + return matcher.Resolve( + home, + FixtureTypeMetadataName, + nameof(HotReloadCoreFixture.Add), + new[] { "System.Int32", "System.Int32" }, + 0); + } + + private static void AssertResolvedTo(HotReloadMethodMatchResult result, MethodInfo expected) + { + Assert.That(expected, Is.Not.Null, "Reflection must find the method the entry names."); + Assert.That(result.Success, Is.True, result.FailureReason + ": " + result.ErrorMessage); + Assert.That(result.Method, Is.EqualTo(expected)); + } + + // A copy of the test assembly's image keeps the loaded assembly's Mvid, so it resolves + // like the original while the test is free to delete or replace the file. + private HotReloadTypeHome CopyTestAssemblyHome() + { + return HotReloadTypeHome.ScriptAssemblies(TestAssemblyName, CopyTestAssembly()); + } + + private string CopyTestAssembly() + { + File.Copy(TestAssemblyDllPath, TempImagePath); + return TempImagePath; + } + + /// + /// A loader that counts every read it is asked for, failed ones included, and can be told + /// to fail them or to return no image. + /// + private sealed class CountingLoader + { + public int Loads { get; private set; } + + public bool FailsReads { get; set; } + + public bool ReturnsNoImage { get; set; } + + public AssemblyDefinition Load(string dllPath) + { + Loads++; + if (FailsReads) + { + throw new IOException("Simulated read failure: " + dllPath); + } + + if (ReturnsNoImage) + { + return null; + } + + return HotReloadMethodMatcher.ReadCompiledAssembly(dllPath); + } + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadOrchestratorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadOrchestratorTests.cs index 14bce83078..bb1b120ba8 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadOrchestratorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadOrchestratorTests.cs @@ -186,6 +186,48 @@ public async Task Run_EditedPrivateAccessMethod_PatchesBehavior() Assert.That(fixture.ComputeWithPrivate(5), Is.EqualTo(10 + 5 + 100)); } + /// + /// What: an apply run reports the time it spent per phase through the production path, + /// from the run down to each group. The worker and the shim compile always take time, and + /// the total covers every phase. + /// + [Test] + public async Task Run_PatchedMethod_ReportsPhaseTimings() + { + string fixturePath = ResolveE2EFixturePath(); + string editedPath = WriteEditedSource( + "PhaseTimings.cs", + BuildFixtureSource( + computeWithPrivateMethod: + "public int ComputeWithPrivate(int delta)\n {\n return _secret + delta + 100;\n }")); + + 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); + } + + AssertNoFileLevelFailure(result); + AssertHasPatched(result, nameof(HotReloadE2EFixture.ComputeWithPrivate)); + HotReloadTimingBreakdown timing = result.Timing; + Assert.That(timing, Is.Not.Null); + Assert.That(timing.AnalysisMs, Is.GreaterThan(0), "AnalysisMs"); + Assert.That(timing.ShimCompileMs, Is.GreaterThan(0), "ShimCompileMs"); + Assert.That( + timing.TotalMs, + Is.GreaterThanOrEqualTo(timing.AnalysisMs + timing.ShimCompileMs + timing.PatchMs), + "TotalMs"); + } + /// /// What: a Patched-only apply arms the Auto Refresh hold so focus return cannot recompile. /// @@ -803,6 +845,212 @@ public async Task Run_EditedMethodUsingSiblingGlobalUsing_PatchesBehavior() Assert.That(fixture.BuildWithGlobalAlias(), Is.EqualTo("patched")); } + /// + /// What: a body edit to an existing method whose parameter type comes only from a + /// sibling-file global using alias is patched instead of being classified as added. + /// + [Test] + public async Task Run_EditedMethodWithGlobalUsingAliasParameter_PatchesBehavior() + { + string fixturePath = ResolveShapeFixturePath(); + string onDisk = File.ReadAllText(fixturePath); + string editedSource = onDisk.Replace( + "return builder.Length;", + "return builder.Length + 1;", + StringComparison.Ordinal); + Assert.That(editedSource, Is.Not.EqualTo(onDisk), "Precondition: global-alias parameter body must differ."); + + string editedPath = WriteEditedSource("MeasureWithGlobalAliasParameter.cs", editedSource); + + HotReloadGlobalUsingFixture fixture = new HotReloadGlobalUsingFixture(); + Assert.That(fixture.MeasureWithGlobalAliasParameter(new HotReloadGlobalAlias("ab")), Is.EqualTo(2)); + + HotReloadOrchestratorResult result = await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { fixturePath }, + editedPath, + CancellationToken.None); + + AssertNoFileLevelFailure(result); + AssertHasPatched(result, nameof(HotReloadGlobalUsingFixture.MeasureWithGlobalAliasParameter)); + Assert.That(fixture.MeasureWithGlobalAliasParameter(new HotReloadGlobalAlias("ab")), Is.EqualTo(3)); + } + + /// + /// What: a body edit to an existing method whose return type comes only from a + /// sibling-file global using alias is patched instead of being classified as added. + /// + [Test] + public async Task Run_EditedMethodWithGlobalUsingAliasReturnType_PatchesBehavior() + { + string fixturePath = ResolveShapeFixturePath(); + string onDisk = File.ReadAllText(fixturePath); + string editedSource = onDisk.Replace( + "builder.Append(\"return-base\");", + "builder.Append(\"return-patched\");", + StringComparison.Ordinal); + Assert.That(editedSource, Is.Not.EqualTo(onDisk), "Precondition: global-alias return body must differ."); + + string editedPath = WriteEditedSource("CreateWithGlobalAliasReturn.cs", editedSource); + + HotReloadGlobalUsingFixture fixture = new HotReloadGlobalUsingFixture(); + Assert.That(fixture.CreateWithGlobalAliasReturn().ToString(), Is.EqualTo("return-base")); + + HotReloadOrchestratorResult result = await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { fixturePath }, + editedPath, + CancellationToken.None); + + AssertNoFileLevelFailure(result); + AssertHasPatched(result, nameof(HotReloadGlobalUsingFixture.CreateWithGlobalAliasReturn)); + Assert.That(fixture.CreateWithGlobalAliasReturn().ToString(), Is.EqualTo("return-patched")); + } + + /// + /// What: a body edit to an existing method that reads a field whose type comes only from a + /// sibling-file global using alias is patched. + /// + [Test] + public async Task Run_EditedMethodReadingFieldOfGlobalUsingAliasType_PatchesBehavior() + { + string fixturePath = ResolveShapeFixturePath(); + string onDisk = File.ReadAllText(fixturePath); + string editedSource = onDisk.Replace( + "return _buffer.Length;", + "return _buffer.Length + 1;", + StringComparison.Ordinal); + Assert.That(editedSource, Is.Not.EqualTo(onDisk), "Precondition: global-alias field reader body must differ."); + + string editedPath = WriteEditedSource("BufferLength.cs", editedSource); + + HotReloadGlobalUsingFixture fixture = new HotReloadGlobalUsingFixture(); + Assert.That(fixture.BufferLength(), Is.EqualTo(3)); + + HotReloadOrchestratorResult result = await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { fixturePath }, + editedPath, + CancellationToken.None); + + AssertNoFileLevelFailure(result); + AssertHasPatched(result, nameof(HotReloadGlobalUsingFixture.BufferLength)); + Assert.That(fixture.BufferLength(), Is.EqualTo(4)); + } + + /// + /// What: a body edit that reads a member of a base class visible only through a + /// sibling-file global using of its namespace is patched. + /// + [Test] + public async Task Run_EditedMethodReadingBaseMemberFromGlobalUsingNamespace_PatchesBehavior() + { + string fixturePath = ResolveShapeFixturePath(); + string onDisk = File.ReadAllText(fixturePath); + string editedSource = onDisk.Replace( + "return BaseOffset + x;", + "return BaseOffset + x + 1;", + StringComparison.Ordinal); + Assert.That(editedSource, Is.Not.EqualTo(onDisk), "Precondition: base-member reader body must differ."); + + string editedPath = WriteEditedSource("OffsetPlus.cs", editedSource); + + HotReloadGlobalUsingDerivedFixture fixture = new HotReloadGlobalUsingDerivedFixture(); + Assert.That(fixture.OffsetPlus(1), Is.EqualTo(11)); + + HotReloadOrchestratorResult result = await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { fixturePath }, + editedPath, + CancellationToken.None); + + AssertNoFileLevelFailure(result); + AssertHasPatched(result, nameof(HotReloadGlobalUsingDerivedFixture.OffsetPlus)); + Assert.That(fixture.OffsetPlus(1), Is.EqualTo(12)); + } + + /// + /// What: a body edit to an existing method whose parameter type is an internal type of + /// another file is patched, and no row of its fixture is Skipped. The worker binds that + /// type as an inaccessible error type, which must not count as an unresolved signature type. + /// + [Test] + public async Task Run_EditedMethodWithInternalParameterTypeFromAnotherFile_PatchesBehavior() + { + string fixturePath = ResolveShapeFixturePath(); + string onDisk = File.ReadAllText(fixturePath); + string editedSource = onDisk.Replace( + "return probe == null ? 1 : 2;", + "return probe == null ? 11 : 12;", + StringComparison.Ordinal); + Assert.That(editedSource, Is.Not.EqualTo(onDisk), "Precondition: internal-signature body must differ."); + + string editedPath = WriteEditedSource("CountProbe.cs", editedSource); + + HotReloadInternalSignatureFixture fixture = new HotReloadInternalSignatureFixture(); + Assert.That(fixture.CountProbe(null), Is.EqualTo(1)); + + HotReloadOrchestratorResult result = await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { fixturePath }, + editedPath, + CancellationToken.None); + + AssertNoFileLevelFailure(result); + AssertHasPatched(result, nameof(HotReloadInternalSignatureFixture.CountProbe)); + // Why only this fixture's rows: the shape fixture file holds other fixtures, and one of + // them may be skipped on purpose without saying anything about the inaccessible type. + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + if (!outcome.Method.Contains(nameof(HotReloadInternalSignatureFixture) + ".")) + { + continue; + } + + Assert.That( + outcome.Kind, + Is.Not.EqualTo(HotReloadMethodOutcomeKind.Skipped), + "The internal-signature fixture must not be skipped.\n" + FormatOutcomes(result)); + } + + Assert.That(fixture.CountProbe(null), Is.EqualTo(11)); + } + + /// + /// What: a method added next to one whose parameter type is an internal type of another + /// file, with that same type in its own signature, is added and its caller is patched. The + /// worker binds that type as an inaccessible error type, which must not be reported as an + /// unresolved signature type for an added method either. + /// + [Test] + public async Task Run_AddedMethodWithInternalParameterTypeFromAnotherFile_IsAdded() + { + string fixturePath = ResolveShapeFixturePath(); + string onDisk = File.ReadAllText(fixturePath); + string editedSource = onDisk.Replace( + " return probe == null ? 1 : 2;\n }\n", + " return AddedCountProbe(probe);\n }\n\n" + + " public int AddedCountProbe(HotReloadInternalSignatureProbe probe)\n" + + " {\n" + + " return probe == null ? 21 : 22;\n" + + " }\n", + StringComparison.Ordinal); + Assert.That(editedSource, Is.Not.EqualTo(onDisk), "Precondition: the added method must be inserted."); + + string editedPath = WriteEditedSource("AddedCountProbe.cs", editedSource); + + HotReloadInternalSignatureFixture fixture = new HotReloadInternalSignatureFixture(); + Assert.That(fixture.CountProbe(null), Is.EqualTo(1)); + + HotReloadOrchestratorResult result = await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { fixturePath }, + editedPath, + CancellationToken.None); + + AssertNoFileLevelFailure(result); + // Why the type-qualified names: "CountProbe" alone is also part of "AddedCountProbe". + AssertHasAdded(result, nameof(HotReloadInternalSignatureFixture) + ".AddedCountProbe("); + AssertHasPatched( + result, + nameof(HotReloadInternalSignatureFixture) + "." + nameof(HotReloadInternalSignatureFixture.CountProbe) + "("); + Assert.That(fixture.CountProbe(null), Is.EqualTo(21)); + } + /// /// What: under Debug code optimization, size-only small methods do not emit the /// aggregated inline-risk warning (branch a); Patched Reason stays empty. diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs new file mode 100644 index 0000000000..8209232bd4 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// A partial type deriving from , so its edited bodies can + /// reach the protected members of a compiled base type. + /// + public partial class HotReloadPartialDerivedFixture : HotReloadInternalMemberHost + { + private int _seed = 1000; + + [MethodImpl(MethodImplOptions.NoInlining)] + public int DerivedValue() + { + return 9; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int ClosureValue() + { + Func read = () => 30; + return read(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int ClosureSeedValue() + { + Func read = () => _seed; + return read(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public IEnumerable IteratorValues() + { + yield return _seed; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int ClosureSeedPlusValue() + { + Func read = () => this._seed; + return read() + 7; + } + + public int DerivedProperty + { + [MethodImpl(MethodImplOptions.NoInlining)] + get { return 40; } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public async System.Threading.Tasks.Task AsyncValue() + { + await System.Threading.Tasks.Task.CompletedTask; + return 50; + } + + public event Action DerivedEvent; + + [MethodImpl(MethodImplOptions.NoInlining)] + public int RaiseDerivedEvent() + { + DerivedEvent?.Invoke(); + return 60; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs.meta new file mode 100644 index 0000000000..07b4403f42 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4173d2e8c5b8c4f6592fccd1ad5a5eb8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialInternalGuardedPeer.Extra.cs b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalGuardedPeer.Extra.cs new file mode 100644 index 0000000000..4e2bf02d05 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalGuardedPeer.Extra.cs @@ -0,0 +1,19 @@ +#if UNITY_EDITOR +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// The other part of . The whole file sits in a + /// conditional-compilation block, the way an editor-only or debug-only part usually does. + /// + public partial class HotReloadPartialInternalGuardedPeer + { + [MethodImpl(MethodImplOptions.NoInlining)] + internal int GuardedPeerInternalValue() + { + return 12; + } + } +} +#endif diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialInternalGuardedPeer.Extra.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalGuardedPeer.Extra.cs.meta new file mode 100644 index 0000000000..45ed9d9773 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalGuardedPeer.Extra.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c534c4bfd695c4efa9de7002d82bb910 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.Extra.cs b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.Extra.cs new file mode 100644 index 0000000000..20011441fe --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.Extra.cs @@ -0,0 +1,17 @@ +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// The other part of , holding the internal method the + /// visibility repro tests call. + /// + public partial class HotReloadPartialInternalPeer + { + [MethodImpl(MethodImplOptions.NoInlining)] + internal int PeerInternalValue() + { + return 11; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.Extra.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.Extra.cs.meta new file mode 100644 index 0000000000..989f477d61 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.Extra.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b9bd97bfefce64410a8e7eb97a622374 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.cs b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.cs new file mode 100644 index 0000000000..25953d41dc --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.cs @@ -0,0 +1,30 @@ +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// The main part of a partial type whose internal method lives in another file. No test passes + /// this file, so the worker sees the type as compiled. + /// + public partial class HotReloadPartialInternalPeer + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int PeerMain() + { + return 1; + } + } + + /// + /// The main part of a partial type whose internal method lives in a file wrapped in a + /// conditional-compilation block. + /// + public partial class HotReloadPartialInternalGuardedPeer + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int GuardedPeerMain() + { + return 2; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.cs.meta new file mode 100644 index 0000000000..14240d0979 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a28b8e4d2be89448dbe32de6cef97290 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialTypeE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeE2ETests.cs new file mode 100644 index 0000000000..e41356a5bf --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeE2ETests.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +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 for method edits on a partial type: each edit is applied and + /// the patched method returns the new value at run time. + /// + public class HotReloadPartialTypeE2ETests + { + private const string FixtureFileName = "HotReloadPartialTypeFixture.cs"; + private const string OtherPartFileName = "HotReloadPartialTypeFixture.Other.cs"; + private const string OwnOnlyDeclaration = + " public int OwnOnly()\n {\n return 1;\n }"; + private const string OwnOnlyEdited = + " public int OwnOnly()\n {\n return 2;\n }"; + + private HotReloadDomainTestScope _scope; + + [SetUp] + public void SetUp() + { + _scope = new HotReloadDomainTestScope(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + } + + [TearDown] + public void TearDown() + { + _scope.Dispose(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + VibeLogger.ClearMemoryLogs(); + } + + /// + /// What: a method that reads a private field declared in another part of the type is + /// patched and returns the edited value. + /// + [Test] + public async Task Run_PartialTypeMethodReadingOtherPartPrivateField_PatchesBehavior() + { + HotReloadOrchestratorResult result = await RunEditedFixtureAsync( + "PartialE2EReadsOtherPartField.cs", + "return _otherSeed;", + "return _otherSeed + 100;"); + + AssertNoFileLevelFailure(result); + AssertPatched(result, "ReadsOtherPartField"); + Assert.That(new HotReloadPartialTypeFixture().ReadsOtherPartField(), Is.EqualTo(105)); + } + + /// + /// What: a method that calls a private method declared in another part of the type is + /// patched and returns the edited value. + /// + [Test] + public async Task Run_PartialTypeMethodCallingOtherPartPrivateMethod_PatchesBehavior() + { + HotReloadOrchestratorResult result = await RunEditedFixtureAsync( + "PartialE2ECallsOtherPartMethod.cs", + "return OtherPartValue();", + "return OtherPartValue() + 100;"); + + AssertNoFileLevelFailure(result); + AssertPatched(result, "CallsOtherPartMethod"); + Assert.That(new HotReloadPartialTypeFixture().CallsOtherPartMethod(), Is.EqualTo(107)); + } + + /// + /// What: in a partial type nested in a partial type, a method that reads a field declared in + /// the nested type's other part is patched and returns the edited value. + /// + [Test] + public async Task Run_NestedPartialTypeMethodReadingOtherPartField_PatchesBehavior() + { + HotReloadOrchestratorResult result = await RunEditedFixtureAsync( + "PartialE2ENestedReadsOtherPartField.cs", + "return _nestedSeed;", + "return _nestedSeed + 100;"); + + AssertNoFileLevelFailure(result); + AssertPatched(result, "ReadsOtherPartNestedField"); + Assert.That(new HotReloadPartialTypeFixture.NestedPartial().ReadsOtherPartNestedField(), Is.EqualTo(113)); + } + + /// + /// What: when both parts of the type are passed, a method edited in each part is patched and + /// returns its edited value. + /// + [Test] + public async Task Run_BothPartsOfAPartialTypeEdited_PatchesBoth() + { + string fixturePath = FixturePath(FixtureFileName); + string otherPartPath = FixturePath(OtherPartFileName); + + HotReloadOrchestratorResult result = await RunAsync( + new[] { fixturePath, otherPartPath }, + new Dictionary + { + [fixturePath] = WriteEdited(fixturePath, "PartialE2EBothPartsMain.cs", OwnOnlyDeclaration, OwnOnlyEdited), + [otherPartPath] = WriteEdited( + otherPartPath, + "PartialE2EBothPartsOther.cs", + "return PartialTuning - 1;", + "return PartialTuning - 1 + 100;") + }); + + AssertNoFileLevelFailure(result); + AssertPatched(result, "OwnOnly"); + AssertPatched(result, "OtherPartOwnMethod"); + HotReloadPartialTypeFixture fixture = new HotReloadPartialTypeFixture(); + Assert.That(fixture.OwnOnly(), Is.EqualTo(2)); + Assert.That(fixture.OtherPartOwnMethod(), Is.EqualTo(103)); + } + + /// + /// What: when both parts of the type are passed and one of them changes a const, the const + /// drift is reported once, not once per part. + /// + [Test] + public async Task Run_PartialTypeConstDriftWithBothPartsEdited_WarnsOnce() + { + string fixturePath = FixturePath(FixtureFileName); + string otherPartPath = FixturePath(OtherPartFileName); + + HotReloadOrchestratorResult result = await RunAsync( + new[] { fixturePath, otherPartPath }, + new Dictionary + { + [fixturePath] = WriteEdited(fixturePath, "PartialE2EConstDriftMain.cs", OwnOnlyDeclaration, OwnOnlyEdited), + [otherPartPath] = WriteEdited( + otherPartPath, + "PartialE2EConstDriftOther.cs", + "private const int PartialTuning = 4;", + "private const int PartialTuning = 6;") + }); + + AssertNoFileLevelFailure(result); + int driftCount = 0; + foreach (string warning in result.Warnings) + { + if (warning.Contains("PartialTuning")) + { + driftCount++; + } + } + + Assert.That( + driftCount, + Is.EqualTo(1), + "Expected exactly one drift warning for PartialTuning.\n" + string.Join("\n", result.Warnings)); + } + + /// + /// What: a method that uses members of its own part is patched and returns the edited value. + /// This pins that the edited file is never also read as another part of its own type: the + /// second copy of _ownSeed would make the name ambiguous and fail the shim compile. + /// + [Test] + public async Task Run_PartialTypeMethodUsingOwnPartMembers_PatchesBehavior() + { + HotReloadOrchestratorResult result = await RunEditedFixtureAsync( + "PartialE2EUsesOwnPartMembers.cs", + "return OwnOnly() + _ownSeed;", + "return OwnOnly() + _ownSeed + 100;"); + + AssertNoFileLevelFailure(result); + AssertPatched(result, "CallsOwnPartMembers"); + Assert.That(new HotReloadPartialTypeFixture().CallsOwnPartMembers(), Is.EqualTo(103)); + } + + /// + /// What: a method that passes its own instance to a compiled API of the same assembly is + /// patched and returns the edited value. + /// + [Test] + public async Task Run_PartialTypeMethodPassingItselfToACompiledApi_PatchesBehavior() + { + HotReloadOrchestratorResult result = await RunEditedFixtureAsync( + "PartialE2EPassesThis.cs", + "return HotReloadPartialTypeFixtureConsumer.Describe(this);", + "return HotReloadPartialTypeFixtureConsumer.Describe(this) + 100;"); + + AssertNoFileLevelFailure(result); + AssertPatched(result, "PassesThisToCompiledApi"); + Assert.That(new HotReloadPartialTypeFixture().PassesThisToCompiledApi(), Is.EqualTo(103)); + } + + private static async Task RunEditedFixtureAsync( + string editedFileName, + string fragment, + string replacement) + { + string fixturePath = FixturePath(FixtureFileName); + return await RunAsync( + new[] { fixturePath }, + new Dictionary + { + [fixturePath] = WriteEdited(fixturePath, editedFileName, fragment, replacement) + }); + } + + private static async Task RunAsync( + string[] files, + Dictionary contentPathOverrideByFile) + { + return await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + files, + contentPathOverride: null, + CancellationToken.None, + contentPathOverrideByFile); + } + + // Why the uniqueness check: a fragment that also matched another member would edit a method + // the test does not call, and the assert would pass or fail for the wrong reason. + private static string WriteEdited(string sourcePath, string editedFileName, string fragment, string replacement) + { + string source = File.ReadAllText(sourcePath); + int first = source.IndexOf(fragment, StringComparison.Ordinal); + Assert.That(first, Is.GreaterThanOrEqualTo(0), "Fragment missing from the fixture: " + fragment); + Assert.That( + source.IndexOf(fragment, first + fragment.Length, StringComparison.Ordinal), + Is.EqualTo(-1), + "Fragment occurs more than once in the fixture: " + fragment); + return HotReloadTestSourceWriter.WriteEditedSource( + editedFileName, + source.Replace(fragment, replacement, StringComparison.Ordinal)); + } + + private static string FixturePath(string fileName) + { + string path = Path.GetFullPath(Path.Combine(Application.dataPath, "Tests", "Editor", "HotReload", fileName)); + Assert.That(File.Exists(path), Is.True, "Fixture missing: " + path); + return path; + } + + private static void AssertNoFileLevelFailure(HotReloadOrchestratorResult result) + { + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + if (outcome.Kind == HotReloadMethodOutcomeKind.Failed + && (outcome.Method == "(file)" || outcome.Method == "(shim-compile)")) + { + Assert.Fail("Unexpected file-level failure: " + outcome.Reason + "\n" + FormatOutcomes(result)); + } + } + } + + private static void AssertPatched(HotReloadOrchestratorResult result, string methodName) + { + // Why the dot and the parenthesis: a bare name would also match a longer method name + // that contains it. + string labelPart = "." + methodName + "("; + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + if (outcome.Kind == HotReloadMethodOutcomeKind.Patched + && outcome.Method != null + && outcome.Method.Contains(labelPart)) + { + return; + } + } + + Assert.Fail("Expected Patched for " + methodName + ".\n" + FormatOutcomes(result)); + } + + private static string FormatOutcomes(HotReloadOrchestratorResult result) + { + List lines = new List(); + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + lines.Add(outcome.Kind + " " + outcome.Method + " @" + outcome.FilePath + " :: " + outcome.Reason); + } + + return string.Join("\n", lines) + "\nWarnings:\n" + string.Join("\n", result.Warnings); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialTypeE2ETests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeE2ETests.cs.meta new file mode 100644 index 0000000000..ff4146d9be --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeE2ETests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 012af1d6cc062419a9df9178d65ca037 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.Other.cs b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.Other.cs new file mode 100644 index 0000000000..521d119924 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.Other.cs @@ -0,0 +1,68 @@ +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// The other part of . Most tests leave this file out of + /// the run, so the members declared here are visible to the edited part only when hot reload reads + /// this file itself. + /// + public partial class HotReloadPartialTypeFixture + { + private const int PartialTuning = 4; + private static int _otherStaticSeed = 17; + private int _otherSeed = 5; + + private int OtherPartProperty + { + get { return 11; } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private int OtherPartValue() + { + return 7; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int OtherPartOwnMethod() + { + return PartialTuning - 1; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + internal int OtherPartInternalValue() + { + return 12; + } + + /// + /// A type only this part declares, named in a method signature of the edited part. + /// + public sealed class OtherPartNested + { + public int Number = 9; + } + + /// + /// The other part of the nested partial type, holding the field the edited part reads. + /// + public partial class NestedPartial + { + private int _nestedSeed = 13; + } + } + + /// + /// Not partial and not in the edited file: stays a compiled type in every run, so its + /// parameter keeps naming the compiled copy of the fixture. + /// + public static class HotReloadPartialTypeFixtureConsumer + { + [MethodImpl(MethodImplOptions.NoInlining)] + public static int Describe(HotReloadPartialTypeFixture fixture) + { + return fixture.OtherPartOwnMethod(); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.Other.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.Other.cs.meta new file mode 100644 index 0000000000..9509264aa2 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.Other.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: de881919fc7524eb590558823413a4c3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.cs new file mode 100644 index 0000000000..549f05f63e --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.cs @@ -0,0 +1,105 @@ +using System.Linq; +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// The part of a partial type that hot-reload tests edit. Several bodies name members that only + /// HotReloadPartialTypeFixture.Other.cs declares, so a run given this file alone has to read that + /// other part to bind them. + /// + public partial class HotReloadPartialTypeFixture + { + private int _ownSeed = 2; + + [MethodImpl(MethodImplOptions.NoInlining)] + public int OwnOnly() + { + return 1; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int CallsOwnPartMembers() + { + return OwnOnly() + _ownSeed; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int ReadsOtherPartField() + { + return _otherSeed; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int CallsOtherPartMethod() + { + return OtherPartValue(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int TakesOtherPartNested(OtherPartNested value) + { + return value.Number; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int PassesThisToCompiledApi() + { + return HotReloadPartialTypeFixtureConsumer.Describe(this); + } + + public int ReadsOtherPartProperty + { + get { return OtherPartProperty; } + } + + /// + /// A partial type nested in the fixture whose other part holds the field it reads. + /// + public partial class NestedPartial + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int ReadsOtherPartNestedField() + { + return _nestedSeed; + } + } + + /// + /// A non-partial type nested in the fixture that reads a static field only the outer type's + /// other part declares. + /// + public sealed class NestedPlain + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int ReadsOuterOtherPartStatic() + { + return _otherStaticSeed; + } + } + } + + /// + /// A second declaration of the same partial type in the same file. + /// + public partial class HotReloadPartialTypeFixture + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int SecondBlockMethod() + { + return 21; + } + } + + /// + /// A partial struct, which hot reload skips for being a struct rather than for being partial. + /// + public partial struct HotReloadPartialStructFixture + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int StructValue() + { + return 1; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.cs.meta new file mode 100644 index 0000000000..af77b9f511 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7699e1bb13b8a4aa19190550d9b4f292 +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 5554e57c9c..177b03aacb 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs @@ -215,7 +215,8 @@ private static IDisposable BeginReplacementWith(IHotReloadHarmony harmony) new HotReloadEditorStateSnapshotCapture(), TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, - new HotReloadApplicationPlayModeQuery())); + new HotReloadApplicationPlayModeQuery(), + new HotReloadSourceSnapshotCapture(() => { }))); } private static MethodInfo GetPatchTarget() diff --git a/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs index 459588393a..0f9f3528cc 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs @@ -89,7 +89,8 @@ private static IDisposable BeginReplacementWith(IHotReloadHarmony harmony) new HotReloadEditorStateSnapshotCapture(), TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, - new HotReloadApplicationPlayModeQuery())); + new HotReloadApplicationPlayModeQuery(), + new HotReloadSourceSnapshotCapture(() => { }))); } /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs new file mode 100644 index 0000000000..a9b810939f --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs @@ -0,0 +1,291 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; + +using Mono.Cecil.Cil; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of the per-assembly PDB document index: which documents it finds, and when it + /// reads a dll and its PDB again. Tests that change a file work on copies in a private temp + /// directory so that ScriptAssemblies is never touched. + /// + public class HotReloadPdbDocumentIndexTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string FixtureProjectRelativePath = + "Assets/Tests/Editor/HotReload/HotReloadE2EFixtures.cs"; + private const string CoreFixtureProjectRelativePath = + "Assets/Tests/Editor/HotReload/HotReloadCoreFixtures.cs"; + private const string PredefinedEditorAssemblyName = "Assembly-CSharp-Editor"; + private const string PredefinedEditorFixtureProjectRelativePath = + "Assets/RegressionHarness/AnnotatedScreenshotMismatch/Editor/AnnotatedScreenshotMismatchSceneBuilder.cs"; + private const string BodylessFixtureProjectRelativePath = + "Assets/Tests/Editor/HotReload/HotReloadSnapshotBodylessFixture.cs"; + + private HotReloadPdbDocumentIndex _index; + + [SetUp] + public void SetUp() + { + _index = new HotReloadPdbDocumentIndex(); + } + + /// + /// What: a second source file of an assembly already read is found without reading the dll and the PDB again. + /// + [Test] + public void TryFindDocument_TwoFilesOfOneAssembly_ReadsThePdbOnce() + { + string dllPath = DllPath(TestAssemblyName); + + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + bool foundSecond = Find(_index, dllPath, CoreFixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundFirst, Is.True); + Assert.That(foundSecond, Is.True); + Assert.That(_index.LoadCount, Is.EqualTo(1)); + } + + /// + /// What: the document found for a file with method bodies carries the checksum of that file's bytes on disk. + /// + [Test] + public void TryFindDocument_ForASourceFileWithMethodBodies_ReturnsTheChecksumOfItsBytes() + { + bool found = Find(_index, DllPath(TestAssemblyName), FixtureProjectRelativePath, out HotReloadPdbDocument document); + + Assert.That(found, Is.True); + Assert.That(document.Hash, Is.Not.Null.And.Not.Empty); + byte[] sourceBytes = File.ReadAllBytes(Path.Combine(ProjectRoot(), FixtureProjectRelativePath)); + Assert.That( + ComputeDocumentHash(document.HashAlgorithm, sourceBytes).SequenceEqual(document.Hash), + Is.True, + "The checksum must equal the hash of the on-disk source bytes (algorithm=" + document.HashAlgorithm + ")."); + } + + /// + /// What: a file without a method body is not found, although the PDB's document table lists it. + /// + [Test] + public void TryFindDocument_ForAFileWithoutMethodBodies_ReturnsFalse() + { + bool found = Find(_index, DllPath(TestAssemblyName), BodylessFixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(found, Is.False); + } + + /// + /// What: looking up the same file twice reads the dll and the PDB once. + /// + [Test] + public void TryFindDocument_SameFileTwice_ReadsThePdbOnce() + { + string dllPath = DllPath(TestAssemblyName); + + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + bool foundSecond = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundFirst, Is.True); + Assert.That(foundSecond, Is.True); + Assert.That(_index.LoadCount, Is.EqualTo(1)); + } + + /// + /// What: a newer write time on the dll makes the next lookup read the files again. + /// + [Test] + public void TryFindDocument_DllWriteTimeChanged_ReadsAgain() + { + string dllPath = CopyAssemblyToTemp(TestAssemblyName); + try + { + bool foundBefore = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + File.SetLastWriteTimeUtc(dllPath, File.GetLastWriteTimeUtc(dllPath).AddSeconds(2)); + bool foundAfter = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundBefore, Is.True); + Assert.That(foundAfter, Is.True); + Assert.That(_index.LoadCount, Is.EqualTo(2)); + } + finally + { + Directory.Delete(Path.GetDirectoryName(dllPath), recursive: true); + } + } + + /// + /// What: a newer write time on the PDB alone makes the next lookup read the files again. + /// + [Test] + public void TryFindDocument_PdbWriteTimeChanged_ReadsAgain() + { + string dllPath = CopyAssemblyToTemp(TestAssemblyName); + try + { + string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + bool foundBefore = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + File.SetLastWriteTimeUtc(pdbPath, File.GetLastWriteTimeUtc(pdbPath).AddSeconds(2)); + bool foundAfter = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundBefore, Is.True); + Assert.That(foundAfter, Is.True); + Assert.That(_index.LoadCount, Is.EqualTo(2)); + } + finally + { + Directory.Delete(Path.GetDirectoryName(dllPath), recursive: true); + } + } + + /// + /// What: lookups that alternate between two assemblies keep a list for each, so each pair of files is read once. + /// + [Test] + public void TryFindDocument_TwoAssemblies_KeepsBothLists() + { + string testDllPath = DllPath(TestAssemblyName); + string editorDllPath = DllPath(PredefinedEditorAssemblyName); + + bool foundTestFirst = Find(_index, testDllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + bool foundEditorFirst = Find(_index, editorDllPath, PredefinedEditorFixtureProjectRelativePath, out HotReloadPdbDocument _); + bool foundTestSecond = Find(_index, testDllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + bool foundEditorSecond = Find(_index, editorDllPath, PredefinedEditorFixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundTestFirst, Is.True); + Assert.That(foundEditorFirst, Is.True); + Assert.That(foundTestSecond, Is.True); + Assert.That(foundEditorSecond, Is.True); + Assert.That(_index.LoadCount, Is.EqualTo(2)); + } + + /// + /// What: a different module version id for the same, unchanged files makes the lookup read them again. + /// + [Test] + public void TryFindDocument_DifferentModuleVersionIdForTheSameFiles_ReadsAgain() + { + string dllPath = CopyAssemblyToTemp(TestAssemblyName); + try + { + string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + bool foundBefore = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + bool foundAfter = _index.TryFindDocument( + dllPath, + pdbPath, + "00000000000000000000000000000000", + FixtureProjectRelativePath, + out HotReloadPdbDocument _); + + Assert.That(foundBefore, Is.True); + Assert.That(foundAfter, Is.True); + Assert.That(_index.LoadCount, Is.EqualTo(2)); + } + finally + { + Directory.Delete(Path.GetDirectoryName(dllPath), recursive: true); + } + } + + /// + /// What: once the PDB can no longer be read, every lookup throws instead of answering from the + /// list read from the earlier files, and no list is stored for the unreadable files. + /// + [Test] + public void TryFindDocument_PdbUnreadableAfterAListWasKept_ThrowsAndKeepsNoStaleList() + { + string dllPath = CopyAssemblyToTemp(TestAssemblyName); + try + { + string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + 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. + File.WriteAllBytes(pdbPath, new byte[] { 0x6E, 0x6F, 0x74, 0x20, 0x61, 0x20, 0x70, 0x64, 0x62 }); + TestDelegate findAgain = () => Find(_index, 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)); + } + finally + { + Directory.Delete(Path.GetDirectoryName(dllPath), recursive: true); + } + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + + private static string DllPath(string assemblyName) + { + return Path.Combine( + ProjectRoot(), + HotReloadConstants.ScriptAssembliesRelativeDirectory, + assemblyName + HotReloadConstants.CompiledAssemblyExtension); + } + + // Looks the file up the way the snapshot loader does: the PDB next to the dll, and the + // MVID read from the dll. + private static bool Find( + HotReloadPdbDocumentIndex index, + string dllPath, + string projectRelativePath, + out HotReloadPdbDocument document) + { + string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + string moduleVersionId = HotReloadSourceSnapshotter.ReadAssemblyMvid(dllPath); + return index.TryFindDocument(dllPath, pdbPath, moduleVersionId, projectRelativePath, out document); + } + + // Copies the assembly's dll and PDB into a new temp directory and returns the dll's path. + // The caller deletes the directory. + private static string CopyAssemblyToTemp(string assemblyName) + { + string sourceDllPath = DllPath(assemblyName); + string sourcePdbPath = Path.ChangeExtension(sourceDllPath, ".pdb"); + Assert.That(File.Exists(sourceDllPath), Is.True, "Assembly dll missing: " + sourceDllPath); + Assert.That(File.Exists(sourcePdbPath), Is.True, "Assembly PDB missing: " + sourcePdbPath); + + string directory = Path.Combine( + Path.GetTempPath(), + "uloop-pdb-document-index-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + string dllPath = Path.Combine(directory, Path.GetFileName(sourceDllPath)); + File.Copy(sourceDllPath, dllPath); + File.Copy(sourcePdbPath, Path.ChangeExtension(dllPath, ".pdb")); + return dllPath; + } + + private static byte[] ComputeDocumentHash(DocumentHashAlgorithm algorithm, byte[] sourceBytes) + { + switch (algorithm) + { + case DocumentHashAlgorithm.SHA1: + using (SHA1 sha1 = SHA1.Create()) + { + return sha1.ComputeHash(sourceBytes); + } + case DocumentHashAlgorithm.SHA256: + using (SHA256 sha256 = SHA256.Create()) + { + return sha256.ComputeHash(sourceBytes); + } + default: + Assert.Fail("Unsupported Document.HashAlgorithm: " + algorithm); + return null; + } + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs.meta new file mode 100644 index 0000000000..18bcf2a414 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e5bb74c8121544a748bbbd1854d211da +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPeelSameNameFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadPeelSameNameFixture.cs new file mode 100644 index 0000000000..6e881ac7c9 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPeelSameNameFixture.cs @@ -0,0 +1,17 @@ +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// A second type declaring a method named like HotReloadCoreFixture.StaticPing, so a + /// test can tell a peel that matches by name alone from one that resolves the method. + /// + public static class HotReloadPeelSameNameFixture + { + [MethodImpl(MethodImplOptions.NoInlining)] + public static string StaticPing() + { + return "same-name"; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPeelSameNameFixture.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPeelSameNameFixture.cs.meta new file mode 100644 index 0000000000..51550af2c5 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPeelSameNameFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 77416684834c44eb6ae324e63ed642e9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs new file mode 100644 index 0000000000..34ce3c5dbf --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// A plain type deriving from : the control for the + /// partial type that derives from it. + /// + public class HotReloadPlainDerivedFixture : HotReloadInternalMemberHost + { + private int _seed = 1000; + + [MethodImpl(MethodImplOptions.NoInlining)] + public int DerivedValue() + { + return 10; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int ClosureValue() + { + Func read = () => 30; + return read(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int ClosureSeedValue() + { + Func read = () => _seed; + return read(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public IEnumerable IteratorValues() + { + yield return _seed; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int ClosureSeedPlusValue() + { + Func read = () => this._seed; + return read() + 7; + } + + public int DerivedProperty + { + [MethodImpl(MethodImplOptions.NoInlining)] + get { return 40; } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public async System.Threading.Tasks.Task AsyncValue() + { + await System.Threading.Tasks.Task.CompletedTask; + return 50; + } + + public event Action DerivedEvent; + + [MethodImpl(MethodImplOptions.NoInlining)] + public int RaiseDerivedEvent() + { + DerivedEvent?.Invoke(); + return 60; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs.meta new file mode 100644 index 0000000000..522553368f --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 52992140133b44b419f2e5f5e401d603 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs new file mode 100644 index 0000000000..f5bbaf592d --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Mono.Cecil; +using NUnit.Framework; +using UnityEditor.Compilation; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Verifies the Cecil search directories for publicizing a compilation assembly's references + /// reach the precompiled DLLs that only a transitively referenced assembly lists, and keep the + /// directories of the assembly's own references first. + /// + public sealed class HotReloadResolverSearchDirectoriesTests + { + private const string CodeAnalysisPluginPath = + "Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Plugins/CodeAnalysis/" + + "UnityCliLoop.System.Collections.Immutable.dll"; + + private readonly List _images = new List(); + 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() + { + foreach (InternalsExposureTestImage image in _images) + { + foreach (string copyPath in PublicizedCopiesOf(image)) + { + File.Delete(copyPath); + } + + image.Dispose(); + } + + _images.Clear(); + if (Directory.Exists(_tempRoot)) + { + Directory.Delete(_tempRoot, true); + } + } + + /// + /// Verifies the directories reach a plugin two assembly references away, which neither the + /// root's own references nor those of its direct reference list. + /// + [Test] + public void Collect_IncludesDirectoriesOfTransitivelyReferencedPrecompiledAssemblies() + { + string pluginPath = CreateEmptyFile("plugins", "Plugin.dll"); + ReferenceGraph graph = CreateReferenceGraph(pluginPath, Array.Empty()); + + IReadOnlyCollection directories = HotReloadResolverSearchDirectories.Collect(graph.Tests); + + Assert.That(directories, Does.Contain(FullDirectoryOf(graph.Tests.outputPath))); + Assert.That(directories, Does.Contain(FullDirectoryOf(pluginPath))); + Assert.That( + ReferencePublicizer.CollectResolverSearchDirectories(graph.Tests.allReferences), + Does.Not.Contain(FullDirectoryOf(pluginPath)), + "The root's own references must not list the plugin, or the graph does not test the transitive walk."); + Assert.That( + ReferencePublicizer.CollectResolverSearchDirectories(graph.Game.allReferences), + Does.Not.Contain(FullDirectoryOf(pluginPath)), + "The direct reference must not list the plugin, or one step of the walk would be enough."); + } + + /// + /// Verifies the directories of the root's own references come before those that only a + /// transitive reference adds, so a same-named DLL resolves to the root's own copy first. + /// + [Test] + public void Collect_OrdersOwnReferencesBeforeTransitiveOnes() + { + string pluginPath = CreateEmptyFile("plugins", "Plugin.dll"); + string ownPath = CreateEmptyFile("own", "Own.dll"); + ReferenceGraph graph = CreateReferenceGraph(pluginPath, new[] { ownPath }); + + List directories = new List(HotReloadResolverSearchDirectories.Collect(graph.Tests)); + + int ownIndex = directories.IndexOf(FullDirectoryOf(ownPath)); + int pluginIndex = directories.IndexOf(FullDirectoryOf(pluginPath)); + Assert.That(ownIndex, Is.GreaterThanOrEqualTo(0)); + Assert.That(pluginIndex, Is.GreaterThan(ownIndex)); + } + + /// + /// 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 + /// assembly lists. + /// + [Test] + public void Collect_ForHotReloadTestAssembly_ReachesTheCodeAnalysisPluginDirectoryOnlyTransitively() + { + UnityCompilationAssembly testAssembly = PublicizerTestSearchDirectories.HotReloadTestAssembly(); + string pluginDirectory = Path.GetFullPath(Path.GetDirectoryName(CodeAnalysisPluginPath)); + Assert.That( + ReferencePublicizer.CollectResolverSearchDirectories(testAssembly.allReferences), + 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)); + } + + /// + /// Verifies a publicized copy whose metadata needs an assembly that only a transitive + /// reference lists cannot be written with the root's own reference directories, and can be + /// written with the transitive ones. + /// + [Test] + public void GetOrCreatePublicizedCopy_ResolvesAnAssemblyThatOnlyATransitiveReferenceLists() + { + string externalName = "TransitiveEnumFixture_" + Guid.NewGuid().ToString("N"); + string externalDirectory = Path.Combine(_tempRoot, "plugins"); + InternalsExposureTestImage image = + InternalsExposureTestImage.CreateWithConstantOfEnumIn(externalDirectory, externalName); + _images.Add(image); + ReferenceGraph graph = CreateReferenceGraph( + Path.Combine(externalDirectory, externalName + ".dll"), + Array.Empty()); + + // Why the failing call goes first: a failed write caches nothing, while a successful one + // would satisfy the second call from the cache before Cecil resolves anything. + Assert.Throws(() => ReferencePublicizer.GetOrCreatePublicizedCopy( + image.Home, + ReferencePublicizer.CollectResolverSearchDirectories(graph.Tests.allReferences))); + + string publicized = ReferencePublicizer.GetOrCreatePublicizedCopy( + image.Home, + HotReloadResolverSearchDirectories.Collect(graph.Tests)); + + Assert.That(File.Exists(publicized), Is.True); + } + + /// + /// Verifies the shim reference build publicizes its target with the transitive search + /// directories, so a target whose metadata needs an assembly that only a transitive reference + /// lists yields references instead of a publicize failure. + /// + [Test] + public void TryBuildShimReferencePaths_PublicizesTheTargetWithTransitiveSearchDirectories() + { + string externalName = "TransitiveShimEnumFixture_" + Guid.NewGuid().ToString("N"); + string externalDirectory = Path.Combine(_tempRoot, "plugins"); + InternalsExposureTestImage image = + InternalsExposureTestImage.CreateWithConstantOfEnumIn(externalDirectory, externalName); + _images.Add(image); + ReferenceGraph graph = CreateReferenceGraph( + Path.Combine(externalDirectory, externalName + ".dll"), + Array.Empty()); + + HotReloadShimReferenceBuilder.ShimReferencePathsResult result = + HotReloadShimReferenceBuilder.TryBuildShimReferencePaths( + graph.Tests, + image.Home, + false, + false, + Array.Empty()); + + Assert.That(result.ErrorMessage, Is.Null); + Assert.That(File.Exists(result.References[0]), Is.True); + } + + // 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) + { + UnityCompilationAssembly core = new UnityCompilationAssembly( + "Core", + ScriptPath("Core.dll"), + Array.Empty(), + Array.Empty(), + Array.Empty(), + new[] { pluginPath }, + AssemblyFlags.None); + UnityCompilationAssembly game = new UnityCompilationAssembly( + "Game", + ScriptPath("Game.dll"), + Array.Empty(), + Array.Empty(), + new[] { core }, + Array.Empty(), + AssemblyFlags.None); + UnityCompilationAssembly tests = new UnityCompilationAssembly( + "Tests", + ScriptPath("Tests.dll"), + Array.Empty(), + Array.Empty(), + new[] { game }, + testsOwnReferences, + AssemblyFlags.EditorAssembly); + return new ReferenceGraph(game, tests); + } + + // The script assemblies exist as files so their directory counts as a search directory. + private string ScriptPath(string fileName) + { + return CreateEmptyFile("scripts", fileName); + } + + private string CreateEmptyFile(string directoryName, string fileName) + { + string directory = Path.Combine(_tempRoot, directoryName); + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, fileName); + File.WriteAllBytes(path, Array.Empty()); + return path; + } + + private static string FullDirectoryOf(string path) + { + return Path.GetDirectoryName(Path.GetFullPath(path)); + } + + private static string[] PublicizedCopiesOf(InternalsExposureTestImage image) + { + string directory = Path.Combine( + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + HotReloadConstants.PublicizedRefsRelativeDirectory); + return Directory.Exists(directory) + ? Directory.GetFiles(directory, image.Definition.Name.Name + "-*") + : Array.Empty(); + } + + private sealed class ReferenceGraph + { + internal ReferenceGraph(UnityCompilationAssembly game, UnityCompilationAssembly tests) + { + Game = game; + Tests = tests; + } + + internal UnityCompilationAssembly Game { get; } + + internal UnityCompilationAssembly Tests { get; } + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs.meta new file mode 100644 index 0000000000..003c472b5d --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: af19673c8e56e4647acb50c3c3a5fd53 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadRunTimingTests.cs b/Assets/Tests/Editor/HotReload/HotReloadRunTimingTests.cs new file mode 100644 index 0000000000..3e561ef3c3 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadRunTimingTests.cs @@ -0,0 +1,117 @@ +using System; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Tests the accumulator that sums the milliseconds of each phase of one apply run. + /// + public sealed class HotReloadRunTimingTests + { + /// + /// What: two analysis spans of one run, such as a worker run and its rerun, add up. + /// + [Test] + public void AddAnalysis_CalledTwice_SumsTheMilliseconds() + { + HotReloadRunTiming timing = new HotReloadRunTiming(); + + timing.AddAnalysis(30); + timing.AddAnalysis(12); + + Assert.That(timing.Complete(100).AnalysisMs, Is.EqualTo(42)); + } + + /// + /// What: completing the run copies each phase into its own field and the total as given. + /// Every value differs, so a phase written into another's field fails. + /// + [Test] + public void Complete_CopiesEveryPhaseAndTheTotal() + { + HotReloadRunTiming timing = new HotReloadRunTiming(); + timing.AddAnalysis(5); + timing.AddShimCompile(7); + timing.AddPatch(1); + + HotReloadTimingBreakdown breakdown = timing.Complete(50); + + Assert.That(breakdown.AnalysisMs, Is.EqualTo(5), "AnalysisMs"); + Assert.That(breakdown.ShimCompileMs, Is.EqualTo(7), "ShimCompileMs"); + Assert.That(breakdown.PatchMs, Is.EqualTo(1), "PatchMs"); + Assert.That(breakdown.TotalMs, Is.EqualTo(50), "TotalMs"); + } + + /// + /// What: a run that reached no phase, such as one whose files all dropped out before any + /// group ran, reports zero for every phase and still reports its total. + /// + [Test] + public void Complete_WithoutPhases_ReportsZeroPhasesAndTheTotal() + { + HotReloadTimingBreakdown breakdown = new HotReloadRunTiming().Complete(3); + + Assert.That(breakdown.AnalysisMs, Is.EqualTo(0), "AnalysisMs"); + Assert.That(breakdown.ShimCompileMs, Is.EqualTo(0), "ShimCompileMs"); + Assert.That(breakdown.PatchMs, Is.EqualTo(0), "PatchMs"); + Assert.That(breakdown.TotalMs, Is.EqualTo(3), "TotalMs"); + } + + /// + /// What: a negative total is refused instead of being reported, since elapsed time never is. + /// + [Test] + public void Complete_NegativeTotal_Throws() + { + HotReloadRunTiming timing = new HotReloadRunTiming(); + + Assert.Throws(() => timing.Complete(-1)); + } + + /// + /// What: the time outside the three phases is reported as the total minus the phases. + /// + [Test] + public void Complete_ReportsOtherAsTotalMinusThePhases() + { + HotReloadRunTiming timing = new HotReloadRunTiming(); + timing.AddAnalysis(100); + timing.AddShimCompile(200); + timing.AddPatch(30); + + Assert.That(timing.Complete(1000).OtherMs, Is.EqualTo(670)); + } + + /// + /// What: a total that the phases fill exactly leaves nothing outside them. + /// + [Test] + public void Complete_TotalEqualToThePhases_ReportsZeroOther() + { + HotReloadRunTiming timing = new HotReloadRunTiming(); + timing.AddAnalysis(100); + timing.AddShimCompile(200); + timing.AddPatch(30); + + Assert.That(timing.Complete(330).OtherMs, Is.EqualTo(0)); + } + + /// + /// What: a total shorter than the phases inside it is refused instead of reporting a + /// negative time outside the phases. + /// + [Test] + public void Complete_TotalBelowThePhases_Throws() + { + HotReloadRunTiming timing = new HotReloadRunTiming(); + timing.AddAnalysis(100); + timing.AddShimCompile(200); + timing.AddPatch(30); + + Assert.Throws(() => timing.Complete(329)); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadRunTimingTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadRunTimingTests.cs.meta new file mode 100644 index 0000000000..0b71b8eb93 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadRunTimingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b2072c7a39f0a4051ba50d062b0edb80 +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 b6ae534e7f..9d6d042969 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadServicesTestScope.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadServicesTestScope.cs @@ -55,6 +55,13 @@ internal static IDisposable BeginWithChangeDetector(IHotReloadChangeDetector cha HotReloadCompositionRoot.Services.WithChangeDetector(changeDetector)); } + /// Replaces the capture the tool makes sure of before it reads a source snapshot. + internal static IDisposable BeginWithSourceSnapshotCapture(HotReloadSourceSnapshotCapture capture) + { + return HotReloadCompositionRoot.BeginReplacement( + HotReloadCompositionRoot.Services.WithSourceSnapshotCapture(capture)); + } + /// Replaces the transform worker host the client routes through. internal static IDisposable BeginWithWorkerHost(TransformWorkerHost host) { @@ -104,7 +111,8 @@ internal static IDisposable BeginWith( editorStateSnapshotCapture, transformWorkerHost, buildDependencies, - playModeQuery)); + playModeQuery, + installed.SourceSnapshotCapture)); } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadShapeFixtures.cs b/Assets/Tests/Editor/HotReload/HotReloadShapeFixtures.cs index f3c7615701..d872f3d186 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadShapeFixtures.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadShapeFixtures.cs @@ -157,11 +157,15 @@ public int EditorGuardedReturn() } /// - /// Method body that resolves HotReloadGlobalAlias only through a sibling-file - /// global using. Shim compile fails with CS0246 unless the worker collects that alias. + /// Members that resolve HotReloadGlobalAlias only through a sibling-file + /// global using. In a body, the shim compile fails with CS0246 unless the worker + /// collects that alias; in a parameter, return or field type, an existing method matches its + /// compiled signature only when the worker binds the edited file with that alias. /// internal class HotReloadGlobalUsingFixture { + private readonly HotReloadGlobalAlias _buffer = new HotReloadGlobalAlias("abc"); + [MethodImpl(MethodImplOptions.NoInlining)] public string BuildWithGlobalAlias() { @@ -169,5 +173,51 @@ public string BuildWithGlobalAlias() builder.Append("base"); return builder.ToString(); } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int MeasureWithGlobalAliasParameter(HotReloadGlobalAlias builder) + { + return builder.Length; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public HotReloadGlobalAlias CreateWithGlobalAliasReturn() + { + HotReloadGlobalAlias builder = new HotReloadGlobalAlias(); + builder.Append("return-base"); + return builder; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int BufferLength() + { + return _buffer.Length; + } + } + + /// + /// Existing method whose parameter type is an internal type of another file: the worker + /// binds it as an inaccessible error type, and a body edit must still match the compiled method. + /// + internal class HotReloadInternalSignatureFixture + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int CountProbe(HotReloadInternalSignatureProbe probe) + { + return probe == null ? 1 : 2; + } + } + + /// + /// Derives from a base type that only the test assembly's global using makes visible, so a + /// body that reads a base member binds only when the worker compilation carries that using. + /// + internal class HotReloadGlobalUsingDerivedFixture : HotReloadGlobalUsingBaseHost + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int OffsetPlus(int x) + { + return BaseOffset + x; + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadSiblingConstDefinitions.cs b/Assets/Tests/Editor/HotReload/HotReloadSiblingConstDefinitions.cs index e94645a2cb..193cce82f8 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSiblingConstDefinitions.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSiblingConstDefinitions.cs @@ -7,5 +7,9 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload public static class HotReloadSiblingConstDefinitions { public const int SiblingTuning = 6; + + // Why typed through the global using alone: a changed sibling naming this const has a + // value only in a compilation that carries the assembly's global usings. + public const HotReloadGlobalUsingMode SiblingMode = HotReloadGlobalUsingMode.First; } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourcePathMatchingTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourcePathMatchingTests.cs new file mode 100644 index 0000000000..48118a19b5 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSourcePathMatchingTests.cs @@ -0,0 +1,77 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Coverage for matching an absolute source path against a project-relative one, which the + /// worker uses to recognize the real file behind an edited copy and to name a file in a reason. + /// + public class HotReloadSourcePathMatchingTests + { + /// + /// What: an absolute path written with backslashes matches its project-relative form written + /// with slashes. + /// + [Test] + public void EndsWithProjectRelativePath_BackslashSeparatedAbsolutePath_Matches() + { + Assert.That( + HotReloadSourcePathMatching.EndsWithProjectRelativePath("C:\\proj\\Assets\\A\\Foo.cs", "Assets/A/Foo.cs"), + Is.True); + } + + /// + /// What: a file name that only ends with the relative path's characters does not match, + /// because the match must start at a directory separator. + /// + [Test] + public void EndsWithProjectRelativePath_NameThatOnlySharesTheTail_DoesNotMatch() + { + Assert.That( + HotReloadSourcePathMatching.EndsWithProjectRelativePath("/p/Assets/A/AnOther.cs", "Other.cs"), + Is.False); + } + + /// + /// What: an absolute path matches the project-relative path it ends with. + /// + [Test] + public void EndsWithProjectRelativePath_SlashSeparatedAbsolutePath_Matches() + { + Assert.That( + HotReloadSourcePathMatching.EndsWithProjectRelativePath("/p/Assets/A/Foo.cs", "Assets/A/Foo.cs"), + Is.True); + } + + /// + /// What: another file under the same root is given in project-relative form with slashes, + /// using the root a known absolute and project-relative pair anchors. + /// + [Test] + public void ToProjectRelativeOrNull_FileUnderTheAnchoredRoot_ReturnsTheRelativePath() + { + Assert.That( + HotReloadSourcePathMatching.ToProjectRelativeOrNull( + "C:\\proj\\Assets\\B\\Bar.cs", + "C:\\proj\\Assets\\A\\Foo.cs", + "Assets/A/Foo.cs"), + Is.EqualTo("Assets/B/Bar.cs")); + } + + /// + /// What: a file outside the anchored root has no project-relative form. + /// + [Test] + public void ToProjectRelativeOrNull_FileOutsideTheAnchoredRoot_ReturnsNull() + { + Assert.That( + HotReloadSourcePathMatching.ToProjectRelativeOrNull( + "/elsewhere/Assets/B/Bar.cs", + "/proj/Assets/A/Foo.cs", + "Assets/A/Foo.cs"), + Is.Null); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourcePathMatchingTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadSourcePathMatchingTests.cs.meta new file mode 100644 index 0000000000..8e9b32f25b --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSourcePathMatchingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eae5203f49d61412592218a3934e790b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs new file mode 100644 index 0000000000..951a4538d4 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs @@ -0,0 +1,58 @@ +using System; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// EditMode coverage for capturing the source snapshot once per domain. + /// + public class HotReloadSourceSnapshotCaptureTests + { + /// + /// What: once a capture has completed, a later call does not run it again. + /// + [Test] + public void EnsureCaptured_CalledTwice_RunsTheCaptureOnce() + { + int captureCount = 0; + HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => captureCount++); + + capture.EnsureCaptured(); + capture.EnsureCaptured(); + + Assert.That(captureCount, Is.EqualTo(1)); + } + + /// + /// What: a capture that throws passes the exception to the caller and is not marked done, so + /// the next call runs it again; once that run completes, later calls do not. + /// + [Test] + public void EnsureCaptured_WhenTheCaptureThrows_RunsItAgainOnTheNextCall() + { + int captureCount = 0; + InvalidOperationException failure = new InvalidOperationException("The capture failed."); + HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + { + captureCount++; + if (captureCount == 1) + { + throw failure; + } + }); + + InvalidOperationException thrown = + Assert.Throws(() => capture.EnsureCaptured()); + Assert.That(thrown, Is.SameAs(failure)); + + Assert.DoesNotThrow(() => capture.EnsureCaptured()); + Assert.That(captureCount, Is.EqualTo(2)); + + capture.EnsureCaptured(); + Assert.That(captureCount, Is.EqualTo(2)); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs.meta new file mode 100644 index 0000000000..be853d3a1e --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6f0c03863de1946d8af869c01ea124d6 +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 99d6c82b46..795e4776ae 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs @@ -30,6 +30,8 @@ public class HotReloadSourceSnapshotTests private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; private const string FixtureProjectRelativePath = "Assets/Tests/Editor/HotReload/HotReloadE2EFixtures.cs"; + private const string CoreFixtureProjectRelativePath = + "Assets/Tests/Editor/HotReload/HotReloadCoreFixtures.cs"; private const string PredefinedEditorAssemblyName = "Assembly-CSharp-Editor"; private const string PredefinedEditorFixtureProjectRelativePath = "Assets/RegressionHarness/AnnotatedScreenshotMismatch/Editor/AnnotatedScreenshotMismatchSceneBuilder.cs"; @@ -68,6 +70,48 @@ public void PortablePdb_DocumentChecksum_MatchesSourceFileBytes() + document.HashAlgorithm + ")."); } + /// + /// What: the PDB document index finds, for files of two assemblies, the same document a walk that stops at the first matching sequence point returns. + /// + [Test] + 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); + } + + private static void AssertIndexFindsTheWalkedDocument( + HotReloadPdbDocumentIndex index, + string dllPath, + string projectRelativePath) + { + string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + Document walked = FindDocumentForProjectRelativePath(dllPath, pdbPath, projectRelativePath); + Assert.That(walked, Is.Not.Null, "Precondition: the walk must find a document for " + projectRelativePath); + + bool found = index.TryFindDocument( + dllPath, + pdbPath, + HotReloadSourceSnapshotter.ReadAssemblyMvid(dllPath), + projectRelativePath, + out HotReloadPdbDocument indexed); + + Assert.That(found, Is.True, projectRelativePath); + Assert.That(indexed.Url, Is.EqualTo(walked.Url), projectRelativePath); + Assert.That(indexed.HashAlgorithm, Is.EqualTo(walked.HashAlgorithm), projectRelativePath); + Assert.That(indexed.Hash.SequenceEqual(walked.Hash), Is.True, projectRelativePath); + } + /// /// What: LoadVerifiedSnapshotSource returns the on-disk fixture text when a PDB-validated snapshot exists for the test assembly. /// @@ -163,7 +207,8 @@ public void LoadVerifiedSnapshotSource_WhenSnapshotBytesTampered_ReturnsNull() string loaded = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( fakeRoot, FixtureProjectRelativePath, - dllPath); + dllPath, + HotReloadPdbDocumentIndex.Shared); Assert.That(loaded, Is.Null); } finally @@ -187,7 +232,8 @@ public void DescribeSnapshotMiss_ForAFileWithoutMethodBodies_ReportsNoDocumentIn HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMissAt( projectRoot, BodylessFixtureProjectRelativePath, - TestAssemblyDllPath(projectRoot)); + TestAssemblyDllPath(projectRoot), + HotReloadPdbDocumentIndex.Shared); Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoDocumentInPdb)); } @@ -204,7 +250,8 @@ public void DescribeSnapshotMiss_WithoutASnapshotFile_ReportsNoSnapshotFile() HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMissAt( emptyRoot, FixtureProjectRelativePath, - TestAssemblyDllPath(projectRoot)); + TestAssemblyDllPath(projectRoot), + HotReloadPdbDocumentIndex.Shared); Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoSnapshotFile)); } @@ -224,7 +271,8 @@ public void DescribeSnapshotMiss_WhenSnapshotBytesTampered_ReportsHashMismatch() HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMissAt( fakeRoot, FixtureProjectRelativePath, - dllPath); + dllPath, + HotReloadPdbDocumentIndex.Shared); Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.HashMismatch)); } finally @@ -244,11 +292,62 @@ public void DescribeSnapshotMiss_ForAVerifiedSnapshot_ReportsNone() HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMissAt( projectRoot, FixtureProjectRelativePath, - TestAssemblyDllPath(projectRoot)); + TestAssemblyDllPath(projectRoot), + HotReloadPdbDocumentIndex.Shared); Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.None)); } + /// + /// What: loading the verified snapshots of two files of one assembly reads its dll and PDB once. + /// + [Test] + 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); + + Assert.That(first, Is.Not.Null); + Assert.That(second, Is.Not.Null); + Assert.That(index.LoadCount, Is.EqualTo(1)); + } + + /// + /// What: asking why a load found no document reuses the list that load read, so the PDB is read once. + /// + [Test] + 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); + + Assert.That(loaded, Is.Null); + Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoDocumentInPdb)); + Assert.That(index.LoadCount, Is.EqualTo(1)); + } + private static string TestAssemblyDllPath(string projectRoot) { return Path.Combine( diff --git a/Assets/Tests/Editor/HotReload/HotReloadSpikeS4ArtifactPatchTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSpikeS4ArtifactPatchTests.cs index 4c99ac30a1..5814e38f24 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSpikeS4ArtifactPatchTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSpikeS4ArtifactPatchTests.cs @@ -75,7 +75,8 @@ public async Task MethodMatcher_ResolvesMethodInsideRetainedArtifactHome() { LoadedArtifact artifact = await CompileAndLoadArtifactAsync("SpikeS4Artifact_Matcher"); - HotReloadMethodMatchResult matchResult = HotReloadMethodMatcher.Resolve( + using HotReloadMethodMatcher matcher = HotReloadMethodMatcher.CreateReadingFromDisk(); + HotReloadMethodMatchResult matchResult = matcher.Resolve( artifact.Home, IntroducedTypeMetadataName, "Compute", @@ -167,7 +168,8 @@ private static void PatchWithReturnTwo(MethodBase method) private static MethodBase ResolveOrFail(LoadedArtifact artifact, string methodName) { - HotReloadMethodMatchResult matchResult = HotReloadMethodMatcher.Resolve( + using HotReloadMethodMatcher matcher = HotReloadMethodMatcher.CreateReadingFromDisk(); + HotReloadMethodMatchResult matchResult = matcher.Resolve( artifact.Home, IntroducedTypeMetadataName, methodName, diff --git a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs index 682cec1c40..a50e847b5a 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs @@ -763,6 +763,119 @@ public void BuildApplyResponse_WhenSuccess_LeavesRecommendedNextActionEmpty() Assert.That(response.ShouldSerializeRecommendedNextAction(), Is.False); } + /// + /// What: a run whose requested method was Patched reports Outcome Applied, zero for every + /// other per-kind total, and no hold sentence because it did not newly arm the hold. + /// + [Test] + public void BuildApplyResponse_WhenMethodsPatched_ReportsAppliedOutcomeAndTotals() + { + HotReloadOrchestratorResult result = new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Patched("Type.Method", "Assets/A.cs") + }, + new List(), + patchedTotal: 1, + activePatchTotal: 1); + + HotReloadResponse response = HotReloadTool.BuildApplyResponse(result); + + Assert.That(response.Outcome, Is.EqualTo("Applied")); + Assert.That(response.PatchedTotal, Is.EqualTo(1)); + Assert.That(response.SkippedTotal, Is.EqualTo(0)); + Assert.That(response.AddedTotal, Is.EqualTo(0)); + Assert.That(response.FailedTotal, Is.EqualTo(0)); + Assert.That(response.AlreadyActiveTotal, Is.EqualTo(0)); + Assert.That(response.StaleTotal, Is.EqualTo(0)); + Assert.That(response.AutoRefreshHoldMessage, Is.Empty); + } + + /// + /// What: each per-kind total counts only the rows of its own Kind, and AlreadyActive rows + /// beside a Skipped row answer PartiallyApplied. Every kind has a different row count, so + /// dropping or swapping any two totals fails. + /// + [Test] + public void BuildApplyResponse_WithEveryMethodKind_CountsEachKindApart() + { + HotReloadOrchestratorResult result = new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Skipped("Type.Skipped1()", "reason", "Assets/A.cs"), + HotReloadMethodOutcome.Added("Type.Added1()", "Assets/A.cs"), + HotReloadMethodOutcome.Added("Type.Added2()", "Assets/A.cs"), + HotReloadMethodOutcome.AlreadyActive("Type.Active1()", "Assets/A.cs"), + HotReloadMethodOutcome.AlreadyActive("Type.Active2()", "Assets/A.cs"), + HotReloadMethodOutcome.AlreadyActive("Type.Active3()", "Assets/A.cs"), + HotReloadMethodOutcome.Stale("Type.Removed1()", "Assets/A.cs"), + HotReloadMethodOutcome.Stale("Type.Removed2()", "Assets/A.cs"), + HotReloadMethodOutcome.Stale("Type.Removed3()", "Assets/A.cs"), + HotReloadMethodOutcome.Stale("Type.Removed4()", "Assets/A.cs") + }, + new List(), + patchedTotal: 0, + activePatchTotal: 9); + + HotReloadResponse response = HotReloadTool.BuildApplyResponse(result); + + Assert.That(response.SkippedTotal, Is.EqualTo(1), "SkippedTotal"); + Assert.That(response.AddedTotal, Is.EqualTo(2), "AddedTotal"); + Assert.That(response.AlreadyActiveTotal, Is.EqualTo(3), "AlreadyActiveTotal"); + Assert.That(response.StaleTotal, Is.EqualTo(4), "StaleTotal"); + Assert.That(response.PatchedTotal, Is.EqualTo(0), "PatchedTotal"); + Assert.That(response.FailedTotal, Is.EqualTo(0), "FailedTotal"); + Assert.That(response.Outcome, Is.EqualTo("PartiallyApplied")); + } + + /// + /// What: the response's Timing copies each phase the run measured into its own field. + /// Every value differs, so a phase written into another's field fails. + /// + [Test] + public void BuildApplyResponse_WithTiming_CopiesEveryPhase() + { + HotReloadOrchestratorResult result = new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Patched("Type.Method", "Assets/A.cs") + }, + new List(), + patchedTotal: 1, + activePatchTotal: 1, + timing: new HotReloadTimingBreakdown(5, 7, 1, 50)); + + HotReloadResponse response = HotReloadTool.BuildApplyResponse(result); + + Assert.That(response.Timing, Is.Not.Null); + Assert.That(response.Timing.AnalysisMs, Is.EqualTo(5), "AnalysisMs"); + Assert.That(response.Timing.ShimCompileMs, Is.EqualTo(7), "ShimCompileMs"); + Assert.That(response.Timing.PatchMs, Is.EqualTo(1), "PatchMs"); + Assert.That(response.Timing.OtherMs, Is.EqualTo(37), "OtherMs"); + Assert.That(response.Timing.TotalMs, Is.EqualTo(50), "TotalMs"); + } + + /// + /// What: a result the run did not measure leaves Timing out of the response. + /// + [Test] + public void BuildApplyResponse_WithoutTiming_OmitsTiming() + { + HotReloadOrchestratorResult result = new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Patched("Type.Method", "Assets/A.cs") + }, + new List(), + patchedTotal: 1, + activePatchTotal: 1); + + HotReloadResponse response = HotReloadTool.BuildApplyResponse(result); + + Assert.That(response.Timing, Is.Null); + Assert.That(response.ShouldSerializeTiming(), Is.False); + } + /// /// What: BuildApplyResponse does not emit pause-point warnings when no markers /// were retargeted or suppressed, even if PatchedTotal > 0. @@ -1011,6 +1124,10 @@ public void BuildApplyResponse_WhenHoldNewlyArmed_AppendsFixedHoldSentence() Assert.That( response.Message, Does.EndWith(HotReloadAutoRefreshHoldConstants.NewlyArmedMessageSuffix)); + Assert.That( + response.AutoRefreshHoldMessage, + Is.EqualTo(HotReloadAutoRefreshHoldConstants.NewlyArmedMessageSuffix)); + Assert.That(response.Message, Does.EndWith(" " + response.AutoRefreshHoldMessage)); } /// @@ -1316,6 +1433,7 @@ public void BuildApplyResponse_EmptyMethodsWithUnchangedTotal_YieldsAllUnchanged response.Message, Is.EqualTo("All 8 methods are unchanged since the last compile; nothing to patch.")); Assert.That(response.UnchangedTotal, Is.EqualTo(8)); + Assert.That(response.Outcome, Is.EqualTo("NothingToApply")); } /// @@ -1621,6 +1739,29 @@ public void BuildApplyResponse_WithFailedOutcome_KeepsExistingFailureMessage() Is.EqualTo("Hot reload finished with one or more Failed method outcomes. See Methods.")); } + /// + /// What: a run with a Failed method reports Outcome Failed and counts the row in + /// FailedTotal, alongside Success false. + /// + [Test] + public void BuildApplyResponse_WhenAMethodFailed_ReportsFailedOutcomeAndFailedTotal() + { + HotReloadOrchestratorResult result = new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Failed("T.M", "reason", "file.cs") + }, + new List(), + patchedTotal: 0, + activePatchTotal: 0); + + HotReloadResponse response = HotReloadTool.BuildApplyResponse(result); + + Assert.That(response.Outcome, Is.EqualTo("Failed")); + Assert.That(response.FailedTotal, Is.EqualTo(1)); + Assert.That(response.Success, Is.False); + } + /// /// What: a file-level failure reaches Message even when cascading method failures are /// listed before it, so the cause is read before the symptoms. @@ -2228,7 +2369,8 @@ public void BuildApplyResponse_AddedAndSkippedOutcomes_CountsSkippedAfterAdded() /// /// What: an applied run that re-applied a sibling's earlier changes says how many of the /// Patched and Added rows came from those siblings, right after the counts they are part of, - /// so a reader who edited one method is not left wondering where the rest came from. + /// so a reader who edited one method is not left wondering where the rest came from. Its + /// Skipped count says the same of the Skipped rows. /// [Test] public void BuildApplyResponse_SiblingRowsReapplied_SaysHowManyOfTheCountsCameFromSiblings() @@ -2252,7 +2394,8 @@ public void BuildApplyResponse_SiblingRowsReapplied_SaysHowManyOfTheCountsCameFr response.Message, Does.StartWith( "Hot reload applied. PatchedTotal=2, ActivePatchTotal=2. Added: 1. " - + "2 of the patched and added rows re-applied changes from earlier reloads in sibling files. Skipped: 1.")); + + "2 of the patched and added rows re-applied changes from earlier reloads in sibling files. " + + "Skipped: 1 (1 in sibling files the run re-applied on its own; Outcome does not count those).")); } /// @@ -2293,7 +2436,7 @@ public void BuildApplyResponse_SiblingRows_AreMarkedReappliedFromSiblingWhatever /// /// What: a pulled-in sibling whose rows were all Skipped re-applied nothing, so the message - /// adds no re-applied count for it. + /// adds no re-applied count for it, while its Skipped count names the row as a sibling's. /// [Test] public void BuildApplyResponse_SiblingRowsAllSkipped_AddsNoReappliedCount() @@ -2312,6 +2455,34 @@ public void BuildApplyResponse_SiblingRowsAllSkipped_AddsNoReappliedCount() HotReloadResponse response = HotReloadTool.BuildApplyResponse(result); Assert.That(response.Message, Does.Not.Contain("re-applied changes from earlier reloads")); + Assert.That(response.Message, Does.Contain(" Skipped: 1 (1 in sibling files")); + } + + /// + /// What: when the requested file and a sibling both have Skipped rows, the Skipped count + /// still covers every row and says how many of them are the sibling's. The sibling also has + /// a Patched row, so counting every sibling row instead of its Skipped ones fails. + /// + [Test] + public void BuildApplyMessage_RequestedAndSiblingRowsSkipped_SaysHowManySkippedRowsAreSiblings() + { + HotReloadOrchestratorResult result = new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Patched("Type.Edited", "Assets/Requested.cs"), + HotReloadMethodOutcome.Skipped("Type.Skip", "reason", "Assets/Requested.cs"), + HotReloadMethodOutcome.Patched("Sibling.Earlier", "Assets/Sibling.cs"), + HotReloadMethodOutcome.Skipped("Sibling.FirstSkip", "reason", "Assets/Sibling.cs"), + HotReloadMethodOutcome.Skipped("Sibling.SecondSkip", "reason", "Assets/Sibling.cs") + }, + new List(), + patchedTotal: 2, + activePatchTotal: 2, + reappliedSiblingPaths: new[] { "Assets/Sibling.cs" }); + + HotReloadResponse response = HotReloadTool.BuildApplyResponse(result); + + Assert.That(response.Message, Does.Contain(" Skipped: 3 (2 in sibling files")); } /// @@ -2579,6 +2750,30 @@ public void BuildApplyResponse_RequestedFileAllSkippedWithSiblingAdded_SaysNothi Is.EqualTo(HotReloadConstants.RequestedFilesAllSkippedRecommendedNextAction)); } + /// + /// What: the same run reports Outcome NothingApplied, because the sibling's Added row does + /// not make the requested file live, while the totals count every row, the sibling's too. + /// + [Test] + public void BuildApplyResponse_WhenRequestedFileAllSkippedWithSiblingAdded_ReportsNothingAppliedAndCountsEveryRow() + { + HotReloadResponse response = HotReloadTool.BuildApplyResponse( + new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Skipped("T.M", "reason", "Assets/Requested.cs"), + HotReloadMethodOutcome.Added("S.N", "Assets/Sibling.cs") + }, + new List(), + patchedTotal: 0, + activePatchTotal: 0, + reappliedSiblingPaths: new[] { "Assets/Sibling.cs" })); + + Assert.That(response.Outcome, Is.EqualTo("NothingApplied")); + Assert.That(response.AddedTotal, Is.EqualTo(1)); + Assert.That(response.SkippedTotal, Is.EqualTo(1)); + } + /// /// What: the same all-Skipped run without a sibling re-apply omits the sibling clause. /// @@ -2671,6 +2866,38 @@ public void BuildApplyResponse_IntroducedTypeBesideSkippedMethods_CountsTheSkipp + " Skipped: 2.")); } + /// + /// What: a run that only introduced a type also says when its Skipped row belongs to a + /// sibling it re-applied on its own, as the ordinary applied message does. + /// + [Test] + public void BuildApplyResponse_IntroducedTypeOnlyWithSiblingSkipped_SaysTheSkippedRowIsASibling() + { + HotReloadResponse response = HotReloadTool.BuildApplyResponse( + new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Skipped("Sibling.Skip", "reason", "Assets/Sibling.cs") + }, + new List(), + patchedTotal: 0, + activePatchTotal: 0, + reappliedSiblingPaths: new[] { "Assets/Sibling.cs" }, + introducedTypes: new[] + { + HotReloadIntroducedTypeOutcome.Introduced( + "Example.Introduced", + "IntroducedAssembly", + "Assets/Requested.cs") + })); + + Assert.That( + response.Message, + Does.StartWith( + string.Format(HotReloadConstants.IntroducedTypesOnlyApplyMessageFormat, 1) + + " Skipped: 1 (1 in sibling files")); + } + /// /// What: a run that skipped an edit outside Play Mode asks the CLI for a compile and leaves /// the run's own next action alone. @@ -2909,6 +3136,30 @@ public async Task ExecuteAsync_Status_WritesNotNeededCompileFallback() Assert.That(serialized["CompileFallback"].ToString(), Is.EqualTo("NotNeeded")); } + /// + /// What: --status omits Outcome, which answers only for an apply run. + /// + [Test] + public async Task ExecuteAsync_Status_OmitsOutcome() + { + HotReloadResponse response = await ExecuteStatusAsync(CancellationToken.None); + + JObject serialized = JObject.FromObject(response); + Assert.That(serialized.ContainsKey("Outcome"), Is.False); + } + + /// + /// What: --status omits Timing, which measures only an apply run. + /// + [Test] + public async Task ExecuteAsync_Status_OmitsTiming() + { + HotReloadResponse response = await ExecuteStatusAsync(CancellationToken.None); + + JObject serialized = JObject.FromObject(response); + Assert.That(serialized.ContainsKey("Timing"), Is.False); + } + /// /// What: a refused parameter combination writes the field too, since no run happened that /// could have left an edit unapplied. diff --git a/Assets/Tests/Editor/HotReload/HotReloadUnchangedPatchPeelTests.cs b/Assets/Tests/Editor/HotReload/HotReloadUnchangedPatchPeelTests.cs index 80b8ee70f2..dc3cedc726 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadUnchangedPatchPeelTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadUnchangedPatchPeelTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.IO; using System.Reflection; @@ -23,6 +24,8 @@ public class HotReloadUnchangedPatchPeelTests private const string OwnerPath = "Assets/Tests/Editor/HotReload/HotReloadCoreFixtures.cs"; private const string FixtureMetadataName = "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadCoreFixture"; + private const string SameNameFixtureMetadataName = + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadPeelSameNameFixture"; private HotReloadDomainTestScope _scope; @@ -83,6 +86,174 @@ public void RevertUnchangedPatches_FileThisRunApplies_PeelsThePreviousRunsPatch( "The peeled method must run the code its own assembly holds again."); } + /// + /// What: with no live patch anywhere, the peel resolves none of the rows, because none of + /// them can lead to a peel. + /// + [Test] + public void RevertUnchangedPatches_NoLivePatchAnywhere_ResolvesNoRow() + { + HotReloadGroupFile file = ArrangeFile(); + + PeelRun run = RevertRows( + file, + Row(FixtureMetadataName, nameof(HotReloadCoreFixture.StaticPing)), + Row(SameNameFixtureMetadataName, nameof(HotReloadPeelSameNameFixture.StaticPing))); + + Assert.That( + run.ResolveCalls, + Is.EqualTo(0), + "With nothing patched, resolving a row only reads the compiled assembly for nothing."); + Assert.That(run.Reverted, Is.EqualTo(0)); + Assert.That(run.Outcomes, Is.Empty); + } + + /// + /// What: a row whose method name no live patch carries is not resolved, and the live patch + /// on the other method stays. + /// + [Test] + public void RevertUnchangedPatches_LivePatchOnAnotherMethodName_ResolvesNoRow() + { + HotReloadGroupFile file = ArrangeUnchangedMethodWithActivePatch(); + + PeelRun run = RevertRows( + file, + Row(FixtureMetadataName, nameof(HotReloadCoreFixture.VoidBump))); + + Assert.That( + run.ResolveCalls, + Is.EqualTo(0), + "Only StaticPing holds a patch, so a VoidBump row cannot lead to a peel."); + Assert.That(run.Reverted, Is.EqualTo(0)); + Assert.That( + HotReloadCoreFixture.StaticPing(), + Is.EqualTo("patched"), + "The patch on the method the rows do not name must stay live."); + } + + /// + /// What: the row of the patched method itself is resolved and has its patch peeled. + /// + [Test] + public void RevertUnchangedPatches_RowOfThePatchedMethod_ResolvesAndPeelsIt() + { + HotReloadGroupFile file = ArrangeUnchangedMethodWithActivePatch(); + + PeelRun run = RevertRows( + file, + Row(FixtureMetadataName, nameof(HotReloadCoreFixture.StaticPing))); + + Assert.That(run.ResolveCalls, Is.EqualTo(1)); + Assert.That(run.Reverted, Is.EqualTo(1)); + Assert.That( + HotReloadCoreFixture.StaticPing(), + Is.EqualTo("original"), + "The peeled method must run the code its own assembly holds again."); + } + + /// + /// What: a row of another type whose method shares the patched method's name is still + /// resolved, and resolving it to that other method keeps the live patch. + /// + [Test] + public void RevertUnchangedPatches_SameNameOnAnotherType_ResolvesButKeepsThePatch() + { + HotReloadGroupFile file = ArrangeUnchangedMethodWithActivePatch(); + + PeelRun run = RevertRows( + file, + Row(SameNameFixtureMetadataName, nameof(HotReloadPeelSameNameFixture.StaticPing))); + + Assert.That( + run.ResolveCalls, + Is.EqualTo(1), + "A shared name only narrows the rows; telling the two methods apart takes resolving."); + Assert.That(run.Reverted, Is.EqualTo(0)); + Assert.That( + HotReloadCoreFixture.StaticPing(), + Is.EqualTo("patched"), + "A row of another type must not peel the patch of a method that only shares its name."); + } + + /// + /// What: a row missing its type name is skipped before it is resolved. + /// + [Test] + public void RevertUnchangedPatches_RowMissingItsTypeName_IsNotResolved() + { + HotReloadGroupFile file = ArrangeUnchangedMethodWithActivePatch(); + + PeelRun run = RevertRows( + file, + Row(null, nameof(HotReloadCoreFixture.StaticPing))); + + Assert.That(run.ResolveCalls, Is.EqualTo(0)); + Assert.That(run.Reverted, Is.EqualTo(0)); + } + + /// + /// What: a row naming an assembly no introduced-type artifact carries stops the peel, even + /// when no live patch exists for the row to lead to. + /// + [Test] + public void RevertUnchangedPatches_RowNamingAnAssemblyNoArtifactCarries_Throws() + { + HotReloadGroupFile file = ArrangeFile(); + TransformWorkerUnchangedMethodDto row = + Row(FixtureMetadataName, nameof(HotReloadCoreFixture.StaticPing)); + row.homeAssemblyName = "UnchangedPeelUnknownAssembly"; + + Assert.Throws(() => RevertRows(file, row)); + } + + /// + /// What: among rows of unpatched methods, only the row named like the live patch is + /// resolved, and that row has its patch peeled. + /// + [Test] + public void RevertUnchangedPatches_PatchedRowAmongUnpatchedRows_ResolvesOnlyThePatchedName() + { + HotReloadGroupFile file = ArrangeUnchangedMethodWithActivePatch(); + + PeelRun run = RevertRows( + file, + Row(FixtureMetadataName, nameof(HotReloadCoreFixture.VoidBump)), + Row(FixtureMetadataName, nameof(HotReloadCoreFixture.StaticPing)), + Row(FixtureMetadataName, nameof(HotReloadCoreFixture.VoidBump))); + + Assert.That( + run.ResolveCalls, + Is.EqualTo(1), + "The VoidBump rows cannot lead to a peel, so only the StaticPing row is resolved."); + Assert.That(run.Reverted, Is.EqualTo(1)); + } + + /// + /// What: the filter names only the methods that hold a live patch: none before the patch, + /// StaticPing while it is patched, and none again once the peel has removed that patch. + /// + [Test] + public void CollectPatchedMethodNames_ListsTheNamesOfLivePatchesOnly() + { + HotReloadDomainTestAccess access = new HotReloadDomainTestAccess(); + Assert.That( + HotReloadUnchangedPeelFilter.CollectPatchedMethodNames(access.Domain.ListGenerations()), + Is.Empty, + "Nothing is patched yet."); + + HotReloadGroupFile file = ArrangeUnchangedMethodWithActivePatch(); + Assert.That( + HotReloadUnchangedPeelFilter.CollectPatchedMethodNames(access.Domain.ListGenerations()), + Is.EquivalentTo(new[] { nameof(HotReloadCoreFixture.StaticPing) })); + + RevertRows(file, Row(FixtureMetadataName, nameof(HotReloadCoreFixture.StaticPing))); + Assert.That( + HotReloadUnchangedPeelFilter.CollectPatchedMethodNames(access.Domain.ListGenerations()), + Is.Empty, + "A peeled patch is no longer live, so its name must not keep rows resolving."); + } + private static void Revert(HotReloadGroupFile file) { HotReloadCompositionRoot.Services.EntryApplier.RevertUnchangedPatchesPerFile( @@ -90,8 +261,45 @@ private static void Revert(HotReloadGroupFile file) HotReloadWorkerRowsByFile.Build(BuildWorkerOutput(), new[] { OwnerPath })); } + // Runs the peel over the given rows with the production resolver wrapped in a counter. + private static PeelRun RevertRows(HotReloadGroupFile file, params TransformWorkerUnchangedMethodDto[] rows) + { + int resolveCalls = 0; + using HotReloadMethodMatcher matcher = HotReloadMethodMatcher.CreateReadingFromDisk(); + HotReloadMethodResolver counting = (home, typeMetadataName, methodName, parameterTypeFullNames, genericArity) => + { + resolveCalls++; + return matcher.Resolve( + home, typeMetadataName, methodName, parameterTypeFullNames, genericArity); + }; + List outcomes = new List(); + int reverted = HotReloadCompositionRoot.Services.EntryApplier.RevertUnchangedPatches( + file.Home, + new HotReloadEntryHomeResolver(new HotReloadDomainTestAccess().Domain, ProjectRoot), + rows, + outcomes, + file.AssemblyResolvePath, + counting); + return new PeelRun(reverted, resolveCalls, outcomes); + } + + // A worker row for a parameterless, non-generic method of the fixture owner's file. + private static TransformWorkerUnchangedMethodDto Row(string typeMetadataName, string methodName) + { + return new TransformWorkerUnchangedMethodDto + { + sourceProjectRelativePath = OwnerPath, + typeMetadataName = typeMetadataName, + methodName = methodName, + parameterTypeFullNames = new string[0], + genericArity = 0, + homeAssemblyName = null + }; + } + private static HotReloadGroupFile ArrangeUnchangedMethodWithActivePatch() { + HotReloadGroupFile file = ArrangeFile(); MethodInfo original = AccessTools.Method( typeof(HotReloadCoreFixture), nameof(HotReloadCoreFixture.StaticPing)); MethodInfo shim = AccessTools.Method( @@ -104,7 +312,12 @@ private static HotReloadGroupFile ArrangeUnchangedMethodWithActivePatch() HotReloadPatchResult patch = new HotReloadDomainTestAccess().ApplyPatch( original, shim, HotReloadPatchShape.Transplant, OwnerPath); Assert.That(patch.Success, Is.True, patch.ErrorMessage); + return file; + } + // The fixture owner's file with no patch installed; a test that needs one adds it. + private static HotReloadGroupFile ArrangeFile() + { HotReloadFileSinks sinks = new HotReloadFileSinks(new List(), null, new HotReloadRunStaleSignatureWarnings()); return new HotReloadGroupFile( OwnerPath, @@ -159,5 +372,23 @@ in UnityEditor.Compilation.CompilationPipeline.GetAssemblies()) Assert.Fail("Compilation assembly was not found."); return null; } + + // What one peel did: the patches it removed, the rows it resolved, and the outcomes it + // recorded. + private sealed class PeelRun + { + internal PeelRun(int reverted, int resolveCalls, List outcomes) + { + Reverted = reverted; + ResolveCalls = resolveCalls; + Outcomes = outcomes; + } + + internal int Reverted { get; } + + internal int ResolveCalls { get; } + + internal List Outcomes { get; } + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs new file mode 100644 index 0000000000..8250f37e64 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs @@ -0,0 +1,660 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +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 for edited bodies that use internal members of a compiled type + /// the reload was not given: what each row reports, and what the method does when called. + /// + /// + /// Why separate groups of edits: a straight-line body runs as IL copied into the patched method, + /// while a lambda or iterator body runs in code the shim assembly compiles on its own, and a + /// name inherited by simple name has to be qualified before the shim can compile it at all. + /// A partial type also skips some uses that run in the patched method, such as a method passed + /// as a delegate, so those have a group of their own. + /// + public class HotReloadUnpassedInternalMemberE2ETests + { + private const string PlainDerivedFileName = "HotReloadPlainDerivedFixture.cs"; + private const string PartialDerivedFileName = "HotReloadPartialDerivedFixture.cs"; + private const string CallerFileName = "HotReloadInternalMemberCaller.cs"; + private const string PlainDerivedValueBody = "return 10;"; + private const string PartialDerivedValueBody = "return 9;"; + private const string DerivedValueAnchor = + " [MethodImpl(MethodImplOptions.NoInlining)]\n public int DerivedValue()"; + private const string DerivedValue = "DerivedValue"; + private const string ClosureValue = "ClosureValue"; + private const string ClosureSeedValue = "ClosureSeedValue"; + private const string IteratorValues = "IteratorValues"; + private const string ClosureSeedPlusValue = "ClosureSeedPlusValue"; + private const string DerivedPropertyGetter = "get_DerivedProperty"; + private const string AsyncValue = "AsyncValue"; + private const string RaiseDerivedEvent = "RaiseDerivedEvent"; + + // Public only because a test case argument has to be as visible as the test method. + public enum FixtureKind + { + Plain, + Partial + } + + private HotReloadDomainTestScope _scope; + + [SetUp] + public void SetUp() + { + _scope = new HotReloadDomainTestScope(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + } + + [TearDown] + public void TearDown() + { + _scope.Dispose(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + VibeLogger.ClearMemoryLogs(); + } + + // Edits whose body runs as IL copied into the patched method and uses an internal member of + // a type in the edited file's own assembly. + private static IEnumerable EditsOfTheSameAssemblyThatRunInThePatchedMethod() + { + yield return "InternalStaticMethod"; + yield return "InternalInstanceMethod"; + yield return "InternalFieldReadAndWrite"; + yield return "InternalPropertyGet"; + yield return "InternalPropertyGetAndSet"; + yield return "InheritedInternalMethodThroughThis"; + yield return "InternalInstanceMethodThroughConditionalAccess"; + yield return "InternalStaticMethodOfNestedType"; + yield return "InternalFieldInsideNameof"; + yield return "InternalStaticMethodNextToLambdaReadingOwnPrivateField"; + yield return "InternalInstanceMethodOfAnInternalResult"; + } + + // Edits whose body runs as IL copied into the patched method. + private static IEnumerable EditsThatRunInThePatchedMethod() + { + foreach (string editName in EditsOfTheSameAssemblyThatRunInThePatchedMethod()) + { + yield return editName; + } + + yield return "InternalMethodOfPublicTypeOfAnotherAssemblyThroughInternalsVisibleTo"; + } + + // Edits the shim cannot run as written: a simple name it cannot qualify, or a use inside a + // lambda, local function, anonymous method, iterator or async method, or in a getter or a + // method that runs through a delegating shim, which the shim assembly compiles as ordinary + // code of its own, also when the lambda reaches the member through the result of another + // internal member. A lambda that only uses such a result as a value is here too, because the + // worker cannot tell it from one that reaches the member through the result. + private static IEnumerable EditsThatDoNotRunInThePatchedMethod() + { + yield return "InheritedInternalMethodBySimpleName"; + yield return "InternalStaticMethodInLambda"; + yield return "InternalInstanceMethodInLambda"; + yield return "InternalStaticMethodInLambdaReadingOwnPrivateField"; + yield return "InternalStaticMethodInIteratorReadingOwnPrivateField"; + yield return "InternalInstanceMethodInIteratorReadingOwnPrivateField"; + yield return "InternalStaticMethodInIterator"; + yield return "InternalInstanceMethodInLambdaOverAnInternalResult"; + yield return "InternalFieldInLambdaParameterFromAnInternalResult"; + yield return "InternalStaticMethodInLocalFunction"; + yield return "InternalStaticMethodInAnonymousMethod"; + yield return "InternalStaticMethodInAsyncMethod"; + yield return "InternalStaticMethodNextToLambdaReadingOwnPrivateFieldInGetter"; + yield return "InternalStaticMethodNextToOwnEventRaise"; + yield return "InternalFieldReadIntoAVarCapturedByALambda"; + } + + // Edits whose body runs as IL copied into the patched method but uses an internal member in a + // form a partial type does not patch: a method passed as a delegate, an event, or a member + // named in an object initializer or a property pattern. + private static IEnumerable EditsThatOnlyAPlainTypeRunsInThePatchedMethod() + { + yield return "InternalStaticMethodPassedAsDelegate"; + yield return "InternalInstanceMethodPassedAsDelegate"; + yield return "InternalEventSubscription"; + yield return "InternalFieldInObjectInitializer"; + yield return "InternalPropertyInPropertyPattern"; + } + + /// + /// What: an edited body of a plain type that uses an internal member of a compiled type the + /// reload was not given is patched, and the method returns the edited value. + /// + [TestCaseSource(nameof(EditsThatRunInThePatchedMethod))] + public async Task Run_PlainTypeBodyUsingInternalMemberOfUnpassedType_PatchesBehavior(string editName) + { + BodyEdit edit = FindEdit(editName); + HotReloadOrchestratorResult result = await RunEditAsync(FixtureKind.Plain, edit); + + await AssertPatchedAsEditedAsync(result, FixtureKind.Plain, edit); + } + + /// + /// What: the edits that use an internal member of a type in the edited file's own assembly + /// are patched on a partial type too, and the method returns the edited value, as on a plain + /// type. + /// + [TestCaseSource(nameof(EditsOfTheSameAssemblyThatRunInThePatchedMethod))] + public async Task Run_PartialTypeBodyUsingInternalMemberOfUnpassedType_PatchesBehavior(string editName) + { + BodyEdit edit = FindEdit(editName); + HotReloadOrchestratorResult result = await RunEditAsync(FixtureKind.Partial, edit); + + await AssertPatchedAsEditedAsync(result, FixtureKind.Partial, edit); + } + + /// + /// What: an edited body of a partial type that uses an internal member of a type in another + /// assembly is either patched and returns the edited value, or skipped and keeps the + /// compiled behavior. + /// + [Test] + public async Task Run_PartialTypeBodyUsingInternalMemberOfATypeOfAnotherAssembly_IsAppliedAsEditedOrSkipped() + { + BodyEdit edit = FindEdit("InternalMethodOfPublicTypeOfAnotherAssemblyThroughInternalsVisibleTo"); + HotReloadOrchestratorResult result = await RunEditAsync(FixtureKind.Partial, edit); + + await AssertAppliedAsEditedOrSkippedAsync(result, FixtureKind.Partial, edit); + } + + /// + /// What: edits on a partial type that the shim cannot run as written are either patched and + /// return the edited value, or skipped and keep the compiled behavior. They are never + /// reported as patched and then fail when called, and never fail the file. + /// + [TestCaseSource(nameof(EditsThatDoNotRunInThePatchedMethod))] + public async Task Run_PartialTypeBodyUsingInternalMemberOfUnpassedType_IsAppliedAsEditedOrSkipped(string editName) + { + BodyEdit edit = FindEdit(editName); + HotReloadOrchestratorResult result = await RunEditAsync(FixtureKind.Partial, edit); + + await AssertAppliedAsEditedOrSkippedAsync(result, FixtureKind.Partial, edit); + } + + /// + /// What: the same edits on a plain type are either patched and return the edited value, or + /// skipped and keep the compiled behavior. They are never reported as patched and then fail + /// when called, and never fail the file. + /// + [TestCaseSource(nameof(EditsThatDoNotRunInThePatchedMethod))] + public async Task Run_PlainTypeBodyUsingInternalMemberOfUnpassedType_IsAppliedAsEditedOrSkipped(string editName) + { + BodyEdit edit = FindEdit(editName); + HotReloadOrchestratorResult result = await RunEditAsync(FixtureKind.Plain, edit); + + await AssertAppliedAsEditedOrSkippedAsync(result, FixtureKind.Plain, edit); + } + + /// + /// What: an edited body of a plain type that passes an internal method of a compiled type the + /// reload was not given as a delegate, subscribes to its internal event, or names its internal + /// member in an object initializer or a property pattern is patched, and the method returns + /// the edited value. + /// + [TestCaseSource(nameof(EditsThatOnlyAPlainTypeRunsInThePatchedMethod))] + public async Task Run_PlainTypeBodyUsingInternalMemberAsADelegateEventInitializerOrPattern_PatchesBehavior(string editName) + { + BodyEdit edit = FindEdit(editName); + HotReloadOrchestratorResult result = await RunEditAsync(FixtureKind.Plain, edit); + + await AssertPatchedAsEditedAsync(result, FixtureKind.Plain, edit); + } + + /// + /// What: an edited getter that uses an internal member of a compiled type the reload was not + /// given is patched on a plain type and on a partial type, and the property returns the edited + /// value. + /// + [TestCase(FixtureKind.Plain)] + [TestCase(FixtureKind.Partial)] + public async Task Run_GetterUsingInternalMemberOfUnpassedType_PatchesBehavior(FixtureKind fixture) + { + BodyEdit edit = new BodyEdit( + DerivedPropertyGetter, + "return 40;", + "return HotReloadInternalMemberHost.InternalStaticValue() + 100;", + 101); + HotReloadOrchestratorResult result = await RunEditAsync(fixture, edit); + + await AssertPatchedAsEditedAsync(result, fixture, edit); + } + + /// + /// What: a file whose applied body uses an internal member of a type the reload was not given + /// is brought back by a later reload of another file, and its method still returns the value + /// that body computes, whether the row re-applies it or skips it. + /// + [TestCase("CallsInternal", FixtureKind.Plain)] + [TestCase("CallsInternal", FixtureKind.Partial)] + [TestCase("PlainValue", FixtureKind.Plain)] + [TestCase("PlainValue", FixtureKind.Partial)] + public async Task Run_SiblingBroughtBackUsingInternalMemberOfUnpassedType_KeepsTheAppliedBodyRunning( + string methodName, + FixtureKind passedFixture) + { + bool callsInternal = methodName == "CallsInternal"; + int edited = callsInternal ? 201 : 202; + string callerPath = FixturePath(CallerFileName); + Dictionary overrides = new Dictionary + { + [callerPath] = WriteEdited( + callerPath, + "UnpassedInternalSiblingCaller.cs", + callsInternal ? "return HotReloadInternalMemberHost.InternalStaticValue();" : "return 1;", + callsInternal + ? "return HotReloadInternalMemberHost.InternalStaticValue() + 200;" + : "return new HotReloadInternalMemberHost().InternalInstanceValue() + 200;") + }; + HotReloadOrchestratorResult first = await RunAsync(new[] { callerPath }, overrides); + Assert.That(FindRow(first, "HotReloadInternalMemberCaller", methodName).Kind, Is.EqualTo(HotReloadMethodOutcomeKind.Patched), "Precondition: the caller must be patched.\n" + FormatOutcomes(first)); + Assert.That(CallCaller(methodName), Is.EqualTo(edited), "Precondition: the caller must run the edited body.\n" + FormatOutcomes(first)); + + string passedPath = FixturePath(FixtureFileName(passedFixture)); + overrides[passedPath] = WriteEdited( + passedPath, + "UnpassedInternalSiblingPassed.cs", + ValueBody(passedFixture), + "return 50;"); + HotReloadOrchestratorResult second = await RunAsync(new[] { passedPath }, overrides); + + Assert.That(second.ReappliedSiblingPaths, Has.Some.EndsWith(CallerFileName), FormatOutcomes(second)); + Assert.That( + FindRow(second, "HotReloadInternalMemberCaller", methodName).Kind, + Is.EqualTo(HotReloadMethodOutcomeKind.Patched).Or.EqualTo(HotReloadMethodOutcomeKind.Skipped), + FormatOutcomes(second)); + Assert.That(CallCaller(methodName), Is.EqualTo(edited), FormatOutcomes(second)); + } + + /// + /// What: an added method whose body calls an internal method of a type the reload was not + /// given is either added and runs the edited body, or skipped together with its caller so + /// the compiled behavior stays. It is never added and then fails when called. + /// + [TestCase("HotReloadInternalMemberHost.InternalStaticValue() + 300", 301)] + [TestCase("new HotReloadInternalMemberHost().InternalInstanceValue() + 300", 302)] + public async Task Run_AddedMethodUsingInternalMemberOfUnpassedType_IsAddedAsEditedOrSkipped( + string expression, + int edited) + { + string path = FixturePath(PlainDerivedFileName); + string source = ReplaceOnce(File.ReadAllText(path), PlainDerivedValueBody, "return AddedInternalCall();"); + source = ReplaceOnce( + source, + DerivedValueAnchor, + " private int AddedInternalCall()\n {\n return " + expression + ";\n }\n\n" + + DerivedValueAnchor); + HotReloadOrchestratorResult result = await RunAsync( + new[] { path }, + new Dictionary + { + [path] = HotReloadTestSourceWriter.WriteEditedSource("UnpassedInternalAddedMethod.cs", source) + }); + + HotReloadMethodOutcomeKind addedKind = FindRow(result, "HotReloadPlainDerivedFixture", "AddedInternalCall").Kind; + Assert.That( + addedKind, + Is.EqualTo(HotReloadMethodOutcomeKind.Added).Or.EqualTo(HotReloadMethodOutcomeKind.Skipped), + FormatOutcomes(result)); + int expected = addedKind == HotReloadMethodOutcomeKind.Added ? edited : 10; + Assert.That(new HotReloadPlainDerivedFixture().DerivedValue(), Is.EqualTo(expected), FormatOutcomes(result)); + } + + private static BodyEdit FindEdit(string editName) + { + switch (editName) + { + case "InternalStaticMethod": + return BodyEdit.OfDerivedValue("return HotReloadInternalMemberHost.InternalStaticValue() + 100;", 101); + case "InternalInstanceMethod": + return BodyEdit.OfDerivedValue("return new HotReloadInternalMemberHost().InternalInstanceValue() + 100;", 102); + case "InternalFieldReadAndWrite": + return BodyEdit.OfDerivedValue( + "HotReloadInternalMemberHost host = new HotReloadInternalMemberHost();\n" + + " host.InternalField = host.InternalField + 40;\n" + + " return host.InternalField + 100;", + 143); + case "InternalPropertyGet": + return BodyEdit.OfDerivedValue("return new HotReloadInternalMemberHost().InternalProperty + 100;", 104); + case "InternalPropertyGetAndSet": + return BodyEdit.OfDerivedValue( + "HotReloadInternalMemberHost host = new HotReloadInternalMemberHost();\n" + + " host.InternalSettableProperty = host.InternalSettableProperty + 50;\n" + + " return host.InternalSettableProperty + 100;", + 170); + case "InheritedInternalMethodThroughThis": + return BodyEdit.OfDerivedValue("return this.InternalInstanceValue() + 100;", 102); + case "InternalInstanceMethodThroughConditionalAccess": + return BodyEdit.OfDerivedValue( + "HotReloadInternalMemberHost host = new HotReloadInternalMemberHost();\n" + + " return (host?.InternalInstanceValue() ?? 0) + 100;", + 102); + case "InternalStaticMethodOfNestedType": + return BodyEdit.OfDerivedValue("return HotReloadInternalMemberHost.Nested.NestedInternalValue() + 100;", 111); + case "InternalFieldInsideNameof": + return BodyEdit.OfDerivedValue("return nameof(HotReloadInternalMemberHost.InternalField).Length + 100;", 113); + case "InternalStaticMethodNextToLambdaReadingOwnPrivateField": + return new BodyEdit(ClosureSeedPlusValue, "read() + 7", "read() + HotReloadInternalMemberHost.InternalStaticValue()", 1001); + case "InternalInstanceMethodOfAnInternalResult": + return BodyEdit.OfDerivedValue("return HotReloadInternalMemberHost.InternalSelf().InternalInstanceValue() + 120;", 122); + case "InternalMethodOfPublicTypeOfAnotherAssemblyThroughInternalsVisibleTo": + return BodyEdit.OfDerivedValue( + "return global::io.github.hatayama.UnityCliLoop.FirstPartyTools.PausePointResponse" + + ".NormalizeNotCapturableVariables(new string[] { \"a\", \"b\" }).Count + 100;", + 102); + case "InheritedInternalMethodBySimpleName": + return BodyEdit.OfDerivedValue("return InternalInstanceValue() + 100;", 102); + case "InternalStaticMethodInLambda": + return new BodyEdit(ClosureValue, "() => 30", "() => HotReloadInternalMemberHost.InternalStaticValue() + 100", 101); + case "InternalInstanceMethodInLambda": + return new BodyEdit(ClosureValue, "() => 30", "() => new HotReloadInternalMemberHost().InternalInstanceValue() + 100", 102); + case "InternalStaticMethodInLambdaReadingOwnPrivateField": + return new BodyEdit(ClosureSeedValue, "() => _seed", "() => _seed + HotReloadInternalMemberHost.InternalStaticValue()", 1001); + case "InternalStaticMethodInIteratorReadingOwnPrivateField": + return new BodyEdit(IteratorValues, "yield return _seed;", "yield return _seed + HotReloadInternalMemberHost.InternalStaticValue();", 1001); + case "InternalInstanceMethodInIteratorReadingOwnPrivateField": + return new BodyEdit(IteratorValues, "yield return _seed;", "yield return _seed + new HotReloadInternalMemberHost().InternalInstanceValue();", 1002); + case "InternalStaticMethodInIterator": + return new BodyEdit(IteratorValues, "yield return _seed;", "yield return HotReloadInternalMemberHost.InternalStaticValue() + 100;", 101); + case "InternalInstanceMethodInLambdaOverAnInternalResult": + return BodyEdit.OfDerivedValue( + "var host = HotReloadInternalMemberHost.InternalSelf();\n" + + " System.Func read = () => host.InternalInstanceValue() + 100;\n" + + " return read();", + 102); + case "InternalFieldInLambdaParameterFromAnInternalResult": + return BodyEdit.OfDerivedValue( + "return System.Array.Exists(HotReloadInternalMemberHost.InternalHosts(), host => host.InternalField > 0) ? 100 : 0;", + 100); + case "InternalStaticMethodInLocalFunction": + return BodyEdit.OfDerivedValue( + "int Read() { return HotReloadInternalMemberHost.InternalStaticValue() + 100; }\n" + + " return Read();", + 101); + case "InternalStaticMethodInAnonymousMethod": + return BodyEdit.OfDerivedValue( + "System.Func read = delegate { return HotReloadInternalMemberHost.InternalStaticValue() + 100; };\n" + + " return read();", + 101); + case "InternalStaticMethodInAsyncMethod": + return new BodyEdit(AsyncValue, "return 50;", "return HotReloadInternalMemberHost.InternalStaticValue() + 100;", 101); + case "InternalStaticMethodNextToLambdaReadingOwnPrivateFieldInGetter": + return new BodyEdit( + DerivedPropertyGetter, + "return 40;", + "System.Func read = () => this._seed; return read() + HotReloadInternalMemberHost.InternalStaticValue();", + 1001); + case "InternalStaticMethodNextToOwnEventRaise": + return new BodyEdit(RaiseDerivedEvent, "return 60;", "return 60 + HotReloadInternalMemberHost.InternalStaticValue();", 61); + case "InternalFieldReadIntoAVarCapturedByALambda": + return BodyEdit.OfDerivedValue( + "var seed = new HotReloadInternalMemberHost().InternalField;\n" + + " System.Func read = () => seed + 100;\n" + + " return read();", + 103); + case "InternalStaticMethodPassedAsDelegate": + return BodyEdit.OfDerivedValue( + "System.Func read = HotReloadInternalMemberHost.InternalStaticValue;\n" + + " return read() + 100;", + 101); + case "InternalInstanceMethodPassedAsDelegate": + return BodyEdit.OfDerivedValue( + "System.Func read = new HotReloadInternalMemberHost().InternalInstanceValue;\n" + + " return read() + 100;", + 102); + case "InternalEventSubscription": + return BodyEdit.OfDerivedValue( + "HotReloadInternalMemberHost host = new HotReloadInternalMemberHost();\n" + + " host.InternalEvent += HotReloadInternalMemberHost.NoOp;\n" + + " return host.RaiseInternalEvent() + 100;", + 101); + case "InternalFieldInObjectInitializer": + return BodyEdit.OfDerivedValue("return new HotReloadInternalMemberHost { InternalField = 150 }.InternalField;", 150); + case "InternalPropertyInPropertyPattern": + return BodyEdit.OfDerivedValue("return new HotReloadInternalMemberHost() is { InternalProperty: 4 } ? 104 : 0;", 104); + default: + throw new ArgumentException("Unknown edit: " + editName); + } + } + + private static async Task RunEditAsync(FixtureKind fixture, BodyEdit edit) + { + string path = FixturePath(FixtureFileName(fixture)); + string fragment = edit.Fragment ?? ValueBody(fixture); + return await RunAsync( + new[] { path }, + new Dictionary + { + [path] = WriteEdited(path, "UnpassedInternal" + fixture + edit.MethodName + ".cs", fragment, edit.Replacement) + }); + } + + private static async Task AssertPatchedAsEditedAsync(HotReloadOrchestratorResult result, FixtureKind fixture, BodyEdit edit) + { + Assert.That(FindRow(result, FixtureTypeName(fixture), edit.MethodName).Kind, Is.EqualTo(HotReloadMethodOutcomeKind.Patched), FormatOutcomes(result)); + int value = await CallFixtureAsync(fixture, edit.MethodName); + Assert.That(value, Is.EqualTo(edit.EditedValue), FormatOutcomes(result)); + } + + // Why the value follows the row: a skipped row keeps the compiled body, and a patched row + // must run the edited one. A call that throws fails the test, which is the point. + private static async Task AssertAppliedAsEditedOrSkippedAsync(HotReloadOrchestratorResult result, FixtureKind fixture, BodyEdit edit) + { + HotReloadMethodOutcomeKind kind = FindRow(result, FixtureTypeName(fixture), edit.MethodName).Kind; + Assert.That( + kind, + Is.EqualTo(HotReloadMethodOutcomeKind.Patched).Or.EqualTo(HotReloadMethodOutcomeKind.Skipped), + FormatOutcomes(result)); + int expected = kind == HotReloadMethodOutcomeKind.Patched ? edit.EditedValue : CompiledValue(fixture, edit.MethodName); + int value = await CallFixtureAsync(fixture, edit.MethodName); + Assert.That(value, Is.EqualTo(expected), FormatOutcomes(result)); + } + + private static int CompiledValue(FixtureKind fixture, string methodName) + { + switch (methodName) + { + case DerivedValue: + return fixture == FixtureKind.Plain ? 10 : 9; + case ClosureValue: + return 30; + case ClosureSeedPlusValue: + return 1007; + case AsyncValue: + return 50; + case DerivedPropertyGetter: + return 40; + case RaiseDerivedEvent: + return 60; + default: + return 1000; + } + } + + // Why async: the async fixture method is awaited rather than waited on, because an EditMode + // test must not block the main thread on a task. + private static async Task CallFixtureAsync(FixtureKind fixture, string methodName) + { + if (fixture == FixtureKind.Plain) + { + HotReloadPlainDerivedFixture plain = new HotReloadPlainDerivedFixture(); + switch (methodName) + { + case DerivedValue: + return plain.DerivedValue(); + case ClosureValue: + return plain.ClosureValue(); + case ClosureSeedValue: + return plain.ClosureSeedValue(); + case ClosureSeedPlusValue: + return plain.ClosureSeedPlusValue(); + case IteratorValues: + return First(plain.IteratorValues()); + case DerivedPropertyGetter: + return plain.DerivedProperty; + case AsyncValue: + return await plain.AsyncValue(); + case RaiseDerivedEvent: + return plain.RaiseDerivedEvent(); + } + } + else + { + HotReloadPartialDerivedFixture partial = new HotReloadPartialDerivedFixture(); + switch (methodName) + { + case DerivedValue: + return partial.DerivedValue(); + case ClosureValue: + return partial.ClosureValue(); + case ClosureSeedValue: + return partial.ClosureSeedValue(); + case ClosureSeedPlusValue: + return partial.ClosureSeedPlusValue(); + case IteratorValues: + return First(partial.IteratorValues()); + case DerivedPropertyGetter: + return partial.DerivedProperty; + case AsyncValue: + return await partial.AsyncValue(); + case RaiseDerivedEvent: + return partial.RaiseDerivedEvent(); + } + } + + throw new ArgumentException("Unknown fixture method: " + methodName); + } + + private static int CallCaller(string methodName) + { + HotReloadInternalMemberCaller caller = new HotReloadInternalMemberCaller(); + return methodName == "CallsInternal" ? caller.CallsInternal() : caller.PlainValue(); + } + + private static int First(IEnumerable values) + { + foreach (int value in values) + { + return value; + } + + throw new InvalidOperationException("The iterator yielded nothing."); + } + + private static string FixtureFileName(FixtureKind fixture) + { + return fixture == FixtureKind.Plain ? PlainDerivedFileName : PartialDerivedFileName; + } + + private static string FixtureTypeName(FixtureKind fixture) + { + return fixture == FixtureKind.Plain ? nameof(HotReloadPlainDerivedFixture) : nameof(HotReloadPartialDerivedFixture); + } + + private static string ValueBody(FixtureKind fixture) + { + return fixture == FixtureKind.Plain ? PlainDerivedValueBody : PartialDerivedValueBody; + } + + // Why the type and the parenthesis: a bare method name would also match a longer name that + // contains it, or the same method on the other fixture type. + private static HotReloadMethodOutcome FindRow(HotReloadOrchestratorResult result, string typeName, string methodName) + { + string labelPart = "." + typeName + "." + methodName + "("; + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + if (outcome.Method != null && outcome.Method.Contains(labelPart)) + { + return outcome; + } + } + + Assert.Fail("No row for " + typeName + "." + methodName + ".\n" + FormatOutcomes(result)); + return null; + } + + private static Task RunAsync(string[] files, Dictionary overrides) + { + return HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + files, + contentPathOverride: null, + CancellationToken.None, + new Dictionary(overrides)); + } + + private static string WriteEdited(string sourcePath, string editedFileName, string fragment, string replacement) + { + return HotReloadTestSourceWriter.WriteEditedSource( + editedFileName, + ReplaceOnce(File.ReadAllText(sourcePath), fragment, replacement)); + } + + // Why the uniqueness check: a fragment that also matched another member would edit a method + // the test does not call, and the assert would pass or fail for the wrong reason. + private static string ReplaceOnce(string source, string fragment, string replacement) + { + int first = source.IndexOf(fragment, StringComparison.Ordinal); + Assert.That(first, Is.GreaterThanOrEqualTo(0), "Fragment missing from the fixture: " + fragment); + Assert.That( + source.IndexOf(fragment, first + fragment.Length, StringComparison.Ordinal), + Is.EqualTo(-1), + "Fragment occurs more than once in the fixture: " + fragment); + return source.Replace(fragment, replacement, StringComparison.Ordinal); + } + + private static string FixturePath(string fileName) + { + string path = Path.GetFullPath(Path.Combine(Application.dataPath, "Tests", "Editor", "HotReload", fileName)); + Assert.That(File.Exists(path), Is.True, "Fixture missing: " + path); + return path; + } + + private static string FormatOutcomes(HotReloadOrchestratorResult result) + { + List lines = new List(); + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + lines.Add(outcome.Kind + " " + outcome.Method + " :: " + outcome.Reason); + } + + return string.Join("\n", lines) + "\nWarnings:\n" + string.Join("\n", result.Warnings); + } + + // One edit of a fixture method: the fragment it replaces (null for the fixture's own + // DerivedValue body, which differs between the two fixtures) and the value the edit returns. + private sealed class BodyEdit + { + internal BodyEdit(string methodName, string fragment, string replacement, int editedValue) + { + MethodName = methodName; + Fragment = fragment; + Replacement = replacement; + EditedValue = editedValue; + } + + internal string MethodName { get; } + internal string Fragment { get; } + internal string Replacement { get; } + internal int EditedValue { get; } + + internal static BodyEdit OfDerivedValue(string replacement, int editedValue) + { + return new BodyEdit(DerivedValue, null, replacement, editedValue); + } + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs.meta new file mode 100644 index 0000000000..ffe4cbfe7a --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bcb10cd9148204b379eb58e797e6a7fb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs new file mode 100644 index 0000000000..cbc657a8f9 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs @@ -0,0 +1,166 @@ +using System.IO; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +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. + /// + public class HotReloadVirtualPlayerProjectTests + { + private static readonly string VirtualPlayerRoot = + Path.Combine("workspace", "project", "Library", "VP", "mppm0a1b2c3d"); + + private static readonly string OrdinaryProjectRoot = Path.Combine("workspace", "project"); + + /// + /// What: a player directory directly under Library/VP is recognized as a Virtual Player root. + /// + [Test] + public void IsVirtualPlayerProjectRoot_PlayerDirectoryUnderLibraryVP_ReturnsTrue() + { + 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. + /// + [Test] + public void DescribeMissingCompiledAssembly_VirtualPlayerRoot_SaysHotReloadCannotPatchAVirtualPlayer() + { + string dllPath = Path.Combine(VirtualPlayerRoot, "Library", "ScriptAssemblies", "Sample.dll"); + + string reason = HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(VirtualPlayerRoot, dllPath); + + Assert.That(reason, Does.Contain(dllPath)); + Assert.That(reason, Does.Contain("Virtual Player")); + Assert.That(reason, Does.Contain("main Editor")); + Assert.That(reason, Does.Contain("compile")); + Assert.That(reason, Does.Not.Contain("Compile the project first")); + } + + /// + /// What: for an ordinary project, the reason keeps the compile-first text unchanged. + /// + [Test] + public void DescribeMissingCompiledAssembly_OrdinaryRoot_KeepsTheCompileFirstText() + { + string dllPath = Path.Combine(OrdinaryProjectRoot, "Library", "ScriptAssemblies", "Sample.dll"); + + string reason = HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(OrdinaryProjectRoot, dllPath); + + Assert.That( + reason, + Is.EqualTo("Compiled assembly not found at '" + dllPath + "'. Compile the project first.")); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs.meta new file mode 100644 index 0000000000..62a0d7e1b6 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d5931f630b52945939be6449c828de76 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs index 68e6dd8edd..abac8d519c 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs @@ -210,9 +210,35 @@ private static IEnumerable RenderCases() + "(state-machine MoveNext JIT-compiles normally and fails accessibility checks)." + " Accessor rewrite unavailable: " + GenericMethodFragment); yield return Case( - HotReloadWorkerReasonCode.MethodTransformPartialType, - NoArgs, - "Partial types are skipped because a single file cannot provide a complete semantic model."); + HotReloadWorkerReasonCode.MethodTransformPartialOtherPartChanged, + new[] { "Assets/Scripts/Presenter.Other.cs" }, + "Another part of this partial type changed since the last compile " + + "(Assets/Scripts/Presenter.Other.cs), so hot reload cannot bind this method against the " + + "compiled type. Pass that file with --files too, or run 'uloop compile'."); + yield return Case( + HotReloadWorkerReasonCode.MethodTransformPartialOtherPartsUnverified, + NoArgs, + "The other parts of this partial type could not be checked against the last compile " + + "(no source snapshot for the assembly, or too many changed files to scan), so hot reload " + + "cannot bind this method against the compiled type. Run 'uloop compile'."); + yield return Case( + HotReloadWorkerReasonCode.MethodTransformPartialBodyUnbound, + new[] { "CS0103: The name '_generated' does not exist in the current context" }, + "CS0103: The name '_generated' does not exist in the current context. None of this partial " + + "type's source files known to hot reload declares that name: a part generated at compile " + + "time is not visible to it, and a file added since the last compile must be passed with " + + "--files. Otherwise run 'uloop compile'."); + yield return Case( + HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberOutOfReach, + new[] { "CS0117: 'Host' does not contain a definition for 'Value'", "'Host'" }, + "CS0117: 'Host' does not contain a definition for 'Value'. That member is internal to 'Host', " + + "whose source this reload was not given. Hot reload patches a use of such a member only where " + + "it is a field, a property or a method call written with its receiver ('this.Name', " + + "'Type.Name', 'value.Name') in the method's own statements: not a bare name, a method passed " + + "as a delegate, or a use inside a lambda, local function, query, iterator or async method, or " + + "in a body patched through a delegating shim. A lambda, local function or query that works " + + "with a value hot reload could not resolve, such as the member's result, keeps the whole body " + + "out as well. Qualify a bare name with 'this.' or the type name, or run 'uloop compile'."); yield return Case( HotReloadWorkerReasonCode.MethodTransformStructHost, NoArgs, @@ -252,6 +278,13 @@ private static IEnumerable RenderCases() NoArgs, "Added generic methods are skipped; hot reload cannot emit a typed shim for them. " + "Run 'uloop compile'."); + yield return Case( + HotReloadWorkerReasonCode.AddedMethodSignatureTypeUnresolved, + new[] { "Missing" }, + "The method signature names a type the hot-reload compilation could not resolve " + + "('Missing'), so hot reload cannot tell whether this method already exists " + + "in the compiled assembly. If the type is declared in another file of this edit, pass " + + "that file with --files too; otherwise run 'uloop compile'."); yield return Case( HotReloadWorkerReasonCode.AddedMethodMethodGroupReference, new[] { "Helper", " (such as 'a => Helper(a)')" }, @@ -314,7 +347,9 @@ private static IEnumerable RenderCases() "The added member's body could not be fully bound in the hot-reload compilation " + "(CS1503: Argument 1: cannot convert); hot reload cannot verify a member it cannot bind, " + "so it is skipped. If the name is declared in a new file, pass that file to --files too " - + "(new files are not selected automatically); run 'uloop compile' only if it still does not bind."); + + "(new files are not selected automatically). If the name is generated at compile time, " + + "for example by a source generator, hot reload cannot see it: run 'uloop compile'. " + + "Also run 'uloop compile' if it still does not bind."); yield return Case( HotReloadWorkerReasonCode.AddedMethodBodyBindsCompiledSignature, new[] { "CS1503: Argument 1: cannot convert", "'Example.Payload'", "'Example.Registry'", "'Assets/Registry.cs'" }, diff --git a/Assets/Tests/Editor/HotReload/InternalsExposureTestImage.cs b/Assets/Tests/Editor/HotReload/InternalsExposureTestImage.cs index 0d0ff23074..fd94d9c0cd 100644 --- a/Assets/Tests/Editor/HotReload/InternalsExposureTestImage.cs +++ b/Assets/Tests/Editor/HotReload/InternalsExposureTestImage.cs @@ -60,33 +60,9 @@ internal static InternalsExposureTestImage CreateWithConstantOfMissingEnum( Action configure = null) { string externalDirectory = Path.Combine(Application.temporaryCachePath, externalName); - Directory.CreateDirectory(externalDirectory); try { - using DefaultAssemblyResolver writeResolver = new DefaultAssemblyResolver(); - writeResolver.AddSearchDirectory(externalDirectory); - using AssemblyDefinition external = AssemblyDefinition.CreateAssembly( - new AssemblyNameDefinition(externalName, new Version(1, 0, 0, 0)), externalName, ModuleKind.Dll); - TypeDefinition externalKind = new TypeDefinition( - "", "ExternalKind", TypeAttributes.Public | TypeAttributes.Sealed, - external.MainModule.ImportReference(typeof(Enum))); - externalKind.Fields.Add(new FieldDefinition( - "value__", - FieldAttributes.Public | FieldAttributes.SpecialName | FieldAttributes.RTSpecialName, - external.MainModule.TypeSystem.Int32)); - external.MainModule.Types.Add(externalKind); - external.Write(Path.Combine(externalDirectory, externalName + ".dll")); - return new InternalsExposureTestImage( - candidate => - { - configure?.Invoke(candidate); - candidate.Fields.Add(new FieldDefinition( - "Default", - FieldAttributes.Assembly | FieldAttributes.Static | FieldAttributes.Literal - | FieldAttributes.HasDefault, - candidate.Module.ImportReference(externalKind)) { Constant = 1 }); - }, - writeResolver); + return CreateWithConstantOfEnumIn(externalDirectory, externalName, configure); } finally { @@ -94,6 +70,41 @@ internal static InternalsExposureTestImage CreateWithConstantOfMissingEnum( } } + // Why the enum's assembly is left in place: a copy of this image can then be written only by + // a resolver that searches externalDirectory, so a test can tell which search directories + // reach it. The caller owns externalDirectory and deletes it. + internal static InternalsExposureTestImage CreateWithConstantOfEnumIn( + string externalDirectory, + string externalName, + Action configure = null) + { + Directory.CreateDirectory(externalDirectory); + using DefaultAssemblyResolver writeResolver = new DefaultAssemblyResolver(); + writeResolver.AddSearchDirectory(externalDirectory); + using AssemblyDefinition external = AssemblyDefinition.CreateAssembly( + new AssemblyNameDefinition(externalName, new Version(1, 0, 0, 0)), externalName, ModuleKind.Dll); + TypeDefinition externalKind = new TypeDefinition( + "", "ExternalKind", TypeAttributes.Public | TypeAttributes.Sealed, + external.MainModule.ImportReference(typeof(Enum))); + externalKind.Fields.Add(new FieldDefinition( + "value__", + FieldAttributes.Public | FieldAttributes.SpecialName | FieldAttributes.RTSpecialName, + external.MainModule.TypeSystem.Int32)); + external.MainModule.Types.Add(externalKind); + external.Write(Path.Combine(externalDirectory, externalName + ".dll")); + return new InternalsExposureTestImage( + candidate => + { + configure?.Invoke(candidate); + candidate.Fields.Add(new FieldDefinition( + "Default", + FieldAttributes.Assembly | FieldAttributes.Static | FieldAttributes.Literal + | FieldAttributes.HasDefault, + candidate.Module.ImportReference(externalKind)) { Constant = 1 }); + }, + writeResolver); + } + internal static MethodDefinition AddReadMethod(TypeDefinition type, string name, MethodAttributes access) { MethodDefinition method = new MethodDefinition(name, diff --git a/Assets/Tests/Editor/HotReload/PublicizerTestSearchDirectories.cs b/Assets/Tests/Editor/HotReload/PublicizerTestSearchDirectories.cs index d66085d7af..c9a8126715 100644 --- a/Assets/Tests/Editor/HotReload/PublicizerTestSearchDirectories.cs +++ b/Assets/Tests/Editor/HotReload/PublicizerTestSearchDirectories.cs @@ -19,6 +19,15 @@ internal static class PublicizerTestSearchDirectories private const string HotReloadTestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; public static IReadOnlyCollection ForHotReloadTestAssembly() + { + return ReferencePublicizer.CollectResolverSearchDirectories(HotReloadTestAssembly().allReferences); + } + + /// + /// Returns Unity's compilation assembly for this test assembly, failing the test when the + /// pipeline does not list it. + /// + public static UnityCompilationAssembly HotReloadTestAssembly() { UnityCompilationAssembly compilationAssembly = FindHotReloadTestAssembly(); Assert.That( @@ -26,7 +35,7 @@ public static IReadOnlyCollection ForHotReloadTestAssembly() Is.Not.Null, "CompilationPipeline assembly not found: " + HotReloadTestAssemblyName); - return ReferencePublicizer.CollectResolverSearchDirectories(compilationAssembly.allReferences); + return compilationAssembly; } private static UnityCompilationAssembly FindHotReloadTestAssembly() diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerAddedFieldTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerAddedFieldTests.cs index 5d28a0710b..e5908764c4 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerAddedFieldTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerAddedFieldTests.cs @@ -58,7 +58,7 @@ public class TransformWorkerAddedFieldTests // Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OutsideMethodBodyDriftChecker.cs. // That constant lives in the Unity-ignored worker process and is not visible here. private const string OutsideMethodBodyDriftWarningFormat = - "Edits outside method bodies in {0} (fields, initializers, or attributes) are not applied by hot reload; run uloop compile to pick them up."; + "Edits outside method bodies in {0} (fields, initializers, or attributes) since the last compile are not applied by hot reload; run uloop compile to pick them up."; private const string FieldKindChangeProjectRelativePath = "Assets/Tests/Editor/HotReload/HotReloadAddedMemberHost.cs"; @@ -1470,7 +1470,7 @@ public async Task Drift_ExistingInitializerEditWithAddedField_StillWarns() Assert.That(foundDrift, Is.True, "Existing field initializer edits must still warn."); AssertHasDeclarationDriftWarning( result, - "Edits outside method bodies in AddedFieldWithInitializerDrift.cs (field initializer: PublicSeed) are not applied by hot reload; run uloop compile to pick them up."); + "Edits outside method bodies in AddedFieldWithInitializerDrift.cs (field initializer: PublicSeed) since the last compile are not applied by hot reload; run uloop compile to pick them up."); Assert.That(result.Output.hasAddedFieldRewrites, Is.True); } @@ -1496,7 +1496,7 @@ public async Task Drift_ExistingInitializerEdit_NamesDeclaration() Is.EqualTo( new[] { - "Edits outside method bodies in NamedInitializerDrift.cs (field initializer: PublicSeed) are not applied by hot reload; run uloop compile to pick them up." + "Edits outside method bodies in NamedInitializerDrift.cs (field initializer: PublicSeed) since the last compile are not applied by hot reload; run uloop compile to pick them up." })); } @@ -1525,7 +1525,7 @@ public async Task Drift_FieldDeclarationOrderSwap_EmitsFileOnlyWarning() Is.EqualTo( new[] { - "Edits outside method bodies in FieldOrderSwapDrift.cs (fields, initializers, or attributes) are not applied by hot reload; run uloop compile to pick them up." + "Edits outside method bodies in FieldOrderSwapDrift.cs (fields, initializers, or attributes) since the last compile are not applied by hot reload; run uloop compile to pick them up." })); } @@ -1552,7 +1552,7 @@ public async Task Drift_MultiDeclaratorAttributeEdit_NamesEverySibling() Is.EqualTo( new[] { - "Edits outside method bodies in MultiDeclaratorAttributeDrift.cs (field attributes: PairAlpha, PairBeta) are not applied by hot reload; run uloop compile to pick them up." + "Edits outside method bodies in MultiDeclaratorAttributeDrift.cs (field attributes: PairAlpha, PairBeta) since the last compile are not applied by hot reload; run uloop compile to pick them up." })); } @@ -1578,7 +1578,7 @@ public async Task Drift_MultiDeclaratorInitializerEdit_NamesOnlyChangedVariable( Is.EqualTo( new[] { - "Edits outside method bodies in MultiDeclaratorInitializerDrift.cs (field initializer: PairAlpha) are not applied by hot reload; run uloop compile to pick them up." + "Edits outside method bodies in MultiDeclaratorInitializerDrift.cs (field initializer: PairAlpha) since the last compile are not applied by hot reload; run uloop compile to pick them up." })); } @@ -1604,7 +1604,7 @@ public async Task Drift_MultiDeclaratorRegroup_FailsOpenToFileOnlyWarning() Is.EqualTo( new[] { - "Edits outside method bodies in MultiDeclaratorRegroupDrift.cs (fields, initializers, or attributes) are not applied by hot reload; run uloop compile to pick them up." + "Edits outside method bodies in MultiDeclaratorRegroupDrift.cs (fields, initializers, or attributes) since the last compile are not applied by hot reload; run uloop compile to pick them up." })); } @@ -1634,10 +1634,72 @@ public async Task Drift_DuplicateFieldSyntaxKey_FailsOpenToFileOnlyWarning() Is.EqualTo( new[] { - "Edits outside method bodies in DupFieldKeyFailOpen.cs (fields, initializers, or attributes) are not applied by hot reload; run uloop compile to pick them up." + "Edits outside method bodies in DupFieldKeyFailOpen.cs (fields, initializers, or attributes) since the last compile are not applied by hot reload; run uloop compile to pick them up." })); } + /// + /// What: when a duplicate field syntax key sends the drift check to the whole-tree + /// fail-open compare, an edit that only adds a comment still emits no warning. + /// + [Test] + public async Task Drift_DuplicateFieldSyntaxKeyWithCommentOnlyEdit_DoesNotWarn() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + // Why the duplicate repeats a compiled field: a name the compiled type lacks counts as + // an added field of the edited source and is stripped from that tree only, so the + // compare would warn without any comment. + string snapshotSource = onDisk.Replace( + " public int PublicSeed = 3;", + " public int PublicSeed = 3;\n public int PublicSeed = 3;", + StringComparison.Ordinal); + string edited = snapshotSource.Replace( + " public int PublicSeed = 3;\n public int PublicSeed = 3;", + " // comment added by the test\n public int PublicSeed = 3;\n public int PublicSeed = 3;", + StringComparison.Ordinal); + Assert.That(edited, Is.Not.EqualTo(snapshotSource), "Precondition: the edit must add the comment."); + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + WriteEdited("DupFieldKeyCommentOnly.cs", edited), + HostProjectRelativePath, + snapshotSource: snapshotSource); + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That( + result.Output.files[0].declarationDriftWarnings, + Is.Empty, + string.Join("\n", result.Output.files[0].declarationDriftWarnings ?? Array.Empty())); + } + + /// + /// What: adding only a line comment above the attribute list of an existing field emits no + /// outside-method-body warning. + /// + [Test] + public async Task Drift_LineCommentOnlyEditAboveAFieldAttribute_DoesNotWarn() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + // Why the snapshot carries the attribute too: no field of the compiled host has one, and + // the comment must be the only difference between the snapshot and the edited source. + string snapshotSource = onDisk.Replace( + " public int PublicSeed = 3;", + " [SerializeField]\n public int PublicSeed = 3;", + StringComparison.Ordinal); + string edited = snapshotSource.Replace( + " [SerializeField]\n public int PublicSeed = 3;", + " // comment added by the test\n [SerializeField]\n public int PublicSeed = 3;", + StringComparison.Ordinal); + Assert.That(snapshotSource, Is.Not.EqualTo(onDisk), "Precondition: the snapshot must add the attribute."); + Assert.That(edited, Is.Not.EqualTo(snapshotSource), "Precondition: the edit must add the comment."); + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + WriteEdited("FieldAttributeCommentOnly.cs", edited), + HostProjectRelativePath, + snapshotSource: snapshotSource); + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That( + result.Output.files[0].declarationDriftWarnings, + Is.Empty, + string.Join("\n", result.Output.files[0].declarationDriftWarnings ?? Array.Empty())); + } + /// /// What: a readonly added field can still be read through GetOrInit. /// @@ -1922,7 +1984,7 @@ public async Task Drift_PropertyKindChangeWithInitializerEdit_StillEmitsNamedOut new[] { "Compiled property 'io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadFieldKindChangeFixture.Hp' was removed or redeclared as a different member kind in the edited source; the compiled member stays until 'uloop compile'.", - "Edits outside method bodies in PropertyKindChangeWithInitializer.cs (field initializer: PublicSeed) are not applied by hot reload; run uloop compile to pick them up." + "Edits outside method bodies in PropertyKindChangeWithInitializer.cs (field initializer: PublicSeed) since the last compile are not applied by hot reload; run uloop compile to pick them up." })); } @@ -1953,7 +2015,7 @@ public async Task Drift_EventKindChangeWithInitializerEdit_StillEmitsNamedOutsid new[] { "Compiled event 'io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadFieldKindChangeFixture.ScoreChanged' was removed or redeclared as a different member kind in the edited source; the compiled member stays until 'uloop compile'.", - "Edits outside method bodies in EventKindChangeWithInitializer.cs (field initializer: PublicSeed) are not applied by hot reload; run uloop compile to pick them up." + "Edits outside method bodies in EventKindChangeWithInitializer.cs (field initializer: PublicSeed) since the last compile are not applied by hot reload; run uloop compile to pick them up." })); } diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs index 1b90185006..94fab69642 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs @@ -474,6 +474,215 @@ public async Task Rewrite_NameofAddedMethod_FoldsToStringLiteral() Assert.That(slice, Does.Not.Contain("nameof(")); } + /// + /// What: nameof of a compiled instance field inside an added static method folds to a + /// string literal instead of naming an instance parameter the static shim does not have. + /// + [Test] + public async Task Rewrite_NameofInstanceFieldInAddedStaticMethod_FoldsToStringLiteral() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = WithHostMembers( + onDisk, + "private static int AddedNameLength()\n {\n return nameof(_privateSeed).Length;\n }"); + string sourcePath = WriteEdited("NameofInstanceFieldInAddedStatic.cs", edited); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + sourcePath, + HostProjectRelativePath, + snapshotSource: onDisk); + Assert.That(result.Success, Is.True, result.ErrorMessage); + + TransformWorkerEntryDto added = FindEntry(result, "AddedNameLength"); + Assert.That(added, Is.Not.Null, "Skipped=" + FormatSkipped(result.Output.skipped)); + string slice = SliceShimMethod(result.Output.shimSource, added.shimMethodName); + Assert.That(slice, Does.Contain("\"_privateSeed\"")); + Assert.That(slice, Does.Not.Contain("nameof(")); + Assert.That(slice, Does.Not.Contain("__uloopInstance")); + } + + /// + /// What: nameof of a compiled instance field, method group, and property inside an edited + /// existing static method folds to string literals, so its shim names no instance parameter. + /// + [Test] + public async Task Rewrite_NameofInstanceMembersInExistingStaticMethod_FoldsToStringLiterals() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = onDisk.Replace( + " private static int PrivateStaticSeven()\n {\n return 7;\n }", + " private static int PrivateStaticSeven()\n {\n" + + " return nameof(PublicSeed).Length + nameof(ExistingValue).Length" + + " + nameof(ExistingGetter).Length;\n" + + " }", + StringComparison.Ordinal); + Assert.That(edited, Is.Not.EqualTo(onDisk)); + string sourcePath = WriteEdited("NameofInstanceMembersInExistingStatic.cs", edited); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + sourcePath, + HostProjectRelativePath, + snapshotSource: onDisk); + Assert.That(result.Success, Is.True, result.ErrorMessage); + + TransformWorkerEntryDto existing = FindEntry(result, "PrivateStaticSeven"); + Assert.That(existing, Is.Not.Null, "Skipped=" + FormatSkipped(result.Output.skipped)); + Assert.That(existing.patchKind, Is.Not.EqualTo(HotReloadConstants.PatchKindAddedMethod)); + string slice = SliceShimMethod(result.Output.shimSource, existing.shimMethodName); + Assert.That(slice, Does.Contain("\"PublicSeed\"")); + Assert.That(slice, Does.Contain("\"ExistingValue\"")); + Assert.That(slice, Does.Contain("\"ExistingGetter\"")); + Assert.That(slice, Does.Not.Contain("nameof(")); + Assert.That(slice, Does.Not.Contain("__uloopInstance")); + } + + /// + /// What: nameof of a bare instance field, a this-qualified field, and a parameter inside an + /// instance method folds to string literals. + /// + [Test] + public async Task Rewrite_NameofInInstanceMethod_FoldsToStringLiterals() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = onDisk.Replace( + " public int ExistingCaller(int value)\n {\n return value;\n }", + " public int ExistingCaller(int value)\n {\n" + + " return nameof(_privateSeed).Length + nameof(this.PublicSeed).Length" + + " + nameof(value).Length + value;\n" + + " }", + StringComparison.Ordinal); + string sourcePath = WriteEdited("NameofInInstanceMethod.cs", edited); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + sourcePath, + HostProjectRelativePath, + snapshotSource: onDisk); + Assert.That(result.Success, Is.True, result.ErrorMessage); + + TransformWorkerEntryDto caller = FindEntry(result, nameof(HotReloadAddedMemberHost.ExistingCaller)); + Assert.That(caller, Is.Not.Null, "Skipped=" + FormatSkipped(result.Output.skipped)); + string slice = SliceShimMethod(result.Output.shimSource, caller.shimMethodName); + Assert.That(slice, Does.Contain("\"_privateSeed\"")); + Assert.That(slice, Does.Contain("\"PublicSeed\"")); + Assert.That(slice, Does.Contain("\"value\"")); + Assert.That(slice, Does.Not.Contain("nameof(")); + } + + /// + /// What: nameof of a name that does not bind stays a nameof expression in the shim instead + /// of folding to a name the original code never compiled with. + /// + [Test] + public async Task Rewrite_NameofUnboundName_IsNotFolded() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = onDisk.Replace( + " public int ExistingCaller(int value)\n {\n return value;\n }", + " public int ExistingCaller(int value)\n {\n return nameof(NoSuchName).Length + value;\n }", + StringComparison.Ordinal); + string sourcePath = WriteEdited("NameofUnboundName.cs", edited); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + sourcePath, + HostProjectRelativePath, + snapshotSource: onDisk); + Assert.That(result.Success, Is.True, result.ErrorMessage); + + TransformWorkerEntryDto caller = FindEntry(result, nameof(HotReloadAddedMemberHost.ExistingCaller)); + Assert.That(caller, Is.Not.Null, "Skipped=" + FormatSkipped(result.Output.skipped)); + string slice = SliceShimMethod(result.Output.shimSource, caller.shimMethodName); + Assert.That(slice, Does.Contain("nameof(NoSuchName)")); + } + + /// + /// What: nameof of a generic type whose type argument does not bind stays a nameof + /// expression in the shim even though the outer type name itself binds. + /// + [Test] + public async Task Rewrite_NameofWithUnboundTypeArgument_IsNotFolded() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = onDisk.Replace( + " public int ExistingCaller(int value)\n {\n return value;\n }", + " public int ExistingCaller(int value)\n {\n" + + " return nameof(System.Collections.Generic.List).Length + value;\n" + + " }", + StringComparison.Ordinal); + string sourcePath = WriteEdited("NameofUnboundTypeArgument.cs", edited); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + sourcePath, + HostProjectRelativePath, + snapshotSource: onDisk); + Assert.That(result.Success, Is.True, result.ErrorMessage); + + TransformWorkerEntryDto caller = FindEntry(result, nameof(HotReloadAddedMemberHost.ExistingCaller)); + Assert.That(caller, Is.Not.Null, "Skipped=" + FormatSkipped(result.Output.skipped)); + string slice = SliceShimMethod(result.Output.shimSource, caller.shimMethodName); + Assert.That(slice, Does.Contain("nameof(")); + Assert.That(slice, Does.Not.Contain("\"List\"")); + } + + /// + /// What: nameof of an added method that is skipped, and so never registered as added, + /// still folds to a string literal instead of naming a member the compiled type lacks. + /// + [Test] + public async Task Rewrite_NameofAddedMethodThatIsNotApplied_FoldsToStringLiteral() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = WithHostMembers( + onDisk, + "public T AddedGenericProbe(T value)\n {\n return value;\n }"); + edited = edited.Replace( + " public int ExistingCaller(int value)\n {\n return value;\n }", + " public int ExistingCaller(int value)\n {\n" + + " return nameof(AddedGenericProbe).Length + value;\n" + + " }", + StringComparison.Ordinal); + string sourcePath = WriteEdited("NameofAddedMethodNotApplied.cs", edited); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + sourcePath, + HostProjectRelativePath, + snapshotSource: onDisk); + Assert.That(result.Success, Is.True, result.ErrorMessage); + + TransformWorkerEntryDto caller = FindEntry(result, nameof(HotReloadAddedMemberHost.ExistingCaller)); + Assert.That(caller, Is.Not.Null, "Skipped=" + FormatSkipped(result.Output.skipped)); + Assert.That(FindEntry(result, "AddedGenericProbe"), Is.Null); + string slice = SliceShimMethod(result.Output.shimSource, caller.shimMethodName); + Assert.That(slice, Does.Contain("\"AddedGenericProbe\"")); + Assert.That(slice, Does.Not.Contain("nameof(")); + } + + /// + /// What: a property pattern that names a compiled member of the target type keeps the + /// bare member name in the shim, since a pattern's member name cannot take a receiver. + /// + [Test] + public async Task Rewrite_PropertyPatternNamingCompiledMember_KeepsTheBareName() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = onDisk.Replace( + " public int ExistingCaller(int value)\n {\n return value;\n }", + " public int ExistingCaller(int value)\n {\n return Inner is { PublicSeed: 3 } ? 1 : value;\n }", + StringComparison.Ordinal); + string sourcePath = WriteEdited("PropertyPatternCompiledMember.cs", edited); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + sourcePath, + HostProjectRelativePath, + snapshotSource: onDisk); + Assert.That(result.Success, Is.True, result.ErrorMessage); + + TransformWorkerEntryDto caller = FindEntry(result, nameof(HotReloadAddedMemberHost.ExistingCaller)); + Assert.That(caller, Is.Not.Null, "Skipped=" + FormatSkipped(result.Output.skipped)); + string slice = SliceShimMethod(result.Output.shimSource, caller.shimMethodName); + Assert.That(slice, Does.Contain("PublicSeed:")); + Assert.That(slice, Does.Not.Contain("__uloopInstance.PublicSeed")); + } + /// /// What: added virtual, override, generic, and method-group-capturing methods are skipped /// with the documented reasons; the captured added instance method itself still emits. @@ -570,7 +779,8 @@ public async Task Run_AddedMethodReturningUnresolvedType_WithPrivateAccess_Repor Assert.That(result.Success, Is.True, result.ErrorMessage); string reason = FindSkipReason(result, "AddedUnresolved"); Assert.That(reason, Is.Not.Null, "Expected a skip for AddedUnresolved."); - Assert.That(reason, Does.Contain("could not be resolved")); + Assert.That(reason, Does.Contain("could not resolve")); + Assert.That(reason, Does.Contain("'MissingReturnType'")); Assert.That(reason, Does.Not.Contain("condition c")); } @@ -588,7 +798,8 @@ public async Task Run_AddedMethodReturningListOfUnresolvedType_ReportsUnresolved Assert.That(result.Success, Is.True, result.ErrorMessage); string reason = FindSkipReason(result, "AddedUnresolvedList"); Assert.That(reason, Is.Not.Null, "Expected a skip for AddedUnresolvedList."); - Assert.That(reason, Does.Contain("could not be resolved")); + Assert.That(reason, Does.Contain("could not resolve")); + Assert.That(reason, Does.Contain("'MissingType'")); Assert.That(reason, Does.Not.Contain("condition c")); } @@ -717,6 +928,91 @@ public async Task Warn_AddedTestMethod_KeepsEntryAndEmitsWarning() Has.Some.Contain("AddedProbe").And.Contain("Unity Test Runner")); } + /// + /// What: an added method with a test attribute says on its own entry that the Unity Test + /// Runner will not discover it until a compile, so a reader of its row alone learns it. + /// + [TestCase("[Test]\n public void AddedProbe()\n {\n }", "AddedProbe")] + [TestCase( + "[UnityTest]\n public System.Collections.IEnumerator AddedUnityProbe()\n {\n" + + " yield break;\n }", + "AddedUnityProbe")] + public async Task Emit_AddedTestMethod_CarriesTestRunnerLifecycleNote(string addedMember, string methodName) + { + // Why written out: the worker's constant lives in its own program, which the tests + // cannot reference. + const string expectedNote = + "Test method: not discovered by the Unity Test Runner until 'uloop compile'; " + + "'uloop run-tests --skip-compile' will not find or run it."; + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = WithHostMembers(onDisk, addedMember); + string sourcePath = WriteEdited("AddedTestMethodNote" + methodName + ".cs", edited); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + sourcePath, + HostProjectRelativePath, + snapshotSource: onDisk); + Assert.That(result.Success, Is.True, result.ErrorMessage); + + TransformWorkerEntryDto added = FindEntry(result, methodName); + Assert.That(added, Is.Not.Null, methodName + " must be an entry."); + Assert.That(added.patchKind, Is.EqualTo(HotReloadConstants.PatchKindAddedMethod)); + Assert.That(added.lifecycleNote, Is.EqualTo(expectedNote)); + } + + /// + /// What: a compiled method whose body was edited and that gained a test attribute is not + /// an added method, so its entry carries no Test Runner note, as it gets no warning. + /// + [Test] + public async Task Emit_EditedExistingTestMethod_HasNoLifecycleNote() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + const string originalExistingValue = + " public int ExistingValue()\n {\n return 1;\n }"; + Assert.That(onDisk, Does.Contain(originalExistingValue)); + // Why the body changes too: an unchanged body is recorded as unchanged and has no + // entry, so the note could not be checked. + string edited = onDisk.Replace( + originalExistingValue, + " [Test]\n public int ExistingValue()\n {\n return 99;\n }", + StringComparison.Ordinal); + string sourcePath = WriteEdited("EditedExistingTestMethodNote.cs", edited); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + sourcePath, + HostProjectRelativePath, + snapshotSource: onDisk); + Assert.That(result.Success, Is.True, result.ErrorMessage); + + TransformWorkerEntryDto entry = FindEntry(result, nameof(HotReloadAddedMemberHost.ExistingValue)); + Assert.That(entry, Is.Not.Null, "The edited ExistingValue must be an entry."); + Assert.That(entry.patchKind, Is.Not.EqualTo(HotReloadConstants.PatchKindAddedMethod)); + Assert.That(entry.lifecycleNote, Is.Null.Or.Empty); + } + + /// + /// What: an added method without a test attribute carries no Test Runner note. + /// + [Test] + public async Task Emit_AddedPlainMethod_HasNoLifecycleNote() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = WithHostMembers(onDisk, "public void AddedPlainProbe()\n {\n }"); + string sourcePath = WriteEdited("AddedPlainMethodNote.cs", edited); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + sourcePath, + HostProjectRelativePath, + snapshotSource: onDisk); + Assert.That(result.Success, Is.True, result.ErrorMessage); + + TransformWorkerEntryDto added = FindEntry(result, "AddedPlainProbe"); + Assert.That(added, Is.Not.Null, "AddedPlainProbe must be an entry."); + Assert.That(added.patchKind, Is.EqualTo(HotReloadConstants.PatchKindAddedMethod)); + Assert.That(added.lifecycleNote, Is.Null.Or.Empty); + } + /// /// What: added methods with a qualified [TestCase], [global::NUnit.Framework.Test], /// [UnityTest], or [SetUp] each produce a Unity Test Runner warning that names that method. @@ -2026,10 +2322,12 @@ public async Task Classify_PartialTypeMethodDeletion_OmitsRemovedSignatures() } /// - /// An added bodied property on a partial host is skipped before accessor shims are emitted. + /// What: an added bodied property on a partial host whose other part is unchanged is not + /// skipped for being on a partial type, and its getter is emitted as an added method the way + /// it is on a host that is not partial. /// [Test] - public async Task Skip_AddedBodiedPropertyOnPartialHost_SkipsAccessors() + public async Task Classify_AddedBodiedPropertyOnPartialHost_IsNotSkippedAsPartial() { string onDisk = File.ReadAllText(ResolveHostPath()); string edited = onDisk.Replace( @@ -2045,10 +2343,15 @@ public async Task Skip_AddedBodiedPropertyOnPartialHost_SkipsAccessors() snapshotSource: onDisk); Assert.That(result.Success, Is.True, result.ErrorMessage); - Assert.That(FindEntry(result, "get_AddedPartial"), Is.Null); - Assert.That(FindEntry(result, "set_AddedPartial"), Is.Null); - Assert.That(FindSkipReason(result, "get_AddedPartial"), Does.Contain("Partial types are skipped")); - Assert.That(FindSkipReason(result, "set_AddedPartial"), Does.Contain("Partial types are skipped")); + Assert.That(FindSkipReason(result, "get_AddedPartial") ?? string.Empty, Does.Not.Contain("Partial types are skipped")); + Assert.That(FindSkipReason(result, "set_AddedPartial") ?? string.Empty, Does.Not.Contain("Partial types are skipped")); + Assert.That(FindSkipReason(result, "get_AddedPartial") ?? string.Empty, Does.Not.Contain("partial type")); + Assert.That(FindSkipReason(result, "set_AddedPartial") ?? string.Empty, Does.Not.Contain("partial type")); + + TransformWorkerEntryDto getter = FindEntry(result, "get_AddedPartial"); + Assert.That(getter, Is.Not.Null, "Added property getter must be an entry."); + Assert.That(getter.patchKind, Is.EqualTo(HotReloadConstants.PatchKindAddedMethod)); + Assert.That(FindSkipReason(result, "get_AddedPartial"), Is.Null); } /// @@ -2398,6 +2701,9 @@ private static async Task RunWorkerOnSourceAsync( referencePaths = referencePaths, targetTypesAssemblyPath = targetDllPath, assemblySourcePaths = assemblySourcePaths, + // No sibling is passed, which stands for a scan that compared every sibling and + // found none changed. + changedSiblingScanComplete = true, excludedMethodKeys = excludedMethodKeys ?? Array.Empty(), excludedAddedMethodKeys = excludedAddedMethodKeys ?? Array.Empty() }; diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs index c4664c106a..a2e36dce3b 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs @@ -113,6 +113,9 @@ public async Task RunWorker_UnknownOperation_ReturnsFailure() "HotReloadSiblingEnumDefinitions.cs", "HotReloadInternalMonoBehaviourBase.cs", "HotReloadIntroducedTypeStageProbe.cs", + "HotReloadInternalSignatureProbe.cs", + "HotReloadGlobalUsingBehaviourBase.cs", + "HotReloadGlobalUsingMode.cs", }; /// @@ -1062,6 +1065,103 @@ public async Task Run_WithSnapshotFieldInitializerChanged_EmitsOutsideMethodBody Has.Some.Contain("Edits outside method bodies in HotReloadE2EFixtures.cs")); } + /// + /// What: adding only an XML documentation comment above an existing method emits neither + /// the file-level nor a named outside-method-body warning. + /// + [Test] + public async Task Run_WithXmlDocCommentOnlyEditOnAMethod_DoesNotEmitOutsideMethodBodyWarning() + { + const string fileName = "XmlDocCommentOnMethodDrift.cs"; + TransformWorkerClientResult result = await RunWorkerOnEditedE2ECopyAsync( + fileName, + editedSource => ReplaceUniqueFragment( + editedSource, + " public int VisibleSibling()", + " /// Documentation added by the test.\n public int VisibleSibling()")); + + AssertDoesNotContainOutsideMethodBodyDriftWarning(result, fileName); + Assert.That(result.Output.files[0].declarationDriftWarnings, Is.Empty); + } + + /// + /// What: adding only a block comment inside a field initializer emits neither the + /// file-level nor a named outside-method-body warning. + /// + [Test] + public async Task Run_WithBlockCommentOnlyEditInsideAFieldInitializer_DoesNotEmitOutsideMethodBodyWarning() + { + const string fileName = "BlockCommentInFieldInitializerDrift.cs"; + TransformWorkerClientResult result = await RunWorkerOnEditedE2ECopyAsync( + fileName, + editedSource => ReplaceUniqueFragment( + editedSource, + "private int _secret = 10;", + "private int _secret = /* comment added by the test */ 10;")); + + AssertDoesNotContainOutsideMethodBodyDriftWarning(result, fileName); + Assert.That(result.Output.files[0].declarationDriftWarnings, Is.Empty); + } + + /// + /// What: adding only a line comment on its own line above a field emits neither the + /// file-level nor a named outside-method-body warning. + /// + [Test] + public async Task Run_WithLineCommentOnlyEditAboveAField_DoesNotEmitOutsideMethodBodyWarning() + { + const string fileName = "LineCommentAboveFieldDrift.cs"; + TransformWorkerClientResult result = await RunWorkerOnEditedE2ECopyAsync( + fileName, + editedSource => ReplaceUniqueFragment( + editedSource, + " private int _secret = 10;", + " // comment added by the test\n private int _secret = 10;")); + + AssertDoesNotContainOutsideMethodBodyDriftWarning(result, fileName); + Assert.That(result.Output.files[0].declarationDriftWarnings, Is.Empty); + } + + /// + /// What: adding only a block comment between a field's type and its name emits neither the + /// file-level nor a named outside-method-body warning. + /// + [Test] + public async Task Run_WithBlockCommentOnlyEditAfterAFieldType_DoesNotEmitOutsideMethodBodyWarning() + { + const string fileName = "BlockCommentAfterFieldTypeDrift.cs"; + // Why after the type: a comment on the same line belongs to the token before it, so + // this one becomes part of the field's type rather than of its modifiers. + TransformWorkerClientResult result = await RunWorkerOnEditedE2ECopyAsync( + fileName, + editedSource => ReplaceUniqueFragment( + editedSource, + "private int _secret = 10;", + "private int /* comment added by the test */ _secret = 10;")); + + AssertDoesNotContainOutsideMethodBodyDriftWarning(result, fileName); + Assert.That(result.Output.files[0].declarationDriftWarnings, Is.Empty); + } + + /// + /// What: rewording only the XML documentation comment of the type emits neither the + /// file-level nor a named outside-method-body warning. + /// + [Test] + public async Task Run_WithXmlDocCommentOnlyEditOnTheType_DoesNotEmitOutsideMethodBodyWarning() + { + const string fileName = "XmlDocCommentOnTypeDrift.cs"; + TransformWorkerClientResult result = await RunWorkerOnEditedE2ECopyAsync( + fileName, + editedSource => ReplaceUniqueFragment( + editedSource, + "Compiled fixture whose on-disk source path", + "Prebuilt fixture whose on-disk source path")); + + AssertDoesNotContainOutsideMethodBodyDriftWarning(result, fileName); + Assert.That(result.Output.files[0].declarationDriftWarnings, Is.Empty); + } + /// /// What: editing only a const value emits the dedicated const-drift warning and does not /// also emit the generic outside-method-body warning. @@ -1144,6 +1244,44 @@ public async Task Run_WithChangedSiblingConstHolder_EmitsSiblingConstDriftWarnin "Sibling const-drift warnings must stay on siblingConstDriftWarnings, not declarationDriftWarnings."); } + /// + /// What: a changed sibling const whose type the sibling names only through another file's + /// global using is still compared with the compiled value and reported as drifted. + /// + [Test] + public async Task Run_WithChangedSiblingConstOfGlobalUsingEnumType_EmitsSiblingConstDriftWarning() + { + string onDisk = File.ReadAllText(ResolveE2EFixturePath()); + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string directory = Path.Combine(projectRoot, HotReloadConstants.TestSourcesRelativeDirectory); + Directory.CreateDirectory(directory); + string siblingPath = Path.Combine(directory, "SiblingGlobalUsingEnumConstDrift.cs"); + File.WriteAllText( + siblingPath, + "namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload\n" + + "{\n" + + " public static class HotReloadSiblingConstDefinitions\n" + + " {\n" + + " public const HotReloadGlobalUsingMode SiblingMode = HotReloadGlobalUsingMode.Second;\n" + + " }\n" + + "}\n"); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + ResolveE2EFixturePath(), + ResolveE2EFixtureProjectRelativePath(), + snapshotSource: onDisk, + additionalAssemblySourcePaths: null, + changedSiblingSourcePaths: new[] { siblingPath }); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(result.Output.siblingConstDriftWarnings, Is.Not.Null); + Assert.That( + result.Output.siblingConstDriftWarnings, + Has.Some.Contain("HotReloadSiblingConstDefinitions.SiblingMode is 2 in the edited source but 1"), + "The sibling const must bind its enum type through the assembly's global using.\n" + + string.Join("\n", result.Output.siblingConstDriftWarnings)); + } + /// /// What: editing only an enum member value emits the dedicated const-drift warning and /// does not also emit the generic outside-method-body warning. @@ -1248,7 +1386,7 @@ public async Task Run_WithNonConstFieldInitializerEdit_StillEmitsOutsideBodyWarn Assert.That( result.Output.files[0].declarationDriftWarnings, Does.Contain( - "Edits outside method bodies in NonConstFieldInitializerDrift.cs (field initializer: _secret) are not applied by hot reload; run uloop compile to pick them up.")); + "Edits outside method bodies in NonConstFieldInitializerDrift.cs (field initializer: _secret) since the last compile are not applied by hot reload; run uloop compile to pick them up.")); } /// @@ -1538,6 +1676,47 @@ public async Task Run_WithSelfSnapshotOnArityDistinctMethods_TreatsBothUnchanged "Both F(int) and F(int) must appear in unchangedMethods after arity normalization."); } + /// + /// What: a generic method whose parameter type cannot be resolved no longer matches its + /// compiled signature, and its skip reason names that type instead of calling it an added + /// generic method, so the caller sees the missing type rather than a generic-method limit. + /// + [Test] + public async Task Run_GenericMethodWhoseParameterTypeDoesNotResolve_IsSkippedNamingTheType() + { + const string fileName = "HotReloadShapeFixtures.cs"; + string onDisk = File.ReadAllText(ResolveShapeFixturePath()); + string editedSource = onDisk.Replace( + "public int F(int x)", + "public int F(HotReloadMissingAlias x)", + StringComparison.Ordinal); + Assert.That(editedSource, Is.Not.EqualTo(onDisk), "Precondition: the generic parameter must change."); + + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string directory = Path.Combine(projectRoot, HotReloadConstants.TestSourcesRelativeDirectory); + Directory.CreateDirectory(directory); + string sourcePath = Path.Combine(directory, fileName); + File.WriteAllText(sourcePath, editedSource); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + sourcePath, + ResolveShapeFixtureProjectRelativePath(), + snapshotSource: onDisk); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + // Why "F`1(": the method label carries the generic arity, and "F(" would match the + // non-generic F(System.Int32) instead. + AssertHasSkip(result, "F`1(", "HotReloadMissingAlias"); + foreach (TransformWorkerSkippedDto skipped in result.Output.skipped) + { + string rendered = HotReloadWorkerReasonText.Render(skipped.reason); + Assert.That( + rendered, + Does.Not.Contain("Added generic methods are skipped"), + skipped.method + ": " + rendered); + } + } + /// /// What: after including ExplicitInterfaceSpecifier in syntax keys, IA.Run and IB.Run no /// longer collide, so an identical self-snapshot treats both as unchanged. @@ -1975,7 +2154,7 @@ public async Task Run_WithOnEnableOrOnDisable_EmitsDirectNoteThatTogglesEnabled( // Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OutsideMethodBodyDriftChecker.cs. // That constant lives in the Unity-ignored worker process and is not visible here. private const string OutsideMethodBodyDriftWarningFormat = - "Edits outside method bodies in {0} (fields, initializers, or attributes) are not applied by hot reload; run uloop compile to pick them up."; + "Edits outside method bodies in {0} (fields, initializers, or attributes) since the last compile are not applied by hot reload; run uloop compile to pick them up."; /// /// What: editing one instance constructor reports that .ctor as Skipped and omits an @@ -2330,7 +2509,7 @@ public async Task Run_UnsupportedMemberKind_CtorInitializerEdit_EmitsOutsideBody Assert.That( result.Output.files[0].declarationDriftWarnings, Does.Contain( - "Edits outside method bodies in UnsupportedKindCtorInitializerDrift.cs (constructor: .ctor) are not applied by hot reload; run uloop compile to pick them up.")); + "Edits outside method bodies in UnsupportedKindCtorInitializerDrift.cs (constructor: .ctor) since the last compile are not applied by hot reload; run uloop compile to pick them up.")); } /// @@ -2349,7 +2528,7 @@ public async Task Run_UnsupportedMemberKind_OperatorAttributeEdit_EmitsOutsideBo Assert.That( result.Output.files[0].declarationDriftWarnings, Does.Contain( - "Edits outside method bodies in UnsupportedKindOperatorAttributeDrift.cs (operator: +) are not applied by hot reload; run uloop compile to pick them up.")); + "Edits outside method bodies in UnsupportedKindOperatorAttributeDrift.cs (operator: +) since the last compile are not applied by hot reload; run uloop compile to pick them up.")); } /// @@ -2368,7 +2547,7 @@ public async Task Run_UnsupportedMemberKind_ConversionAttributeEdit_EmitsOutside Assert.That( result.Output.files[0].declarationDriftWarnings, Does.Contain( - "Edits outside method bodies in UnsupportedKindConversionAttributeDrift.cs (conversion: implicit->int) are not applied by hot reload; run uloop compile to pick them up.")); + "Edits outside method bodies in UnsupportedKindConversionAttributeDrift.cs (conversion: implicit->int) since the last compile are not applied by hot reload; run uloop compile to pick them up.")); } /// @@ -2387,7 +2566,7 @@ public async Task Run_UnsupportedMemberKind_EventAttributeEdit_EmitsOutsideBodyW Assert.That( result.Output.files[0].declarationDriftWarnings, Does.Contain( - "Edits outside method bodies in UnsupportedKindEventAttributeDrift.cs (event: Edited) are not applied by hot reload; run uloop compile to pick them up.")); + "Edits outside method bodies in UnsupportedKindEventAttributeDrift.cs (event: Edited) since the last compile are not applied by hot reload; run uloop compile to pick them up.")); } /// @@ -2632,6 +2811,19 @@ private static async Task RunWorkerOnEditedE2ECopyA return result; } + // Why the uniqueness check: Replace edits every occurrence, and a comment that also landed + // somewhere else would reach a comparison other than the one the test is about. + private static string ReplaceUniqueFragment(string source, string fragment, string replacement) + { + int index = source.IndexOf(fragment, StringComparison.Ordinal); + Assert.That(index, Is.GreaterThanOrEqualTo(0), "Precondition: the fixture must hold " + fragment); + Assert.That( + source.LastIndexOf(fragment, StringComparison.Ordinal), + Is.EqualTo(index), + "Precondition: the fixture must hold " + fragment + " exactly once."); + return source.Replace(fragment, replacement, StringComparison.Ordinal); + } + private static void AssertPatchedComputeWithPrivate(TransformWorkerClientResult result) { bool foundCompute = false; diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeTestInputs.cs b/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeTestInputs.cs index 628373c498..9de62793e7 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeTestInputs.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeTestInputs.cs @@ -19,7 +19,15 @@ internal static class TransformWorkerIntroducedTypeTestInputs { internal const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; - internal static TransformWorkerInputDto CreateInput(string firstSourcePath, string secondSourcePath) + // Builds a planning input for two edited sources. assemblySourcePaths stands in for the + // other files of the assembly, whose global usings the worker collects, and + // changedSiblingSourcePaths for the other edited files the run does not transform; null + // means none. + internal static TransformWorkerInputDto CreateInput( + string firstSourcePath, + string secondSourcePath, + string[] assemblySourcePaths = null, + string[] changedSiblingSourcePaths = null) { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); string targetDllPath = Path.Combine( @@ -50,8 +58,8 @@ internal static TransformWorkerInputDto CreateInput(string firstSourcePath, stri targetTypesAssemblyPath = targetDllPath, targetAssemblyName = TestAssemblyName, targetAssemblyMvid = typeof(TransformWorkerIntroducedTypeTests).Assembly.ManifestModule.ModuleVersionId.ToString(), - assemblySourcePaths = Array.Empty(), - changedSiblingSourcePaths = Array.Empty() + assemblySourcePaths = assemblySourcePaths ?? Array.Empty(), + changedSiblingSourcePaths = changedSiblingSourcePaths ?? Array.Empty() }; } diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeTests.cs index 1b7de3a25f..e7a658cbc7 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeTests.cs @@ -296,6 +296,146 @@ public async Task PrepareIntroducedTypes_GlobalUsingAlias_CompilesTwoIntroducedT Assert.That(subsetCompileResult.Artifact.Assembly.GetType("GlobalAliasFixture.Second"), Is.Not.Null); } + /// + /// Verifies that a global alias declared only in another file of the assembly, not in + /// either edited source, still lets planning bind and compile both introduced types. + /// + [Test] + public async Task PrepareIntroducedTypes_SiblingGlobalUsingAlias_CompilesTwoIntroducedTypes() + { + string directory = TransformWorkerIntroducedTypeTestInputs.CreateSourceDirectory("SiblingGlobalUsingAlias"); + string firstSourcePath = Path.Combine(directory, "First.cs"); + string secondSourcePath = Path.Combine(directory, "Second.cs"); + string globalUsingsPath = Path.Combine(directory, "GlobalUsings.cs"); + File.WriteAllText( + firstSourcePath, + "namespace GlobalAliasFixture { public class First { public Alias Create() { return null; } } }"); + File.WriteAllText( + secondSourcePath, + "namespace GlobalAliasFixture { public class Second { public Alias Create() { return null; } } }"); + File.WriteAllText(globalUsingsPath, "global using Alias = System.IDisposable;"); + + TransformWorkerInputDto input = TransformWorkerIntroducedTypeTestInputs.CreateInput( + firstSourcePath, + secondSourcePath, + new[] { globalUsingsPath }); + TransformWorkerClientResult workerResult = await HotReloadCompositionRoot.Services.TransformWorkerClient.RunAsync( + input, + CancellationToken.None); + + List descriptors = TransformWorkerIntroducedTypeTestInputs.CreateDescriptors(workerResult.Output.files); + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + HotReloadIntroducedTypeCompilationRequest request = + HotReloadIntroducedTypeCompilationRequest.CreateBatch( + new HotReloadIntroducedTypeArtifactPathFactory(projectRoot, "sibling-global-using-alias").Create(), + descriptors, + input.referencePaths, + input.defines); + HotReloadIntroducedTypeCompilerResult compileResult = await new HotReloadIntroducedTypeCompiler( + new HotReloadRoslynCompilerEnvironment(), + new FakeInternalAccessGrant(isAvailable: false)).CompileAsync(request, CancellationToken.None); + + Assert.That(workerResult.Success, Is.True, workerResult.ErrorMessage); + Assert.That(descriptors, Has.Count.EqualTo(2)); + Assert.That(compileResult.Success, Is.True, compileResult.ErrorMessage); + Assert.That(compileResult.Artifact.Assembly.GetType("GlobalAliasFixture.First"), Is.Not.Null); + Assert.That(compileResult.Artifact.Assembly.GetType("GlobalAliasFixture.Second"), Is.Not.Null); + + HotReloadIntroducedTypeCompilationRequest subsetRequest = + HotReloadIntroducedTypeCompilationRequest.CreateBatch( + new HotReloadIntroducedTypeArtifactPathFactory(projectRoot, "sibling-global-using-alias-subset").Create(), + new[] { descriptors[1] }, + input.referencePaths, + input.defines); + HotReloadIntroducedTypeCompilerResult subsetCompileResult = await new HotReloadIntroducedTypeCompiler( + new HotReloadRoslynCompilerEnvironment(), + new FakeInternalAccessGrant(isAvailable: false)).CompileAsync(subsetRequest, CancellationToken.None); + + Assert.That(subsetCompileResult.Success, Is.True, subsetCompileResult.ErrorMessage); + Assert.That(subsetCompileResult.Artifact.Assembly.GetType("GlobalAliasFixture.Second"), Is.Not.Null); + } + + /// + /// Verifies that a Unity object base class the edited source reaches only through another + /// assembly file's global using is still seen, so the introduced type is refused instead of + /// being planned against an unresolved base. + /// + [Test] + public async Task PrepareIntroducedTypes_SiblingGlobalUsingBringsUnityObjectBase_IsRefused() + { + string directory = TransformWorkerIntroducedTypeTestInputs.CreateSourceDirectory("SiblingGlobalUsingUnityObjectBase"); + string sourcePath = Path.Combine(directory, "Introduced.cs"); + string emptyPath = Path.Combine(directory, "Empty.cs"); + File.WriteAllText( + sourcePath, + "namespace GlobalUsingUnityObjectFixture { public class Introduced : HotReloadGlobalUsingBehaviourBase { } }"); + File.WriteAllText(emptyPath, string.Empty); + string globalUsingsPath = Path.GetFullPath( + Path.Combine(Application.dataPath, "Tests", "Editor", "HotReload", "HotReloadGlobalUsings.cs")); + Assert.That(File.Exists(globalUsingsPath), Is.True, "Global usings fixture missing: " + globalUsingsPath); + + TransformWorkerInputDto input = TransformWorkerIntroducedTypeTestInputs.CreateInput( + sourcePath, + emptyPath, + new[] { globalUsingsPath }); + TransformWorkerClientResult result = await HotReloadCompositionRoot.Services.TransformWorkerClient.RunAsync( + input, + CancellationToken.None); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + TransformWorkerFileOutputDto output = result.Output.files[0]; + Assert.That(output.parseErrors, Is.Empty); + Assert.That(output.introducedTypes, Is.Empty); + Assert.That( + HotReloadWorkerReasonTestText.RenderAll(output.introducedTypeDiagnostics), + Has.Some.Contains("Unity object introduced type requires a compile")); + } + + /// + /// Verifies that an introduced type reading a const of a changed sibling, whose type the + /// sibling names only through another assembly file's global using, is refused because the + /// value changed, not because the value could not be read. + /// + [Test] + public async Task PrepareIntroducedTypes_ChangedSiblingConstOfGlobalUsingEnumType_IsRefusedAsChanged() + { + string directory = TransformWorkerIntroducedTypeTestInputs.CreateSourceDirectory("ChangedSiblingConstOfGlobalUsingEnumType"); + string sourcePath = Path.Combine(directory, "Introduced.cs"); + string emptyPath = Path.Combine(directory, "Empty.cs"); + string siblingPath = Path.Combine(directory, "Sibling.cs"); + File.WriteAllText( + sourcePath, + "namespace GlobalUsingConstFixture { public class Introduced { public int Read() { return (int)io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadSiblingConstDefinitions.SiblingMode; } } }"); + File.WriteAllText(emptyPath, string.Empty); + File.WriteAllText( + siblingPath, + "namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { public static class HotReloadSiblingConstDefinitions { public const HotReloadGlobalUsingMode SiblingMode = HotReloadGlobalUsingMode.Second; } }"); + string globalUsingsPath = Path.GetFullPath( + Path.Combine(Application.dataPath, "Tests", "Editor", "HotReload", "HotReloadGlobalUsings.cs")); + Assert.That(File.Exists(globalUsingsPath), Is.True, "Global usings fixture missing: " + globalUsingsPath); + + TransformWorkerInputDto input = TransformWorkerIntroducedTypeTestInputs.CreateInput( + sourcePath, + emptyPath, + new[] { globalUsingsPath }, + new[] { siblingPath }); + TransformWorkerClientResult result = await HotReloadCompositionRoot.Services.TransformWorkerClient.RunAsync( + input, + CancellationToken.None); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + TransformWorkerFileOutputDto output = result.Output.files[0]; + Assert.That(output.parseErrors, Is.Empty); + Assert.That(output.introducedTypes, Is.Empty); + string[] diagnostics = HotReloadWorkerReasonTestText.RenderAll(output.introducedTypeDiagnostics); + Assert.That( + diagnostics, + Has.Some.Contains( + "Changed const requires a compile: io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadSiblingConstDefinitions.SiblingMode"), + string.Join("\n", diagnostics)); + Assert.That(diagnostics, Has.None.Contains("Const value cannot be verified"), string.Join("\n", diagnostics)); + } + /// /// Verifies that root imports remain in compilation-unit scope when a namespace contains /// a relative namespace with the same name as the imported global namespace. diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs new file mode 100644 index 0000000000..da0c1005b3 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs @@ -0,0 +1,1492 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +using UnityEditor.Compilation; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Worker coverage for edits to methods of partial types: the parts of the type in files the run + /// was not given complete the binding, and a method is skipped with a specific reason only when + /// such a part cannot be trusted or its body names something no visible part declares. + /// + public class TransformWorkerPartialTypeTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string FixtureFileName = "HotReloadPartialTypeFixture.cs"; + private const string OtherPartFileName = "HotReloadPartialTypeFixture.Other.cs"; + private const string FixtureProjectRelativePath = "Assets/Tests/Editor/HotReload/" + FixtureFileName; + private const string OtherPartProjectRelativePath = "Assets/Tests/Editor/HotReload/" + OtherPartFileName; + private const string UnrelatedBrokenProjectRelativePath = "Assets/Tests/Editor/HotReload/PartialUnrelatedBrokenRunFile.cs"; + private const string UnreadableProjectRelativePath = "Assets/Tests/Editor/HotReload/PartialUnreadableRunFile.cs"; + private const string OtherPartOwnMethodBody = "return PartialTuning - 1;"; + private const string OtherPartOwnMethodBodyWithSyntaxError = "return PartialTuning - ;"; + + // A part of the fixture whose "partial" is misspelled. Nothing else in it says "partial", + // so only the type name in its text tells that the file was meant to hold a part. + private const string MisspelledPartialPart = + "namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload\n" + + "{\n" + + " public partal class HotReloadPartialTypeFixture\n" + + " {\n" + + " private int MisspelledPartValue()\n" + + " {\n" + + " return 3;\n" + + " }\n" + + " }\n" + + "}\n"; + + // A broken file that names none of the fixture's partial types. + private const string UnrelatedBrokenFile = + "namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload\n" + + "{\n" + + " internal static class HotReloadUnrelatedBrokenHelper\n" + + " {\n" + + " internal static int Value()\n" + + " {\n" + + " return 1 + ;\n" + + " }\n" + + " }\n" + + "}\n"; + + private const string OwnOnlyDeclaration = + " public int OwnOnly()\n {\n return 1;\n }"; + private const string OwnOnlyEdited = + " public int OwnOnly()\n {\n return 2;\n }"; + private const string OwnOnlyWithAttribute = + "[MethodImpl(MethodImplOptions.NoInlining)]\n public int OwnOnly()"; + private const string OtherPartPropertyGetter = "get { return OtherPartProperty; }"; + private const string OtherPartPropertyGetterEdited = "get { return OtherPartProperty + 100; }"; + + // Fixtures of the internal-visibility repro tests. None of them declares the types whose + // non-public members the edited bodies use, so the worker sees those types as compiled. + private const string FixtureDirectoryProjectRelativePath = "Assets/Tests/Editor/HotReload/"; + private const string CallerFileName = "HotReloadInternalMemberCaller.cs"; + private const string PlainDerivedFileName = "HotReloadPlainDerivedFixture.cs"; + private const string PartialDerivedFileName = "HotReloadPartialDerivedFixture.cs"; + private const string CallerInternalCallBody = "return HotReloadInternalMemberHost.InternalStaticValue();"; + private const string CallerInternalCallBodyEdited = "return HotReloadInternalMemberHost.InternalStaticValue() + 100;"; + private const string CallerPlainValueBody = "return 1;"; + private const string PlainDerivedValueBody = "return 10;"; + private const string PartialDerivedValueBody = "return 9;"; + private const string InternalMemberOfATypeOfAnotherAssembly = + "global::io.github.hatayama.UnityCliLoop.FirstPartyTools.PausePointCapturedVariable.FromSnapshot(null).Name.Length"; + + /// + /// What: a body that reads a private field declared in another part of the type is emitted. + /// + [Test] + public async Task Run_PartialTypeBodyEdit_ReadingOtherPartField_EmitsEntry() + { + TransformWorkerClientResult result = await RunEditedFixtureAsync( + "PartialReadsOtherPartField.cs", + "return _otherSeed;", + "return _otherSeed + 100;"); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "ReadsOtherPartField"); + } + + /// + /// What: a body that uses only members of its own part is emitted. + /// + [Test] + public async Task Run_PartialTypeBodyEdit_UsingOwnMembersOnly_EmitsEntry() + { + TransformWorkerClientResult result = await RunEditedFixtureAsync( + "PartialOwnOnly.cs", + OwnOnlyDeclaration, + OwnOnlyEdited); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "OwnOnly"); + } + + /// + /// What: a body that calls a private method declared in another part of the type is emitted. + /// + [Test] + public async Task Run_PartialTypeBodyEdit_CallingOtherPartPrivateMethod_EmitsEntry() + { + TransformWorkerClientResult result = await RunEditedFixtureAsync( + "PartialCallsOtherPartMethod.cs", + "return OtherPartValue();", + "return OtherPartValue() + 100;"); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "CallsOtherPartMethod"); + } + + /// + /// What: a method whose parameter is a type nested in another part of the type keeps matching + /// the compiled method, so its body edit is a normal edit and not an added method. + /// + [Test] + public async Task Run_PartialTypeMethodTakingOtherPartNestedType_IsNotClassifiedAsAdded() + { + TransformWorkerClientResult result = await RunEditedFixtureAsync( + "PartialTakesOtherPartNested.cs", + "return value.Number;", + "return value.Number + 100;"); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + TransformWorkerEntryDto entry = AssertEmitted(result, "TakesOtherPartNested"); + Assert.That(entry.patchKind, Is.Not.EqualTo(HotReloadConstants.PatchKindAddedMethod)); + Assert.That(entry.replacesCompiledMethod, Is.False); + } + + /// + /// What: in a partial type nested in a partial type, a body that reads a field declared in the + /// nested type's other part is emitted. + /// + [Test] + public async Task Run_NestedPartialTypeBodyEdit_ReadingOtherPartField_EmitsEntry() + { + TransformWorkerClientResult result = await RunEditedFixtureAsync( + "PartialNestedReadsOtherPartField.cs", + "return _nestedSeed;", + "return _nestedSeed + 100;"); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "ReadsOtherPartNestedField"); + } + + /// + /// What: an existing property getter that reads a property declared in another part of the + /// type is emitted. + /// + [Test] + public async Task Run_PartialTypeGetterEdit_ReadingOtherPartProperty_EmitsEntry() + { + TransformWorkerClientResult result = await RunEditedFixtureAsync( + "PartialGetterReadsOtherPartProperty.cs", + OtherPartPropertyGetter, + OtherPartPropertyGetterEdited); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "get_ReadsOtherPartProperty"); + } + + /// + /// What: when another part of the type changed since the last compile and is not in the run, + /// the edited method is skipped with a reason that names that file. + /// + [Test] + public async Task Skip_PartialTypeBodyEdit_WhenAnotherPartChangedSinceTheLastCompile_NamesThatFile() + { + TransformWorkerClientResult result = await RunEditedFixtureAsync( + "PartialOtherPartChanged.cs", + OwnOnlyDeclaration, + OwnOnlyEdited, + changedSiblingSourcePaths: new[] { ResolveFixturePath(OtherPartFileName) }); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(FindEntry(result, "OwnOnly"), Is.Null, "OwnOnly must not be applied."); + string reason = FindSkipReason(result, "OwnOnly"); + Assert.That(reason, Does.Contain(OtherPartFileName), FormatSkipped(result)); + Assert.That(reason, Does.Contain("changed since the last compile"), FormatSkipped(result)); + } + + /// + /// What: when the list of changed files may be incomplete, no other part of the type is + /// trusted, so the edited method is skipped with a reason that says the parts were not checked. + /// + [Test] + public async Task Skip_PartialTypeBodyEdit_WhenTheSiblingScanIsIncomplete_SaysTheOtherPartsWereNotChecked() + { + TransformWorkerClientResult result = await RunEditedFixtureAsync( + "PartialSiblingScanIncomplete.cs", + OwnOnlyDeclaration, + OwnOnlyEdited, + changedSiblingScanComplete: false); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(FindEntry(result, "OwnOnly"), Is.Null, "OwnOnly must not be applied."); + string reason = FindSkipReason(result, "OwnOnly"); + Assert.That(reason, Does.Contain("could not be checked against the last compile"), FormatSkipped(result)); + } + + /// + /// What: when a part of the type is in no source file the run can see (the stand-in for a part + /// generated at compile time), only the body that names that part's member is skipped, with + /// the unresolved-name diagnostic, and the other edited method of the file is still emitted. + /// + [Test] + public async Task Skip_PartialTypeBodyEdit_WhenTheOtherPartIsNotAmongTheAssemblySources_SkipsOnlyTheUnboundBody() + { + string onDisk = File.ReadAllText(ResolveFixturePath(FixtureFileName)); + string edited = ReplaceOnce(onDisk, OwnOnlyDeclaration, OwnOnlyEdited); + edited = ReplaceOnce(edited, "return _otherSeed;", "return _otherSeed + 100;"); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + WriteEdited("PartialOtherPartNotInSources.cs", edited), + FixtureProjectRelativePath, + onDisk, + assemblySourcePathsOverride: BuildAssemblySourcePathsWithout(OtherPartFileName)); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "OwnOnly"); + Assert.That(FindEntry(result, "ReadsOtherPartField"), Is.Null, "ReadsOtherPartField must not be applied."); + string reason = FindSkipReason(result, "ReadsOtherPartField"); + Assert.That(reason, Does.Contain("CS0103"), FormatSkipped(result)); + Assert.That(reason, Does.Contain("generated at compile time"), FormatSkipped(result)); + } + + /// + /// What: when a part of the type is in no source file the run can see, an existing getter + /// that names that part's property is skipped with the unresolved-name diagnostic. + /// + [Test] + public async Task Skip_PartialTypeGetterEdit_WhenTheOtherPartIsNotAmongTheAssemblySources_SkipsTheGetter() + { + string onDisk = File.ReadAllText(ResolveFixturePath(FixtureFileName)); + string edited = ReplaceOnce(onDisk, OtherPartPropertyGetter, OtherPartPropertyGetterEdited); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + WriteEdited("PartialGetterOtherPartNotInSources.cs", edited), + FixtureProjectRelativePath, + onDisk, + assemblySourcePathsOverride: BuildAssemblySourcePathsWithout(OtherPartFileName)); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(FindEntry(result, "get_ReadsOtherPartProperty"), Is.Null, "The getter must not be applied."); + Assert.That(FindSkipReason(result, "get_ReadsOtherPartProperty"), Does.Contain("CS0103"), FormatSkipped(result)); + } + + /// + /// What: a body that passes the instance to a compiled API is emitted even though the worker's + /// binding reports a conversion error for it, because the shim compile settles that error. + /// + [Test] + public async Task Run_PartialTypeBodyEdit_PassingItselfToACompiledApi_EmitsEntry() + { + TransformWorkerClientResult result = await RunEditedFixtureAsync( + "PartialPassesThisToCompiledApi.cs", + "return HotReloadPartialTypeFixtureConsumer.Describe(this);", + "return HotReloadPartialTypeFixtureConsumer.Describe(this) + 100;"); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "PassesThisToCompiledApi"); + } + + /// + /// What: a non-partial type nested in a partial type can read a member declared in the outer + /// type's other part. + /// + [Test] + public async Task Run_PlainTypeNestedInAPartialType_ReadingTheOuterOtherPartMember_EmitsEntry() + { + TransformWorkerClientResult result = await RunEditedFixtureAsync( + "PartialNestedPlainReadsOuterOtherPart.cs", + "return _otherStaticSeed;", + "return _otherStaticSeed + 100;"); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "ReadsOuterOtherPartStatic"); + } + + /// + /// What: with two declarations of one partial type in the edited file, each edited method is + /// emitted exactly once. + /// + [Test] + public async Task Run_TwoDeclarationsOfOnePartialTypeInTheEditedFile_EmitEachMethodOnce() + { + string onDisk = File.ReadAllText(ResolveFixturePath(FixtureFileName)); + string edited = ReplaceOnce(onDisk, OwnOnlyDeclaration, OwnOnlyEdited); + edited = ReplaceOnce(edited, "return 21;", "return 22;"); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + WriteEdited("PartialTwoDeclarations.cs", edited), + FixtureProjectRelativePath, + onDisk); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(CountEntries(result, "OwnOnly"), Is.EqualTo(1), FormatSkipped(result)); + Assert.That(CountEntries(result, "SecondBlockMethod"), Is.EqualTo(1), FormatSkipped(result)); + } + + /// + /// What: a method of a partial struct is skipped for being on a struct, not for being partial. + /// + [Test] + public async Task Skip_PartialStructMethodEdit_ReportsTheStructReason() + { + TransformWorkerClientResult result = await RunEditedFixtureAsync( + "PartialStructValue.cs", + " public int StructValue()\n {\n return 1;\n }", + " public int StructValue()\n {\n return 2;\n }"); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + TransformWorkerSkippedDto skipped = FindSkipped(result, "StructValue"); + Assert.That(skipped, Is.Not.Null, "Missing skipped row for StructValue.\n" + FormatSkipped(result)); + Assert.That(skipped.reason.code, Is.EqualTo(HotReloadWorkerReasonCode.MethodTransformStructHost)); + } + + /// + /// What: a method added to a partial type that calls a method of another part is added, not + /// skipped. + /// + [Test] + public async Task Run_MethodAddedToAPartialType_CallingAnOtherPartMethod_IsAddedNotSkipped() + { + TransformWorkerClientResult result = await RunEditedFixtureAsync( + "PartialAddedCallsOtherPart.cs", + OwnOnlyWithAttribute, + "public int AddedCallsOtherPart()\n {\n return OtherPartValue() + 1;\n }\n\n " + + OwnOnlyWithAttribute); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(FindSkipped(result, "AddedCallsOtherPart"), Is.Null, "Unexpected skip.\n" + FormatSkipped(result)); + TransformWorkerEntryDto entry = FindEntry(result, "AddedCallsOtherPart"); + Assert.That(entry, Is.Not.Null, "Missing entry for AddedCallsOtherPart.\n" + FormatSkipped(result)); + Assert.That(entry.patchKind, Is.EqualTo(HotReloadConstants.PatchKindAddedMethod)); + } + + /// + /// What: a method added to a partial type that passes the instance to a compiled API is skipped + /// with the same split reason a non-partial type gets, and the run itself does not fail. + /// + [Test] + public async Task Skip_MethodAddedToAPartialType_PassingItselfToACompiledApi_NamesTheSplitWithoutFailingTheRun() + { + TransformWorkerClientResult result = await RunEditedFixtureAsync( + "PartialAddedPassesThis.cs", + OwnOnlyWithAttribute, + "public int AddedPassesThis()\n {\n return HotReloadPartialTypeFixtureConsumer.Describe(this);\n }\n\n " + + OwnOnlyWithAttribute); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(FindEntry(result, "AddedPassesThis"), Is.Null, "AddedPassesThis must not be applied."); + TransformWorkerSkippedDto skipped = FindSkipped(result, "AddedPassesThis"); + Assert.That(skipped, Is.Not.Null, "Missing skipped row for AddedPassesThis.\n" + FormatSkipped(result)); + Assert.That(skipped.reason.code, Is.EqualTo(HotReloadWorkerReasonCode.AddedMethodBodyBindsCompiledSignature)); + } + + /// + /// What: when another part of the type has a syntax error, the edited method is skipped with a + /// reason that names that file, even though the file is not listed as changed. + /// + [Test] + public async Task Skip_PartialTypeBodyEdit_WhenAnotherPartHasASyntaxError_NamesThatFile() + { + string brokenSibling = WriteEdited( + "PartialSiblingWithSyntaxError.cs", + ReplaceOnce( + File.ReadAllText(ResolveFixturePath(OtherPartFileName)), + OtherPartOwnMethodBody, + OtherPartOwnMethodBodyWithSyntaxError)); + List assemblySourcePaths = new List(BuildAssemblySourcePathsWithout(OtherPartFileName)) + { + brokenSibling + }; + + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync( + new[] { BuildOwnOnlyEditSource("PartialSiblingWithSyntaxErrorEdited.cs") }, + assemblySourcePathsOverride: assemblySourcePaths.ToArray()); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertSkippedAsOtherPartChanged(result, "OwnOnly", "PartialSiblingWithSyntaxError.cs"); + } + + /// + /// What: when the run is also given another part of the type and that part has a syntax error, + /// the edited method is skipped with a reason that names the broken file, and the broken file + /// reports its parse errors on its own row. + /// + [Test] + public async Task Skip_PartialTypeBodyEdit_WhenAPassedOtherPartHasASyntaxError_NamesThatFile() + { + string brokenOtherPart = ReplaceOnce( + File.ReadAllText(ResolveFixturePath(OtherPartFileName)), + OtherPartOwnMethodBody, + OtherPartOwnMethodBodyWithSyntaxError); + + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildOwnOnlyEditSource("PartialPassedPartWithSyntaxErrorEdited.cs"), + BuildOtherPartSource("PartialPassedPartWithSyntaxError.cs", brokenOtherPart) + }); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertSkippedAsOtherPartChanged(result, "OwnOnly", OtherPartProjectRelativePath); + AssertFileHasParseErrors(result, OtherPartProjectRelativePath); + } + + /// + /// What: when another passed part misspells "partial", so its tree no longer declares a part of + /// the type, the edited method is still skipped with a reason that names that file. + /// + [Test] + public async Task Skip_PartialTypeBodyEdit_WhenAPassedOtherPartMisspellsPartial_NamesThatFile() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildOwnOnlyEditSource("PartialPassedPartMisspelledEdited.cs"), + BuildOtherPartSource("PartialPassedPartMisspelled.cs", MisspelledPartialPart) + }); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertSkippedAsOtherPartChanged(result, "OwnOnly", OtherPartProjectRelativePath); + AssertFileHasParseErrors(result, OtherPartProjectRelativePath); + } + + /// + /// What: a broken file in the same run that never names the partial type does not keep the + /// type's edited method from being emitted. + /// + [Test] + public async Task Run_PartialTypeBodyEdit_WithABrokenRunFileThatNeverNamesTheType_EmitsEntry() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildOwnOnlyEditSource("PartialUnrelatedBrokenRunFileEdited.cs"), + new TransformWorkerSourceDto + { + sourcePath = WriteEdited("PartialUnrelatedBrokenRunFile.cs", UnrelatedBrokenFile), + projectRelativePath = UnrelatedBrokenProjectRelativePath, + snapshotSource = string.Empty + } + }); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "OwnOnly"); + AssertFileHasParseErrors(result, UnrelatedBrokenProjectRelativePath); + } + + /// + /// What: when a file listed as changed since the last compile misspells "partial", the type + /// name in its text still marks it as a part of the type, and the edited method is skipped + /// with a reason that names it. + /// + [Test] + public async Task Skip_PartialTypeBodyEdit_WhenAChangedFileMisspellsPartial_NamesThatFile() + { + string misspelledSibling = WriteEdited("PartialChangedSiblingMisspelled.cs", MisspelledPartialPart); + List assemblySourcePaths = + new List(BuildAbsoluteAssemblySourcePaths(FindCompilationAssembly().sourceFiles)) + { + misspelledSibling + }; + + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync( + new[] { BuildOwnOnlyEditSource("PartialChangedSiblingMisspelledEdited.cs") }, + assemblySourcePathsOverride: assemblySourcePaths.ToArray(), + changedSiblingSourcePaths: new[] { misspelledSibling }); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertSkippedAsOtherPartChanged(result, "OwnOnly", "PartialChangedSiblingMisspelled.cs"); + } + + /// + /// What: when another file of the run cannot be read, nothing tells which types it holds parts + /// of, so the edited method of the partial type is skipped with a reason that names that file. + /// + [Test] + public async Task Skip_PartialTypeBodyEdit_WhenAnotherRunFileCannotBeRead_NamesThatFile() + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string missingPath = Path.Combine( + projectRoot, + HotReloadConstants.TestSourcesRelativeDirectory, + "PartialUnreadableRunFile.cs"); + Assert.That(File.Exists(missingPath), Is.False, "The unreadable run file must not exist: " + missingPath); + + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildOwnOnlyEditSource("PartialUnreadableRunFileEdited.cs"), + new TransformWorkerSourceDto + { + sourcePath = missingPath, + projectRelativePath = UnreadableProjectRelativePath, + snapshotSource = string.Empty + } + }); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertSkippedAsOtherPartChanged(result, "OwnOnly", UnreadableProjectRelativePath); + AssertFileHasParseErrors(result, UnreadableProjectRelativePath); + } + + /// + /// What: a body of a partial type that calls an internal method of a plain type the run was + /// not given binds, as the same call from a plain type does (the control run of the same test). + /// + [Test] + public async Task Run_PartialTypeBodyCallingInternalMethodOfUnpassedPlainType_Binds() + { + const string call = "HotReloadInternalMemberHost.InternalStaticValue()"; + TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] + { + BuildCallerPlainValueEdit("ReproAControl.cs", call) + }); + TransformWorkerClientResult repro = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("ReproAPartial.cs", call) + }); + + AssertControlAndReproEmitted(control, "PlainValue", repro, "OwnOnly"); + } + + /// + /// What: a plain type's body that calls an internal method of an unpassed type binds when an + /// edit of a partial type is in the same run, as it does when the plain file is passed alone + /// (the control run of the same test). + /// + [Test] + public async Task Run_PlainFilePassedNextToPartialTypeEdit_CallingInternalMethodOfUnpassedType_Binds() + { + TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] + { + BuildCallerInternalCallEdit("ReproB1Control.cs") + }); + TransformWorkerClientResult repro = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("ReproB1Partial.cs", "2"), + BuildCallerInternalCallEdit("ReproB1Caller.cs") + }); + + AssertControlAndReproEmitted(control, "CallsInternal", repro, "CallsInternal"); + } + + /// + /// What: a plain file that comes back as a sibling to re-bind its active patches while an edit + /// of a partial type is passed: its body that calls an internal method of an unpassed type + /// binds, as it does when the passed edit is in a plain type (the control run of the same test). + /// + [Test] + public async Task Run_PlainSiblingBroughtBackNextToPartialTypeEdit_CallingInternalMethodOfUnpassedType_Binds() + { + TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] + { + BuildEditedFixtureSource(PlainDerivedFileName, "ReproB2ControlPassed.cs", PlainDerivedValueBody, "return 11;"), + AsReappliedSibling(BuildCallerInternalCallEdit("ReproB2ControlSibling.cs")) + }); + TransformWorkerClientResult repro = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("ReproB2Partial.cs", "2"), + AsReappliedSibling(BuildCallerInternalCallEdit("ReproB2Sibling.cs")) + }); + + AssertControlAndReproEmitted(control, "CallsInternal", repro, "CallsInternal"); + } + + /// + /// What: a body of a partial type that calls an internal method another partial type declares + /// in a separate file the run was not given binds, as the same call from a plain type does + /// (the control run of the same test). + /// + [Test] + public async Task Run_PartialTypeBodyCallingInternalMethodOfAnotherUnpassedPartialType_Binds() + { + const string call = "new HotReloadPartialInternalPeer().PeerInternalValue()"; + TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] + { + BuildCallerPlainValueEdit("ReproC1Control.cs", call) + }); + TransformWorkerClientResult repro = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("ReproC1Partial.cs", call) + }); + + AssertControlAndReproEmitted(control, "PlainValue", repro, "OwnOnly"); + } + + /// + /// What: the same call, with the other partial type's file wrapped whole in a + /// conditional-compilation block whose symbol the assembly defines, binds as the same call + /// from a plain type does (the control run of the same test). + /// + [Test] + public async Task Run_PartialTypeBodyCallingInternalMethodOfAnotherUnpassedPartialTypeInAConditionalFile_Binds() + { + const string call = "new HotReloadPartialInternalGuardedPeer().GuardedPeerInternalValue()"; + TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] + { + BuildCallerPlainValueEdit("ReproC2Control.cs", call) + }); + TransformWorkerClientResult repro = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("ReproC2Partial.cs", call) + }); + + AssertControlAndReproEmitted(control, "PlainValue", repro, "OwnOnly"); + } + + /// + /// What: a body of a partial type that uses a non-public member of a type the run was not + /// given binds, whatever the kind of member, as the same use from a plain type does (the + /// control run of the same test). + /// + [TestCase("InternalInstanceMethod", "new HotReloadInternalMemberHost().InternalInstanceValue()")] + [TestCase("InternalField", "new HotReloadInternalMemberHost().InternalField")] + [TestCase("InternalProperty", "new HotReloadInternalMemberHost().InternalProperty")] + [TestCase("InternalType", "HotReloadInternalOnlyType.Value()")] + [TestCase("InternalMethodOfAnInternalResult", "HotReloadInternalMemberHost.InternalSelf().InternalInstanceValue()")] + [TestCase("ProtectedInternalMethod", "new HotReloadInternalMemberHost().ProtectedInternalValue()")] + [TestCase( + "PublicMemberOfInternalTypeOfAnotherAssemblyThroughInternalsVisibleTo", + "global::io.github.hatayama.UnityCliLoop.FirstPartyTools.HotReloadConstants.TestSourcesRelativeDirectory.Length")] + public async Task Run_PartialTypeBodyUsingNonPublicMemberOfUnpassedType_Binds(string memberKind, string expression) + { + TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] + { + BuildCallerPlainValueEdit("ReproKindControl" + memberKind + ".cs", expression) + }); + TransformWorkerClientResult repro = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("ReproKindPartial" + memberKind + ".cs", expression) + }); + + AssertControlAndReproEmitted(control, "PlainValue", repro, "OwnOnly"); + } + + /// + /// What: a body of a partial type that uses a member a compiled base type grants its derived + /// types binds, as the same use from a plain derived type does (the control run of the same + /// test). + /// + [TestCase("Protected", "ProtectedValue()")] + [TestCase("PrivateProtected", "PrivateProtectedValue()")] + [TestCase("InheritedInternalThroughThis", "this.InternalInstanceValue()")] + public async Task Run_DerivedPartialTypeBodyUsingBaseMemberOfUnpassedType_Binds(string memberKind, string expression) + { + TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] + { + BuildEditedFixtureSource( + PlainDerivedFileName, + "ReproBaseControl" + memberKind + ".cs", + PlainDerivedValueBody, + "return " + expression + ";") + }); + TransformWorkerClientResult repro = await RunWorkerOnSourcesAsync(new[] + { + BuildEditedFixtureSource( + PartialDerivedFileName, + "ReproBasePartial" + memberKind + ".cs", + PartialDerivedValueBody, + "return " + expression + ";") + }); + + AssertControlAndReproEmitted(control, "DerivedValue", repro, "DerivedValue"); + } + + /// + /// What: a body of a partial type that uses an internal member of a type the run was not given + /// where the patched method cannot run it in place (inside a lambda, a local function or a + /// query, in an iterator or async method, in a method that runs through a delegating shim, as + /// a method passed as a delegate, by a bare name, or next to a lambda, local function or query + /// that works with the member's result) is skipped with a reason that says the member is + /// internal, not that a part is missing. + /// + [TestCase("Lambda", "OwnOnly", null, "System.Func read = () => HotReloadInternalMemberHost.InternalStaticValue(); return read();")] + [TestCase("LocalFunction", "OwnOnly", null, "int Read() { return HotReloadInternalMemberHost.InternalStaticValue(); } return Read();")] + [TestCase("Query", "OwnOnly", null, "return (from value in new[] { 1 } select value + HotReloadInternalMemberHost.InternalStaticValue()).First();")] + [TestCase("LambdaUsingTheResult", "OwnOnly", null, "var host = HotReloadInternalMemberHost.InternalSelf(); System.Func read = () => host.InternalInstanceValue(); return read();")] + [TestCase("LambdaParameterFromTheResult", "OwnOnly", null, "return System.Array.Exists(HotReloadInternalMemberHost.InternalHosts(), host => host.InternalField > 0) ? 1 : 0;")] + [TestCase("LocalFunctionUsingTheResult", "OwnOnly", null, "var host = HotReloadInternalMemberHost.InternalSelf(); int Read() { return host.InternalInstanceValue(); } return Read();")] + [TestCase("QueryOverTheResult", "OwnOnly", null, "var hosts = HotReloadInternalMemberHost.InternalHosts(); return (from host in hosts select host.InternalField).First();")] + [TestCase("MethodPassedAsDelegate", "OwnOnly", null, "System.Func read = HotReloadInternalMemberHost.InternalStaticValue; return read();")] + [TestCase("Iterator", "IteratorValues", "yield return _seed;", "yield return HotReloadInternalMemberHost.InternalStaticValue();")] + [TestCase("Async", "AsyncValue", "return 50;", "return HotReloadInternalMemberHost.InternalStaticValue();")] + [TestCase("MethodRaisingItsOwnEvent", "RaiseDerivedEvent", "return 60;", "return 60 + HotReloadInternalMemberHost.InternalStaticValue();")] + [TestCase("BareName", "DerivedValue", PartialDerivedValueBody, "return InternalInstanceValue();")] + public async Task Skip_PartialTypeBodyUsingInternalMemberWhereItCannotBePatchedInPlace_SaysWhy( + string form, + string methodName, + string partialDerivedFragment, + string replacement) + { + string editedFileName = "InternalOutOfReach" + form + ".cs"; + TransformWorkerSourceDto edit = partialDerivedFragment == null + ? BuildFixtureOwnOnlyBodyEdit(editedFileName, replacement) + : BuildEditedFixtureSource(PartialDerivedFileName, editedFileName, partialDerivedFragment, replacement); + + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] { edit }); + + string reason = AssertSkipped(result, methodName); + Assert.That(reason, Does.Contain("is internal to 'HotReloadInternalMemberHost'"), FormatSkipped(result)); + Assert.That(reason, Does.Not.Contain("generated at compile time"), FormatSkipped(result)); + } + + /// + /// What: a body of a plain type that uses an internal member of a type the run was not given + /// where the patched method cannot reach it (inside a lambda, a local function, an anonymous + /// method or a query, in an iterator or async method, in a getter or a method that runs + /// through a delegating shim, by a bare name, or next to a lambda, local function or query that + /// works with the member's result) is skipped with a reason that says the member is internal. + /// + [TestCase("Lambda", "DerivedValue", PlainDerivedValueBody, "System.Func read = () => HotReloadInternalMemberHost.InternalStaticValue(); return read();")] + [TestCase("LocalFunction", "DerivedValue", PlainDerivedValueBody, "int Read() { return HotReloadInternalMemberHost.InternalStaticValue(); } return Read();")] + [TestCase("AnonymousMethod", "DerivedValue", PlainDerivedValueBody, "System.Func read = delegate { return HotReloadInternalMemberHost.InternalStaticValue(); }; return read();")] + [TestCase("Query", "DerivedValue", PlainDerivedValueBody, "return (from value in new[] { 1 } select value + HotReloadInternalMemberHost.InternalStaticValue()).First();")] + [TestCase("Iterator", "IteratorValues", "yield return _seed;", "yield return HotReloadInternalMemberHost.InternalStaticValue();")] + [TestCase("Async", "AsyncValue", "return 50;", "return HotReloadInternalMemberHost.InternalStaticValue();")] + [TestCase("BareName", "DerivedValue", PlainDerivedValueBody, "return InternalInstanceValue();")] + [TestCase("LambdaUsingTheResult", "DerivedValue", PlainDerivedValueBody, "var host = HotReloadInternalMemberHost.InternalSelf(); System.Func read = () => host.InternalInstanceValue(); return read();")] + [TestCase("LambdaParameterFromTheResult", "DerivedValue", PlainDerivedValueBody, "return System.Array.Exists(HotReloadInternalMemberHost.InternalHosts(), host => host.InternalField > 0) ? 1 : 0;")] + [TestCase("LocalFunctionUsingTheResult", "DerivedValue", PlainDerivedValueBody, "var host = HotReloadInternalMemberHost.InternalSelf(); int Read() { return host.InternalInstanceValue(); } return Read();")] + [TestCase("QueryOverTheResult", "DerivedValue", PlainDerivedValueBody, "var hosts = HotReloadInternalMemberHost.InternalHosts(); return (from host in hosts select host.InternalField).First();")] + [TestCase("GetterWithALambdaReadingAPrivateMember", "get_DerivedProperty", "return 40;", "System.Func read = () => this._seed; return read() + HotReloadInternalMemberHost.InternalStaticValue();")] + [TestCase("MethodRaisingItsOwnEvent", "RaiseDerivedEvent", "return 60;", "return 60 + HotReloadInternalMemberHost.InternalStaticValue();")] + [TestCase("LambdaNextToAnUnresolvedName", "DerivedValue", PlainDerivedValueBody, "System.Func read = () => HotReloadInternalMemberHost.InternalStaticValue(); return read() + NothingDeclaresThisName();")] + public async Task Skip_PlainTypeBodyUsingInternalMemberWhereThePatchedMethodCannotReachIt_SaysWhy( + string form, + string methodName, + string fragment, + string replacement) + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildEditedFixtureSource(PlainDerivedFileName, "PlainInternalOutOfReach" + form + ".cs", fragment, replacement) + }); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(AssertSkipped(result, methodName), Does.Contain("is internal to 'HotReloadInternalMemberHost'"), FormatSkipped(result)); + } + + /// + /// What: a body of a plain type that would work if patched, but that the worker cannot tell + /// from a use the patched method cannot reach, is skipped with the same internal-member reason: + /// a lambda that only uses an internal member's result as a value, an internal member in the + /// first source expression of a query, which runs as the method's own statement, and an + /// internal member named in 'nameof' inside a lambda, which compiles to a constant. The skip is + /// a deliberate choice; a change that lets one of these through rewrites its case. + /// + [TestCase("LambdaCapturingTheResultAsAValue", "var seed = new HotReloadInternalMemberHost().InternalField; System.Func read = () => seed + 100; return read();")] + [TestCase("QuerySourceExpression", "return (from host in HotReloadInternalMemberHost.InternalHosts() select 1).Count();")] + [TestCase("LambdaUsingNameofOfInternalMember", "System.Func name = () => nameof(HotReloadInternalMemberHost.InternalField); return name().Length;")] + public async Task Skip_PlainTypeBodyTheGuardCannotTellFromAnOutOfReachUse_IsSkippedToo(string form, string replacement) + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildEditedFixtureSource(PlainDerivedFileName, "PlainInternalIndistinguishable" + form + ".cs", PlainDerivedValueBody, replacement) + }); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(AssertSkipped(result, "DerivedValue"), Does.Contain("is internal to 'HotReloadInternalMemberHost'"), FormatSkipped(result)); + } + + /// + /// What: a body of a plain type that uses an internal member of a type the run was not given + /// where the patched method runs the use itself is emitted: a method passed as a delegate, an + /// event subscription, or a member named in an object initializer or a property pattern, which + /// a partial type skips, and a result held in an explicitly typed local that a lambda captures. + /// + [TestCase("MethodPassedAsDelegate", "System.Func read = HotReloadInternalMemberHost.InternalStaticValue; return read();")] + [TestCase("EventSubscription", "HotReloadInternalMemberHost host = new HotReloadInternalMemberHost(); host.InternalEvent += HotReloadInternalMemberHost.NoOp; return host.RaiseInternalEvent();")] + [TestCase("ObjectInitializer", "return new HotReloadInternalMemberHost { InternalField = 150 }.InternalField;")] + [TestCase("PropertyPattern", "return new HotReloadInternalMemberHost() is { InternalProperty: 4 } ? 104 : 0;")] + [TestCase("ExplicitlyTypedLocalCapturedByALambda", "int seed = new HotReloadInternalMemberHost().InternalField; System.Func read = () => seed + 100; return read();")] + public async Task Run_PlainTypeBodyUsingInternalMemberInAFormOnlyAPlainTypeEmits_EmitsEntry(string form, string replacement) + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildEditedFixtureSource(PlainDerivedFileName, "PlainInternalInReach" + form + ".cs", PlainDerivedValueBody, replacement) + }); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "DerivedValue"); + } + + /// + /// What: a body of a plain type that uses a name nothing declares is still emitted, with no + /// skipped row, so the internal-member guard does not skip it with either of its reasons. + /// + [Test] + public async Task Run_PlainTypeBodyUsingANameNothingDeclares_IsNotSkippedByTheInternalMemberGuard() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildEditedFixtureSource(PlainDerivedFileName, "PlainUnresolvedName.cs", PlainDerivedValueBody, "return NothingDeclaresThisName() + 1;") + }); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "DerivedValue"); + } + + /// + /// What: a getter of a partial type whose lambda reads a private member, so the whole getter + /// runs through a delegating shim, is skipped with a reason that says the internal member it + /// uses outside the lambda is internal. + /// + [Test] + public async Task Skip_PartialTypeGetterWithALambdaReadingAPrivateMember_UsingInternalMemberDirectly_SaysWhy() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildEditedFixtureSource( + PartialDerivedFileName, + "InternalOutOfReachDelegatingGetter.cs", + "return 40;", + "System.Func read = () => this._seed; return read() + HotReloadInternalMemberHost.InternalStaticValue();") + }); + + Assert.That(AssertSkipped(result, "get_DerivedProperty"), Does.Contain("is internal to"), FormatSkipped(result)); + } + + /// + /// What: a getter of a partial type whose lambda works with the result of an internal member + /// of a type the run was not given is skipped with the internal-member reason, as a method + /// with the same body is. + /// + [Test] + public async Task Skip_PartialTypeGetterWithALambdaUsingTheResultOfAnInternalMember_SaysWhy() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildEditedFixtureSource( + PartialDerivedFileName, + "InternalOutOfReachGetterLambdaUsingTheResult.cs", + "return 40;", + "var host = HotReloadInternalMemberHost.InternalSelf(); System.Func read = () => host.InternalInstanceValue(); return read();") + }); + + Assert.That(AssertSkipped(result, "get_DerivedProperty"), Does.Contain("is internal to"), FormatSkipped(result)); + } + + /// + /// What: a body of a partial type that uses a private member of a type the run was not given + /// keeps the missing-name reason. + /// + [Test] + public async Task Skip_PartialTypeBodyUsingPrivateMemberOfUnpassedType_KeepsTheMissingNameReason() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("UnpassedPrivateMember.cs", "HotReloadInternalMemberHost.PrivateStaticValue()") + }); + + Assert.That(AssertSkipped(result, "OwnOnly"), Does.Contain("generated at compile time"), FormatSkipped(result)); + } + + /// + /// What: a body of a partial type that uses an internal member of a type in another assembly + /// keeps the missing-name reason. + /// + [Test] + public async Task Skip_PartialTypeBodyUsingInternalMemberOfATypeOfAnotherAssembly_KeepsTheMissingNameReason() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("AnotherAssemblyInternalMember.cs", InternalMemberOfATypeOfAnotherAssembly) + }); + + Assert.That(AssertSkipped(result, "OwnOnly"), Does.Contain("generated at compile time"), FormatSkipped(result)); + } + + /// + /// What: a body of a partial type that uses an internal member of an unpassed type next to a + /// name nothing declares is skipped with a reason that names the undeclared name. + /// + [Test] + public async Task Skip_PartialTypeBodyUsingInternalMemberNextToAnUnresolvedName_NamesTheUnresolvedName() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit( + "InternalMemberNextToUnresolvedName.cs", + "HotReloadInternalMemberHost.InternalStaticValue() + NoSuchName") + }); + + string reason = AssertSkipped(result, "OwnOnly"); + Assert.That(reason, Does.Contain("NoSuchName"), FormatSkipped(result)); + Assert.That(reason, Does.Contain("generated at compile time"), FormatSkipped(result)); + } + + /// + /// What: when a part of the type is in no source file the run can see, a body that calls that + /// part's internal method through 'this' keeps the missing-name reason, although the compiled + /// type declares the method. + /// + [Test] + public async Task Skip_PartialTypeBodyUsingThisInternalMemberOfAPartNotAmongTheAssemblySources_KeepsTheMissingNameReason() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync( + new[] { BuildFixtureOwnOnlyEdit("PartialThisInternalOfPartNotInSources.cs", "this.OtherPartInternalValue()") }, + assemblySourcePathsOverride: BuildAssemblySourcePathsWithout(OtherPartFileName)); + + Assert.That(AssertSkipped(result, "OwnOnly"), Does.Contain("generated at compile time"), FormatSkipped(result)); + } + + /// + /// What: a body of a partial type that calls an internal method of a type nested in a type the + /// run was not given is emitted. + /// + [Test] + public async Task Run_PartialTypeBodyUsingInternalMemberOfANestedUnpassedType_EmitsEntry() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("NestedTypeInternalMember.cs", "HotReloadInternalMemberHost.Nested.NestedInternalValue()") + }); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "OwnOnly"); + } + + /// + /// What: an existing getter of a partial type that calls an internal method of a type the run + /// was not given is emitted. + /// + [Test] + public async Task Run_PartialTypeGetterUsingInternalMemberOfUnpassedType_EmitsEntry() + { + TransformWorkerClientResult result = await RunEditedFixtureAsync( + "PartialGetterUsesUnpassedInternal.cs", + OtherPartPropertyGetter, + "get { return HotReloadInternalMemberHost.InternalStaticValue(); }"); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "get_ReadsOtherPartProperty"); + } + + /// + /// What: a getter of a file brought back to re-bind its active patches that calls an internal + /// method of a type the run was not given is emitted. + /// + [Test] + public async Task Run_SiblingBroughtBack_GetterUsingInternalMemberOfUnpassedType_EmitsEntry() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("SiblingGetterPassed.cs", "2"), + AsReappliedSibling(BuildEditedFixtureSource( + CallerFileName, + "SiblingGetterCaller.cs", + "get { return 30; }", + "get { return HotReloadInternalMemberHost.InternalStaticValue(); }")) + }); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "get_CallerProperty"); + } + + /// + /// What: a getter of a plain file brought back to re-bind its active patches that uses an + /// internal member of an unpassed type inside a lambda is skipped with the internal-member + /// reason, not the sibling's reason: passing the file would not help, because the lambda still + /// runs in the shim assembly. + /// + [Test] + public async Task Skip_PlainSiblingBroughtBack_GetterUsingInternalMemberInsideALambda_SaysTheMemberIsInternal() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("SiblingGetterLambdaPassed.cs", "2"), + AsReappliedSibling(BuildEditedFixtureSource( + CallerFileName, + "SiblingGetterLambdaCaller.cs", + "get { return 30; }", + "get { System.Func read = () => HotReloadInternalMemberHost.InternalStaticValue(); return read(); }")) + }); + + string reason = AssertSkipped(result, "get_CallerProperty"); + Assert.That(reason, Does.Contain("is internal to 'HotReloadInternalMemberHost'"), FormatSkipped(result)); + Assert.That(reason, Does.Not.Contain("brought back to re-bind"), FormatSkipped(result)); + } + + /// + /// What: a method of a file brought back to re-bind its active patches whose body uses an + /// internal member of an unpassed type inside a lambda keeps the sibling's skip reason. + /// + [Test] + public async Task Skip_SiblingBroughtBack_UsingInternalMemberInsideALambda_KeepsTheSiblingReason() + { + TransformWorkerClientResult result = await RunWithSiblingPlainValueAsync( + "SiblingInternalInLambda", + "System.Func read = () => HotReloadInternalMemberHost.InternalStaticValue(); return read();"); + + Assert.That(AssertSkipped(result, "PlainValue"), Does.Contain("brought back to re-bind"), FormatSkipped(result)); + } + + /// + /// What: a method of a file brought back to re-bind its active patches whose lambda works with + /// the result of an internal member of an unpassed type keeps the sibling's skip reason. + /// + [Test] + public async Task Skip_SiblingBroughtBack_WithALambdaUsingTheResultOfAnInternalMember_KeepsTheSiblingReason() + { + TransformWorkerClientResult result = await RunWithSiblingPlainValueAsync( + "SiblingLambdaUsingTheResult", + "var host = HotReloadInternalMemberHost.InternalSelf(); System.Func read = () => host.InternalInstanceValue(); return read();"); + + Assert.That(AssertSkipped(result, "PlainValue"), Does.Contain("brought back to re-bind"), FormatSkipped(result)); + } + + /// + /// What: a method of a file brought back to re-bind its active patches whose body uses an + /// internal member of an unpassed type next to a name nothing declares keeps the sibling's skip + /// reason. + /// + [Test] + public async Task Skip_SiblingBroughtBack_UsingInternalMemberNextToAnUnresolvedName_KeepsTheSiblingReason() + { + TransformWorkerClientResult result = await RunWithSiblingPlainValueAsync( + "SiblingInternalNextToUnresolvedName", + "return HotReloadInternalMemberHost.InternalStaticValue() + NoSuchName;"); + + Assert.That(AssertSkipped(result, "PlainValue"), Does.Contain("brought back to re-bind"), FormatSkipped(result)); + } + + // A fixture file next to these tests, edited once and described as a passed run source whose + // snapshot is the file on disk. + private static TransformWorkerSourceDto BuildEditedFixtureSource( + string fileName, + string editedFileName, + string fragment, + string replacement) + { + string onDisk = File.ReadAllText(ResolveFixturePath(fileName)); + return new TransformWorkerSourceDto + { + sourcePath = WriteEdited(editedFileName, ReplaceOnce(onDisk, fragment, replacement)), + projectRelativePath = FixtureDirectoryProjectRelativePath + fileName, + snapshotSource = onDisk + }; + } + + // The edited main part of the partial fixture, with OwnOnly returning the given expression. + private static TransformWorkerSourceDto BuildFixtureOwnOnlyEdit(string editedFileName, string returnedExpression) + { + string editedOwnOnly = + " public int OwnOnly()\n {\n return " + returnedExpression + ";\n }"; + return BuildEditedFixtureSource(FixtureFileName, editedFileName, OwnOnlyDeclaration, editedOwnOnly); + } + + // The edited main part of the partial fixture, with OwnOnly's body replaced by the given line, + // which may hold several statements. + private static TransformWorkerSourceDto BuildFixtureOwnOnlyBodyEdit(string editedFileName, string bodyLine) + { + string editedOwnOnly = + " public int OwnOnly()\n {\n " + bodyLine + "\n }"; + return BuildEditedFixtureSource(FixtureFileName, editedFileName, OwnOnlyDeclaration, editedOwnOnly); + } + + // An edit of the partial fixture passed next to the plain caller, which comes back as a sibling + // whose PlainValue body is the given line. + private static Task RunWithSiblingPlainValueAsync(string label, string plainValueBody) + { + return RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit(label + "Passed.cs", "2"), + AsReappliedSibling(BuildEditedFixtureSource(CallerFileName, label + "Sibling.cs", CallerPlainValueBody, plainValueBody)) + }); + } + + // The plain caller with PlainValue returning the given expression. + private static TransformWorkerSourceDto BuildCallerPlainValueEdit(string editedFileName, string returnedExpression) + { + return BuildEditedFixtureSource( + CallerFileName, + editedFileName, + CallerPlainValueBody, + "return " + returnedExpression + ";"); + } + + // The plain caller with an edit inside the body that calls the internal method. + private static TransformWorkerSourceDto BuildCallerInternalCallEdit(string editedFileName) + { + return BuildEditedFixtureSource( + CallerFileName, + editedFileName, + CallerInternalCallBody, + CallerInternalCallBodyEdited); + } + + private static TransformWorkerSourceDto AsReappliedSibling(TransformWorkerSourceDto source) + { + source.reappliedSibling = true; + return source; + } + + // Why one assertion over both runs: the control and the repro fail for different reasons, and + // the reader needs both runs' rows even when the control already failed. + private static void AssertControlAndReproEmitted( + TransformWorkerClientResult control, + string controlMethodName, + TransformWorkerClientResult repro, + string reproMethodName) + { + List failures = new List(); + CollectMissingEntry("control", control, controlMethodName, failures); + CollectMissingEntry("repro", repro, reproMethodName, failures); + Assert.That(failures, Is.Empty, string.Join("\n\n", failures)); + } + + private static void CollectMissingEntry( + string runLabel, + TransformWorkerClientResult result, + string methodName, + List failures) + { + if (!result.Success) + { + failures.Add(runLabel + " run failed: " + result.ErrorMessage); + return; + } + + if (FindEntry(result, methodName) == null) + { + failures.Add( + runLabel + ": missing entry for " + methodName + ".\n" + + FormatSkipped(result) + "\n" + FormatFileErrors(result)); + } + } + + private static string FormatFileErrors(TransformWorkerClientResult result) + { + List rows = new List(); + foreach (TransformWorkerFileOutputDto file in result.Output.files) + { + foreach (string parseError in file.parseErrors ?? Array.Empty()) + { + rows.Add(file.projectRelativePath + " :: " + parseError); + } + } + + return rows.Count == 0 ? "FileErrors=(none)" : "FileErrors=\n" + string.Join("\n", rows); + } + + // The edited main part. OwnOnly reads only its own part, so it binds whether or not the other + // part is visible, and only an untrusted other part can keep it from being emitted. + private static TransformWorkerSourceDto BuildOwnOnlyEditSource(string editedFileName) + { + string onDisk = File.ReadAllText(ResolveFixturePath(FixtureFileName)); + return new TransformWorkerSourceDto + { + sourcePath = WriteEdited(editedFileName, ReplaceOnce(onDisk, OwnOnlyDeclaration, OwnOnlyEdited)), + projectRelativePath = FixtureProjectRelativePath, + snapshotSource = onDisk + }; + } + + // A run file that stands for the fixture's other part, with the given text. + private static TransformWorkerSourceDto BuildOtherPartSource(string editedFileName, string contents) + { + return new TransformWorkerSourceDto + { + sourcePath = WriteEdited(editedFileName, contents), + projectRelativePath = OtherPartProjectRelativePath, + snapshotSource = File.ReadAllText(ResolveFixturePath(OtherPartFileName)) + }; + } + + private static void AssertSkippedAsOtherPartChanged( + TransformWorkerClientResult result, + string methodName, + string namedPath) + { + Assert.That(FindEntry(result, methodName), Is.Null, methodName + " must not be applied.\n" + FormatSkipped(result)); + TransformWorkerSkippedDto skipped = FindSkipped(result, methodName); + Assert.That(skipped, Is.Not.Null, "Missing skipped row for " + methodName + ".\n" + FormatSkipped(result)); + Assert.That( + skipped.reason.code, + Is.EqualTo(HotReloadWorkerReasonCode.MethodTransformPartialOtherPartChanged), + FormatSkipped(result)); + Assert.That(HotReloadWorkerReasonText.Render(skipped.reason), Does.Contain(namedPath), FormatSkipped(result)); + } + + private static void AssertFileHasParseErrors(TransformWorkerClientResult result, string projectRelativePath) + { + foreach (TransformWorkerFileOutputDto file in result.Output.files) + { + if (file.projectRelativePath == projectRelativePath) + { + Assert.That(file.parseErrors, Is.Not.Empty, "Expected parse errors on " + projectRelativePath); + return; + } + } + + Assert.Fail("Missing file row for " + projectRelativePath); + } + + private static async Task RunEditedFixtureAsync( + string editedFileName, + string fragment, + string replacement, + string[] changedSiblingSourcePaths = null, + bool changedSiblingScanComplete = true) + { + string onDisk = File.ReadAllText(ResolveFixturePath(FixtureFileName)); + string edited = ReplaceOnce(onDisk, fragment, replacement); + return await RunWorkerOnSourceAsync( + WriteEdited(editedFileName, edited), + FixtureProjectRelativePath, + onDisk, + changedSiblingSourcePaths: changedSiblingSourcePaths, + changedSiblingScanComplete: changedSiblingScanComplete); + } + + // Why the uniqueness check: a fragment that also matched another member would edit a method + // the test does not look at, and the assert would pass or fail for the wrong reason. + private static string ReplaceOnce(string source, string fragment, string replacement) + { + int first = source.IndexOf(fragment, StringComparison.Ordinal); + Assert.That(first, Is.GreaterThanOrEqualTo(0), "Fragment missing from the fixture: " + fragment); + Assert.That( + source.LastIndexOf(fragment, StringComparison.Ordinal), + Is.EqualTo(first), + "Fragment occurs more than once in the fixture: " + fragment); + return source.Substring(0, first) + replacement + source.Substring(first + fragment.Length); + } + + // The rendered reason of the method's skipped row, once the run succeeded without an entry for + // the method. + private static string AssertSkipped(TransformWorkerClientResult result, string methodName) + { + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(FindEntry(result, methodName), Is.Null, methodName + " must not be applied.\n" + FormatSkipped(result)); + TransformWorkerSkippedDto skipped = FindSkipped(result, methodName); + Assert.That(skipped, Is.Not.Null, "Missing skipped row for " + methodName + ".\n" + FormatSkipped(result)); + return HotReloadWorkerReasonText.Render(skipped.reason); + } + + private static TransformWorkerEntryDto AssertEmitted(TransformWorkerClientResult result, string methodName) + { + TransformWorkerEntryDto entry = FindEntry(result, methodName); + Assert.That(entry, Is.Not.Null, "Missing entry for " + methodName + ".\n" + FormatSkipped(result)); + Assert.That(FindSkipReason(result, methodName), Is.Null, FormatSkipped(result)); + return entry; + } + + private static TransformWorkerEntryDto FindEntry(TransformWorkerClientResult result, string methodName) + { + foreach (TransformWorkerEntryDto entry in result.Output.entries) + { + if (entry.methodName == methodName) + { + return entry; + } + } + + return null; + } + + private static int CountEntries(TransformWorkerClientResult result, string methodName) + { + int count = 0; + foreach (TransformWorkerEntryDto entry in result.Output.entries) + { + if (entry.methodName == methodName) + { + count++; + } + } + + return count; + } + + private static TransformWorkerSkippedDto FindSkipped(TransformWorkerClientResult result, string methodName) + { + foreach (TransformWorkerSkippedDto skipped in result.Output.skipped) + { + if (skipped.method != null && skipped.method.Contains("." + methodName + "(")) + { + return skipped; + } + } + + return null; + } + + private static string FindSkipReason(TransformWorkerClientResult result, string methodNameFragment) + { + foreach (TransformWorkerSkippedDto skipped in result.Output.skipped) + { + if (skipped.method != null && skipped.method.Contains(methodNameFragment)) + { + return HotReloadWorkerReasonText.Render(skipped.reason); + } + } + + return null; + } + + private static string FormatSkipped(TransformWorkerClientResult result) + { + if (result.Output == null || result.Output.skipped == null || result.Output.skipped.Length == 0) + { + return "Skipped=(none)"; + } + + List rows = new List(); + foreach (TransformWorkerSkippedDto skipped in result.Output.skipped) + { + rows.Add( + skipped.method + " :: " + + (skipped.reason == null ? "(no reason)" : HotReloadWorkerReasonText.Render(skipped.reason))); + } + + return "Skipped=\n" + string.Join("\n", rows); + } + + private static string WriteEdited(string fileName, string contents) + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string directory = Path.Combine(projectRoot, HotReloadConstants.TestSourcesRelativeDirectory); + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, fileName); + File.WriteAllText(path, contents); + return path; + } + + private static string ResolveFixturePath(string fileName) + { + string path = Path.Combine(Application.dataPath, "Tests", "Editor", "HotReload", fileName); + Assert.That(File.Exists(path), Is.True, "Partial type fixture missing: " + path); + return Path.GetFullPath(path); + } + + private static string[] BuildAssemblySourcePathsWithout(string fileName) + { + List paths = new List(); + foreach (string path in BuildAbsoluteAssemblySourcePaths(FindCompilationAssembly().sourceFiles)) + { + if (!string.Equals(Path.GetFileName(path), fileName, StringComparison.Ordinal)) + { + paths.Add(path); + } + } + + return paths.ToArray(); + } + + private static async Task RunWorkerOnSourceAsync( + string sourcePath, + string projectRelativePath, + string snapshotSource, + string[] assemblySourcePathsOverride = null, + string[] changedSiblingSourcePaths = null, + bool changedSiblingScanComplete = true) + { + TransformWorkerSourceDto source = new TransformWorkerSourceDto + { + sourcePath = sourcePath, + projectRelativePath = projectRelativePath, + snapshotSource = snapshotSource + }; + return await RunWorkerOnSourcesAsync( + new[] { source }, + assemblySourcePathsOverride, + changedSiblingSourcePaths, + changedSiblingScanComplete); + } + + private static async Task RunWorkerOnSourcesAsync( + TransformWorkerSourceDto[] sources, + string[] assemblySourcePathsOverride = null, + string[] changedSiblingSourcePaths = null, + bool changedSiblingScanComplete = true) + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string targetDllPath = Path.Combine( + projectRoot, + "Library", + "ScriptAssemblies", + TestAssemblyName + ".dll"); + Assert.That(File.Exists(targetDllPath), Is.True, "Test assembly dll missing: " + targetDllPath); + + UnityEditor.Compilation.Assembly compilationAssembly = FindCompilationAssembly(); + string[] referencePaths = BuildAbsoluteReferencePaths( + compilationAssembly.allReferences, + targetDllPath); + string[] assemblySourcePaths = assemblySourcePathsOverride + ?? BuildAbsoluteAssemblySourcePaths(compilationAssembly.sourceFiles); + + TransformWorkerInputDto input = new TransformWorkerInputDto + { + sources = sources, + defines = compilationAssembly.defines ?? Array.Empty(), + referencePaths = referencePaths, + targetTypesAssemblyPath = targetDllPath, + assemblySourcePaths = assemblySourcePaths, + changedSiblingSourcePaths = changedSiblingSourcePaths ?? Array.Empty(), + changedSiblingScanComplete = changedSiblingScanComplete, + excludedMethodKeys = Array.Empty(), + excludedAddedMethodKeys = Array.Empty() + }; + + return await HotReloadCompositionRoot.Services.TransformWorkerClient.RunAsync(input, CancellationToken.None); + } + + private static UnityEditor.Compilation.Assembly FindCompilationAssembly() + { + foreach (UnityEditor.Compilation.Assembly assembly in CompilationPipeline.GetAssemblies()) + { + if (assembly.name == TestAssemblyName) + { + return assembly; + } + } + + Assert.Fail("CompilationPipeline assembly not found: " + TestAssemblyName); + return null; + } + + private static string[] BuildAbsoluteReferencePaths(string[] allReferences, string targetDllPath) + { + List paths = new List(); + if (allReferences != null) + { + foreach (string reference in allReferences) + { + if (string.IsNullOrEmpty(reference) || !File.Exists(reference)) + { + continue; + } + + paths.Add(Path.GetFullPath(reference)); + } + } + + string fullTarget = Path.GetFullPath(targetDllPath); + bool hasTarget = false; + foreach (string path in paths) + { + if (string.Equals(path, fullTarget, StringComparison.OrdinalIgnoreCase)) + { + hasTarget = true; + break; + } + } + + if (!hasTarget) + { + paths.Add(fullTarget); + } + + return paths.ToArray(); + } + + private static string[] BuildAbsoluteAssemblySourcePaths(string[] sourceFiles) + { + if (sourceFiles == null || sourceFiles.Length == 0) + { + return Array.Empty(); + } + + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string[] paths = new string[sourceFiles.Length]; + for (int index = 0; index < sourceFiles.Length; index++) + { + string normalizedRelativePath = sourceFiles[index].Replace('\\', '/'); + string absoluteSourcePath = Path.Combine( + projectRoot, + normalizedRelativePath.Replace('/', Path.DirectorySeparatorChar)); + paths[index] = Path.GetFullPath(absoluteSourcePath); + } + + return paths; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs.meta b/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs.meta new file mode 100644 index 0000000000..6817158827 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c4460139c114b43b58998da601626325 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md index 7263aadc28..537db387f7 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md @@ -57,7 +57,7 @@ Returns JSON: - `WarningCount`: number or null - `Warning` (string, optional): set when the compile was requested during Play Mode (the Play session is discarded), while pause points were armed in either Play Mode or Edit Mode (the domain reload drops every pause point patch; those enabled with `--persist` are counted separately and re-armed from their saved enable request after the reload), or while hot-reload changes were active (a successful compile drops every patch; the edited sources are compiled in). - `Message`: string -- `ErrorCode`: string or null. `COMPILE_ALREADY_IN_PROGRESS` when Unity is already compiling, `COMPILE_EDITOR_UPDATING` when the editor is updating, `COMPILE_RESULT_UNKNOWN` after a forced recompile that did not return a definitive result. +- `ErrorCode`: string or null. `COMPILE_ALREADY_IN_PROGRESS` / `COMPILE_EDITOR_UPDATING` when Unity was still compiling or updating after the CLI waited for it and sent the compile again (it does so twice before giving up; run `uloop compile` again), `COMPILE_RESULT_UNKNOWN` after a forced recompile that did not return a definitive result. - `NextActions`: string array or null. Corrective steps derived from the errors, e.g. the assembly that declares an unresolved namespace (CS0234), or, for CS0246 in a script under an asmdef, a reminder to check that asmdef's references (the type may instead be a typo — decide from the error). When Unity stops compiling before the finish callback (`Success: null`, indeterminate), `Message` keeps the get-logs pointer and appends `Recent Console errors:` with the last few Console errors (typically the asmdef or compiler error that aborted the compile), so fix from that list before reaching for `uloop get-logs`. 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 df26774fe1..b715e2aefe 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 @@ -47,6 +47,20 @@ uloop --project-path /Library/VP/mppm simulate-keyboard --acti uloop's one-command-at-a-time rule is per Editor, so commands to different players (or to the 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. + ## 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/HotReload/HotReloadApplyOutcome.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyOutcome.cs new file mode 100644 index 0000000000..174bbeb3cf --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyOutcome.cs @@ -0,0 +1,141 @@ +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One-word answer to "are the requested edits live now?" for an apply run. ReplacedByCompile + /// is written by the CLI after a fallback compile succeeded and is listed here so the enum + /// names every value a caller can see. + /// + internal enum HotReloadApplyOutcomeKind + { + // Something of the requested files is live, and none of their methods was Skipped. + Applied = 0, + + // Something of the requested files is live, and some of their methods were Skipped. + PartiallyApplied = 1, + + // Nothing of the requested files is live, and some of their methods were Skipped. + NothingApplied = 2, + + // Nothing of the requested files is live, and none was Skipped: every method is + // unchanged, or the files hold no method bodies. + NothingToApply = 3, + + // A Failed row, siblings included: the same condition that turns Success false. + Failed = 4, + + // Written by the CLI only. + ReplacedByCompile = 5 + } + + /// + /// Decides the of an apply run. + /// + /// + /// Why the sibling rows are left out: a sibling file is re-applied on the run's own initiative, + /// so its rows say nothing about the edit the caller asked about (the same split the message + /// uses through HotReloadRequestedFileOutcomeSummary). A Failed row counts wherever it is, + /// because Success already turns false for it. + /// + internal static class HotReloadApplyOutcome + { + internal static HotReloadApplyOutcomeKind Decide( + IReadOnlyList methods, + IReadOnlyList introducedTypes, + HotReloadReappliedSiblingFiles siblingFiles, + bool hasFailure) + { + Debug.Assert(methods != null, "methods must not be null."); + Debug.Assert(introducedTypes != null, "introducedTypes must not be null."); + Debug.Assert(siblingFiles != null, "siblingFiles must not be null."); + if (hasFailure) + { + return HotReloadApplyOutcomeKind.Failed; + } + + int liveCount = CountRequestedLiveMethods(methods, siblingFiles) + + CountRequestedLiveTypes(introducedTypes, siblingFiles); + bool anySkipped = HasRequestedSkippedMethod(methods, siblingFiles); + if (liveCount == 0) + { + return anySkipped + ? HotReloadApplyOutcomeKind.NothingApplied + : HotReloadApplyOutcomeKind.NothingToApply; + } + + return anySkipped + ? HotReloadApplyOutcomeKind.PartiallyApplied + : HotReloadApplyOutcomeKind.Applied; + } + + // Why AlreadyActive counts: the earlier run's patch is still running, so the answer to + // "is it live now?" is yes. Why Stale does not: it is what is left of a method the source + // no longer declares, not the outcome of an edit, and StaleTotal reports it apart. + private static int CountRequestedLiveMethods( + IReadOnlyList methods, + HotReloadReappliedSiblingFiles siblingFiles) + { + int count = 0; + for (int index = 0; index < methods.Count; index++) + { + HotReloadMethodOutcome method = methods[index]; + if (siblingFiles.Contains(method.FilePath)) + { + continue; + } + + if (method.Kind == HotReloadMethodOutcomeKind.Patched + || method.Kind == HotReloadMethodOutcomeKind.Added + || method.Kind == HotReloadMethodOutcomeKind.AlreadyActive) + { + count++; + } + } + + return count; + } + + private static int CountRequestedLiveTypes( + IReadOnlyList introducedTypes, + HotReloadReappliedSiblingFiles siblingFiles) + { + int count = 0; + for (int index = 0; index < introducedTypes.Count; index++) + { + HotReloadIntroducedTypeOutcome type = introducedTypes[index]; + if (siblingFiles.Contains(type.OwnerProjectRelativePath)) + { + continue; + } + + if (type.Kind == HotReloadIntroducedTypeOutcomeKind.Introduced + || type.Kind == HotReloadIntroducedTypeOutcomeKind.AlreadyActive) + { + count++; + } + } + + return count; + } + + private static bool HasRequestedSkippedMethod( + IReadOnlyList methods, + HotReloadReappliedSiblingFiles siblingFiles) + { + for (int index = 0; index < methods.Count; index++) + { + HotReloadMethodOutcome method = methods[index]; + if (method.Kind == HotReloadMethodOutcomeKind.Skipped + && !siblingFiles.Contains(method.FilePath)) + { + return true; + } + } + + return false; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyOutcome.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyOutcome.cs.meta new file mode 100644 index 0000000000..89743c2738 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyOutcome.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1b461233fe5a1455bbd6df2177c87ac5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs index 1fa8c126db..a5e81132ba 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs @@ -75,6 +75,10 @@ public static HotReloadResponse Build( result.Methods, result.ReappliedSiblingPaths, toProjectRelativeScriptPath); + int skippedSiblingCount = HotReloadRequestedFileOutcomeSummary.CountSkippedSiblingOutcomes( + result.Methods, + result.ReappliedSiblingPaths, + toProjectRelativeScriptPath); string message = BuildApplyMessage( result, hasFailure, @@ -85,10 +89,22 @@ public static HotReloadResponse Build( warnings, HotReloadReappliedSiblingFiles.ForActivePatches(result, toProjectRelativeScriptPath)), allRequestedSkipped, - reappliedSiblingCount); + reappliedSiblingCount, + skippedSiblingCount); + HotReloadOutcomeTally tally = HotReloadOutcomeAggregation.CountMethodOutcomeKinds(result.Methods); return new HotReloadResponse { Success = !hasFailure, + Outcome = HotReloadApplyOutcome.Decide( + result.Methods, + result.IntroducedTypes, + reappliedSiblingFiles, + hasFailure).ToString(), + SkippedTotal = tally.SkippedCount, + AddedTotal = tally.AddedCount, + FailedTotal = tally.FailedCount, + AlreadyActiveTotal = tally.AlreadyActiveCount, + StaleTotal = tally.StaleCount, Methods = methods, Warnings = warnings.ToList(), IntroducedTypes = HotReloadIntroducedTypeResponseSection.BuildRows(result.IntroducedTypes), @@ -104,12 +120,33 @@ public static HotReloadResponse Build( Message = HotReloadAutoRefreshHoldResponseEnricher.AppendNewlyArmedMessage( message, result.AutoRefreshHoldNewlyArmed), + AutoRefreshHoldMessage = result.AutoRefreshHoldNewlyArmed + ? HotReloadAutoRefreshHoldConstants.NewlyArmedMessageSuffix + : string.Empty, RecommendedNextAction = HotReloadRecommendedNextAction.Resolve( hasFailure, result.PatchedTotal, CountAddedOutcomes(result), HotReloadIntroducedTypeResponseSection.CountIntroducedTypes(result.IntroducedTypes), - allRequestedSkipped) + allRequestedSkipped), + Timing = ToTimingResponse(result.Timing) + }; + } + + private static HotReloadTimingResponse ToTimingResponse(HotReloadTimingBreakdown timing) + { + if (timing == null) + { + return null; + } + + return new HotReloadTimingResponse + { + AnalysisMs = timing.AnalysisMs, + ShimCompileMs = timing.ShimCompileMs, + PatchMs = timing.PatchMs, + OtherMs = timing.OtherMs, + TotalMs = timing.TotalMs }; } @@ -138,8 +175,11 @@ private static string BuildApplyMessage( int warningCount, bool appendCompileResolution, bool allRequestedSkipped, - int reappliedSiblingCount) + int reappliedSiblingCount, + int skippedSiblingCount) { + string skippedCountSuffix = BuildSkippedCountSuffix(result, skippedSiblingCount); + // Why asked first: the file a run introduces a type into usually holds untouched // methods as well, and every message below would then report the methods only. if (HotReloadIntroducedTypeResponseSection.TryBuildMessage( @@ -147,7 +187,7 @@ private static string BuildApplyMessage( hasMethodFailure, result.PatchedTotal, CountAddedOutcomes(result), - CountOutcomesOfKind(result, HotReloadMethodOutcomeKind.Skipped), + skippedCountSuffix, out string typeMessage)) { return AppendWarningCount(typeMessage, warningCount, appendCompileResolution); @@ -165,7 +205,12 @@ private static string BuildApplyMessage( appendCompileResolution); } - string message = BuildApplyOutcomeMessage(result, hasFailure, allRequestedSkipped, reappliedSiblingCount); + string message = BuildApplyOutcomeMessage( + result, + hasFailure, + allRequestedSkipped, + reappliedSiblingCount, + skippedCountSuffix); message = AppendUnchangedAndLifecycleNotes(message, result); message = HotReloadIntroducedTypeResponseSection.AppendTypeSummary( message, @@ -174,6 +219,34 @@ private static string BuildApplyMessage( return AppendWarningCount(message, warningCount, appendCompileResolution); } + // The Skipped clause the applied messages end their counts with, or an empty string when + // no row was Skipped. Built once so the type-only message and the ordinary one agree. + // Why the sibling rows are named: the count covers every row while Outcome only judges + // the requested files, so an Applied run beside a bare count reads as if one of the + // requested edits had been skipped. + private static string BuildSkippedCountSuffix(HotReloadOrchestratorResult result, int skippedSiblingCount) + { + int skippedCount = CountOutcomesOfKind(result, HotReloadMethodOutcomeKind.Skipped); + Debug.Assert( + skippedSiblingCount <= skippedCount, + "skippedSiblingCount must not exceed the Skipped rows it was counted from."); + if (skippedCount == 0) + { + return string.Empty; + } + + return skippedSiblingCount > 0 + ? string.Format( + CultureInfo.InvariantCulture, + HotReloadConstants.SkippedCountWithSiblingRowsApplyMessageSuffixFormat, + skippedCount, + skippedSiblingCount) + : string.Format( + CultureInfo.InvariantCulture, + HotReloadConstants.SkippedCountApplyMessageSuffixFormat, + skippedCount); + } + // Whether the Message may say one compile clears every warning and none has to be cleared // first: only when the warnings allow it, and never when a declared type needs a compile // before it exists, which contradicts it. @@ -222,7 +295,8 @@ private static string BuildApplyOutcomeMessage( HotReloadOrchestratorResult result, bool hasFailure, bool allRequestedSkipped, - int reappliedSiblingCount) + int reappliedSiblingCount, + string skippedCountSuffix) { int addedCount = CountAddedOutcomes(result); if (hasFailure) @@ -272,16 +346,9 @@ private static string BuildApplyOutcomeMessage( + " of the patched and added rows re-applied changes from earlier reloads in sibling files."; } - // Why counted here: the totals only count what was applied, so a run that skipped - // some of the edits otherwise reads as if every one of them took effect. - int skippedCount = CountOutcomesOfKind(result, HotReloadMethodOutcomeKind.Skipped); - if (skippedCount > 0) - { - message += string.Format( - CultureInfo.InvariantCulture, - HotReloadConstants.SkippedCountApplyMessageSuffixFormat, - skippedCount); - } + // Why the Skipped count goes here: the totals only count what was applied, so a run + // that skipped some of the edits otherwise reads as if every one of them took effect. + message += skippedCountSuffix; return AppendStaleSummary(message, result); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingScanResult.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingScanResult.cs index 77a47037db..3d79c9c5e9 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingScanResult.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingScanResult.cs @@ -7,17 +7,29 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal sealed class HotReloadChangedSiblingScanResult { + // Why incomplete: Empty stands for a scan that never ran (no DLL, PDB or source list), and + // an empty list from a scan that never ran does not mean that no sibling changed. internal static readonly HotReloadChangedSiblingScanResult Empty = - new HotReloadChangedSiblingScanResult(Array.Empty(), string.Empty); + new HotReloadChangedSiblingScanResult(Array.Empty(), string.Empty, isComplete: false); internal string[] ChangedSiblingAbsolutePaths { get; } internal string ScanLimitWarning { get; } - internal HotReloadChangedSiblingScanResult(string[] changedSiblingAbsolutePaths, string scanLimitWarning) + /// + /// True only when every sibling was compared with its snapshot and the list was not + /// truncated, so a sibling missing from the list is known to be unchanged. + /// + internal bool IsComplete { get; } + + internal HotReloadChangedSiblingScanResult( + string[] changedSiblingAbsolutePaths, + string scanLimitWarning, + bool isComplete) { ChangedSiblingAbsolutePaths = changedSiblingAbsolutePaths ?? Array.Empty(); ScanLimitWarning = scanLimitWarning ?? string.Empty; + IsComplete = isComplete; } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs index 1b8df2dd5a..2800b4e323 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs @@ -88,7 +88,8 @@ internal static HotReloadChangedSiblingScanResult DetectFromSnapshotDirectory( return new HotReloadChangedSiblingScanResult( changedSiblingAbsolutePaths.ToArray(), - sourceScan.ScanLimitWarning); + sourceScan.ScanLimitWarning, + sourceScan.HasBaseline && string.IsNullOrEmpty(sourceScan.ScanLimitWarning)); } /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs index b3bf4a82f6..5d2aa12e87 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs @@ -58,7 +58,8 @@ internal static HotReloadServices CreateProductionServices() new HotReloadEditorStateSnapshotCapture(), TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, - new HotReloadApplicationPlayModeQuery()); + new HotReloadApplicationPlayModeQuery(), + new HotReloadSourceSnapshotCapture(HotReloadSourceSnapshotter.CaptureAfterDomainReload)); } /// @@ -88,7 +89,8 @@ internal static HotReloadServices CreateServices( TransformWorkerHost transformWorkerHost, Func buildDependencies, - IHotReloadPlayModeQuery playMode) + IHotReloadPlayModeQuery playMode, + HotReloadSourceSnapshotCapture sourceSnapshotCapture) { // 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 @@ -183,7 +185,8 @@ internal static HotReloadServices CreateServices( new HotReloadChangeDetector(), unityMessageForwarding, wiredValuePersistence, - wiredValueRestoreRefresh); + wiredValueRestoreRefresh, + sourceSnapshotCapture); } /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs index 075f9dcf3e..a5b32748f4 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs @@ -16,12 +16,14 @@ public static void Initialize() // Why not EditorApplication.delayCall: a cold-start session that hits Unity's native // "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). Capture is - // racy-safe (use-time PDB checksum), so running on the first update tick is fine. + // 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. void CaptureOnFirstUpdateTick() { EditorApplication.update -= CaptureOnFirstUpdateTick; - HotReloadSourceSnapshotter.CaptureAfterDomainReload(); + HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured(); } // Why a callback of its own rather than a line in the capture above: an exception in diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEntryApplier.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEntryApplier.cs index 134506f0cd..4778f0b569 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEntryApplier.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEntryApplier.cs @@ -89,23 +89,36 @@ private HotReloadFileProcessResult ApplyPreparedFile( context, file, compileResult, prepared.Entries, prepared.Resolution); } - // Peels leftover Harmony patches when the source again matches the verified baseline. - // Resolve failures are silent: unchanged identities already matched compile-time IL. - // A method Harmony could not restore becomes that method's Failed outcome instead of - // aborting the peel, so the remaining unchanged methods still get reverted. - // Returns how many Revert calls actually removed a live patch. + /// + /// Peels leftover Harmony patches when the source again matches the verified baseline. + /// Resolve failures are silent: unchanged identities already matched compile-time IL. + /// A method Harmony could not restore becomes that method's Failed outcome instead of + /// aborting the peel, so the remaining unchanged methods still get reverted. + /// Rows whose method name no live patch carries are skipped without being resolved. + /// Returns how many Revert calls actually removed a live patch. + /// + /// + /// How a row is resolved; production passes the Resolve of the matcher it made for the + /// group, and tests count the calls. + /// internal int RevertUnchangedPatches( HotReloadTypeHome fileHome, HotReloadEntryHomeResolver homeResolver, TransformWorkerUnchangedMethodDto[] unchangedMethods, List outcomes, - string assemblyResolvePath) + string assemblyResolvePath, + HotReloadMethodResolver resolveMethod) { Debug.Assert(fileHome != null, "fileHome must not be null."); Debug.Assert(homeResolver != null, "homeResolver must not be null."); Debug.Assert(unchangedMethods != null, "unchangedMethods must not be null."); Debug.Assert(outcomes != null, "outcomes must not be null."); + Debug.Assert(resolveMethod != null, "resolveMethod must not be null."); + // Why once before the loop: the peel only removes patches, so the names collected here + // stay a superset of the methods that still hold one while the rows are walked. + HashSet patchedMethodNames = + HotReloadUnchangedPeelFilter.CollectPatchedMethodNames(_domain.ListGenerations()); int revertedCount = 0; for (int index = 0; index < unchangedMethods.Length; index++) { @@ -118,13 +131,29 @@ internal int RevertUnchangedPatches( continue; } + // Why the row decides the home: a method of a type an artifact serves is patched + // on that artifact, so the leftover patch to peel is only findable there. + // Why before the name check: a row naming an assembly no artifact carries must stop + // the run whether or not a patch could be peeled, as it did when every row was + // resolved. + HotReloadTypeHome home = homeResolver.Resolve(fileHome, unchanged.homeAssemblyName); + + // Why by name before resolving: the first row resolved against an image reads the + // whole compiled assembly, and every row is then matched against it, while a row can + // only be peeled when the method it resolves to holds a live patch. That method + // carries the row's name, so a name no live patch has cannot lead to a peel, and the + // filter spares that row both costs. The name is only a prefilter: a row that passes + // is still resolved exactly, type, parameters and arity included. + if (!patchedMethodNames.Contains(unchanged.methodName)) + { + continue; + } + // Why pass unchanged.genericArity: Caller(int) and Caller(int) share name // and parameters. Arity 0 would resolve the generic unchanged row to the // non-generic sibling and peel its live patch. - // Why the row decides the home: a method of a type an artifact serves is patched - // on that artifact, so the leftover patch to peel is only findable there. - HotReloadMethodMatchResult matchResult = HotReloadMethodMatcher.Resolve( - homeResolver.Resolve(fileHome, unchanged.homeAssemblyName), + HotReloadMethodMatchResult matchResult = resolveMethod( + home, unchanged.typeMetadataName, unchanged.methodName, unchanged.parameterTypeFullNames, @@ -169,6 +198,11 @@ internal void RevertUnchangedPatchesPerFile( HotReloadEntryHomeResolver homeResolver = new HotReloadEntryHomeResolver(_domain, files[0].ProjectRoot); + // Why once for the group: every row of the group that names the same home resolves + // against the same compiled image, so the peel reads that image once instead of once + // for every row it resolves. + using HotReloadMethodMatcher matcher = HotReloadMethodMatcher.CreateReadingFromDisk(); + HotReloadMethodResolver resolveMethod = matcher.Resolve; foreach (HotReloadGroupFile file in files) { // Why a file left unapplied is left alone: a file the shim compile refused keeps @@ -194,7 +228,8 @@ internal void RevertUnchangedPatchesPerFile( homeResolver, unchangedMethods, file.Sinks.Outcomes, - file.AssemblyResolvePath); + file.AssemblyResolvePath, + resolveMethod); } } /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEntryResolution.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEntryResolution.cs index ec807516ca..27f20ad17f 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEntryResolution.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEntryResolution.cs @@ -18,9 +18,12 @@ internal static class HotReloadEntryResolution // accessor binders run once for the group instead of once per file. // Why addedCallees is passed in: a body can call an added member another file of the group // declares, so one file's entries alone cannot name every call. + // Why resolveMethod is passed in: the caller owns the run's matcher and decides how long it + // lives, so resolution never holds a matcher past the run. internal static Result ResolveEntries( HotReloadTypeHome fileHome, HotReloadEntryHomeResolver homeResolver, + HotReloadMethodResolver resolveMethod, string filePath, Assembly shimAssembly, TransformWorkerEntryDto[] entriesToPatch, @@ -29,6 +32,7 @@ internal static Result ResolveEntries( { Debug.Assert(fileHome != null, "fileHome must not be null."); Debug.Assert(homeResolver != null, "homeResolver must not be null."); + Debug.Assert(resolveMethod != null, "resolveMethod must not be null."); Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be empty."); Debug.Assert(shimAssembly != null, "shimAssembly must not be null."); Debug.Assert(entriesToPatch != null, "entriesToPatch must not be null."); @@ -42,6 +46,7 @@ internal static Result ResolveEntries( entriesToPatch[index], fileHome, homeResolver, + resolveMethod, shimAssembly, bindFailures, addedCallees, @@ -114,6 +119,7 @@ private static ResolvedEntryOutcome TryResolveEntry( TransformWorkerEntryDto entry, HotReloadTypeHome fileHome, HotReloadEntryHomeResolver homeResolver, + HotReloadMethodResolver resolveMethod, Assembly shimAssembly, IReadOnlyDictionary bindFailures, HotReloadAddedCalleeIndex addedCallees, @@ -148,6 +154,7 @@ private static ResolvedEntryOutcome TryResolveEntry( methodLabel, fileHome, homeResolver, + resolveMethod, shimAssembly, bindFailures, calledAddedMembers, @@ -224,6 +231,7 @@ private static ResolvedEntryOutcome TryResolveExistingMethod( string methodLabel, HotReloadTypeHome fileHome, HotReloadEntryHomeResolver homeResolver, + HotReloadMethodResolver resolveMethod, Assembly shimAssembly, IReadOnlyDictionary bindFailures, IReadOnlyList calledAddedMembers, @@ -245,7 +253,7 @@ private static ResolvedEntryOutcome TryResolveExistingMethod( // introduced-type artifact this domain retains rather than in the assembly the // edited file belongs to, and only the row knows which. HotReloadTypeHome home = homeResolver.Resolve(fileHome, entry.homeAssemblyName); - HotReloadMethodMatchResult matchResult = HotReloadMethodMatcher.Resolve( + HotReloadMethodMatchResult matchResult = resolveMethod( home, entry.typeMetadataName, entry.methodName, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEnumMemberOnlyLeaveOut.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEnumMemberOnlyLeaveOut.cs index 04f09024d5..35bba97186 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEnumMemberOnlyLeaveOut.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEnumMemberOnlyLeaveOut.cs @@ -156,9 +156,11 @@ internal TransformWorkerInputDto BuildRetryInput( excludedMethodKeys = firstInput.excludedMethodKeys, excludedAddedMethodKeys = firstInput.excludedAddedMethodKeys, assemblySourcePaths = firstInput.assemblySourcePaths, - // Why the left-out files are not added as siblings: the transform reads siblings - // only for const drift, which the left-out file's own notices already report. + // Why the left-out files are not added as siblings: the transform reads siblings for + // const drift and to tell a changed part of a partial type, and a left-out file only + // adds enum members, so neither applies to it; its own notices report its drift. changedSiblingSourcePaths = firstInput.changedSiblingSourcePaths, + changedSiblingScanComplete = firstInput.changedSiblingScanComplete, introducedTypeArtifacts = firstInput.introducedTypeArtifacts, activeMethodLabels = firstInput.activeMethodLabels }; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupEntryPreparation.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupEntryPreparation.cs index 5096287086..e76053c95f 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupEntryPreparation.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupEntryPreparation.cs @@ -45,12 +45,24 @@ internal static IReadOnlyList PrepareGroup( // Why once for the group: a body can call an added member another file of the group // declares, and only the whole group's entries name every added member it may call. HotReloadAddedCalleeIndex addedCallees = new HotReloadAddedCalleeIndex(entriesToPatch); + // Why once for the group: every entry of the group that names the same home resolves + // against the same compiled image, and reading it per entry made the patch stage grow + // with the number of live patches in a file. + using HotReloadMethodMatcher matcher = HotReloadMethodMatcher.CreateReadingFromDisk(); + HotReloadMethodResolver resolveMethod = matcher.Resolve; List prepared = new List(context.Files.Count); foreach (HotReloadGroupFile file in context.Files) { prepared.Add( - PrepareFile(compileResult, homeResolver, file, entriesByFile, bindFailures, addedCallees)); + PrepareFile( + compileResult, + homeResolver, + resolveMethod, + file, + entriesByFile, + bindFailures, + addedCallees)); } return prepared; @@ -59,6 +71,7 @@ internal static IReadOnlyList PrepareGroup( private static HotReloadPreparedGroupFile PrepareFile( HotReloadShimCompileResult compileResult, HotReloadEntryHomeResolver homeResolver, + HotReloadMethodResolver resolveMethod, HotReloadGroupFile file, Dictionary> entriesByFile, Dictionary bindFailures, @@ -79,6 +92,7 @@ private static HotReloadPreparedGroupFile PrepareFile( HotReloadEntryResolution.Result resolution = HotReloadEntryResolution.ResolveEntries( file.Home, homeResolver, + resolveMethod, file.AssemblyResolvePath, compileResult.Assembly, entries, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs index ab852d9f4a..70790f6eb2 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs @@ -1,11 +1,11 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using UnityEngine; +using Stopwatch = System.Diagnostics.Stopwatch; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -49,9 +49,11 @@ internal HotReloadGroupProcessor( internal async Task> ProcessGroupAsync( IReadOnlyList files, string correlationId, + HotReloadRunTiming timing, CancellationToken ct) { Debug.Assert(files != null && files.Count > 0, "A group must hold a file."); + Debug.Assert(timing != null, "timing must not be null."); HotReloadGroupFile firstFile = files[0]; // Application.dataPath and the ledgers require the Unity main thread. @@ -78,15 +80,18 @@ internal async Task> ProcessGroupAsync firstFile.Sinks.SiblingDerivedWarnings.Add(siblingScan.ScanLimitWarning); } - TransformWorkerInputDto workerInput = BuildWorkerInput(files, siblingScan, _domain); + TransformWorkerInputDto 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) .ConfigureAwait(false); + // The preparation runs the transform worker too, so its time counts as analysis. + timing.AddAnalysis(preparationWatch.ElapsedMilliseconds); // Why before the failure branch and only here: one preparation covers every // declaration of the group, so a declaration bound from a retained artifact and a // non-fatal notice are true whether or not another declaration was refused. A @@ -122,7 +127,7 @@ internal async Task> ProcessGroupAsync HotReloadRefusedIntroducedType.CollectFrom(preparation.Notices); if (preparation.Prepared == null) { - return await TransformAndApplyGroupAsync(files, workerInput, null, refusedTypes, activePaths, correlationId, ct) + return await TransformAndApplyGroupAsync(files, workerInput, null, refusedTypes, activePaths, correlationId, timing, ct) .ConfigureAwait(false); } @@ -133,6 +138,7 @@ internal async Task> ProcessGroupAsync refusedTypes, activePaths, correlationId, + timing, ct).ConfigureAwait(false); } @@ -157,6 +163,7 @@ private async Task> TransformAndApplyP IReadOnlyList refusedTypes, HashSet activePaths, string correlationId, + HotReloadRunTiming timing, CancellationToken ct) { HotReloadIntroducedTypeArtifact artifact = prepared.Artifact; @@ -186,6 +193,7 @@ private async Task> TransformAndApplyP refusedTypes, activePaths, correlationId, + timing, ct).ConfigureAwait(false); } finally @@ -213,9 +221,10 @@ private async Task> TransformAndApplyG IReadOnlyList refusedTypes, HashSet activePaths, string correlationId, + HotReloadRunTiming timing, CancellationToken ct) { - TransformWorkerClientResult workerResult = await RunWorkerAsync(workerInput, correlationId, ct) + TransformWorkerClientResult workerResult = await RunWorkerAsync(workerInput, correlationId, timing, ct) .ConfigureAwait(false); if (!workerResult.Success) { @@ -232,7 +241,7 @@ private async Task> TransformAndApplyG activePaths); if (leftOutPaths.Count == 0) { - return await ApplyTransformedGroupAsync(files, workerInput, workerOutput, prepared, refusedTypes, correlationId, ct) + return await ApplyTransformedGroupAsync(files, workerInput, workerOutput, prepared, refusedTypes, correlationId, timing, ct) .ConfigureAwait(false); } @@ -241,7 +250,7 @@ private async Task> TransformAndApplyG TransformWorkerInputDto retryInput = _leaveOut.BuildRetryInput(workerInput, leftOutPaths); HotReloadGroupLeaveOutSplit split = new HotReloadGroupLeaveOutSplit(files, leftOutPaths); List leftOutResults = BuildLeftOutResults(split, files, workerOutput); - TransformWorkerClientResult retryResult = await RunWorkerAsync(retryInput, correlationId, ct) + TransformWorkerClientResult retryResult = await RunWorkerAsync(retryInput, correlationId, timing, ct) .ConfigureAwait(false); IReadOnlyList remainingResults = retryResult.Success ? await ApplyTransformedGroupAsync( @@ -251,6 +260,7 @@ private async Task> TransformAndApplyG prepared, refusedTypes, correlationId, + timing, ct).ConfigureAwait(false) : FailGroup(split.RemainingFiles, retryResult.ErrorMessage); return split.Splice(leftOutResults, remainingResults); @@ -259,11 +269,14 @@ private async Task> TransformAndApplyG private async Task RunWorkerAsync( TransformWorkerInputDto workerInput, string correlationId, + HotReloadRunTiming timing, CancellationToken ct) { + Stopwatch workerWatch = Stopwatch.StartNew(); TransformWorkerClientResult workerResult = await _dependencies .RunWorker(workerInput, ct) .ConfigureAwait(false); + timing.AddAnalysis(workerWatch.ElapsedMilliseconds); HotReloadOrchestratorLog.LogHotReloadWorkerResult(workerResult, correlationId); return workerResult; } @@ -320,6 +333,7 @@ private async Task> ApplyTransformedGr HotReloadPreparedIntroducedTypes prepared, IReadOnlyList refusedTypes, string correlationId, + HotReloadRunTiming timing, CancellationToken ct) { HotReloadGroupFile firstFile = files[0]; @@ -367,19 +381,26 @@ private async Task> ApplyTransformedGr files, prepared, new HotReloadCompileFailureNoteSources(workerOutput.skipped, refusedTypes)); + Stopwatch gateWatch = Stopwatch.StartNew(); HotReloadGroupGateAndCompileResult gateAndCompile = await _dependencies .GateAndCompile(context, ct) .ConfigureAwait(false); + // Why before the failure return: a gate or compile that failed still took its time, + // and the breakdown has to show where that time went. + timing.AddShimCompile(gateWatch.ElapsedMilliseconds); if (gateAndCompile.Outcome == HotReloadGroupGateAndCompileOutcome.Failed) { return _fileEntryApplier.BuildUnappliedGroupResults(files); } - return await CompleteApplyAfterCoverageAsync( + Stopwatch patchWatch = Stopwatch.StartNew(); + IReadOnlyList results = await CompleteApplyAfterCoverageAsync( context, gateAndCompile.Gate, gateAndCompile.Compile, ct).ConfigureAwait(false); + timing.AddPatch(patchWatch.ElapsedMilliseconds); + return results; } /// @@ -568,65 +589,6 @@ private void SnapshotGroupState(IReadOnlyList files) } } - private static TransformWorkerInputDto BuildWorkerInput( - IReadOnlyList files, - HotReloadChangedSiblingScanResult siblingScan, - HotReloadDomain domain) - { - HotReloadGroupFile firstFile = files[0]; - TransformWorkerSourceDto[] sources = new TransformWorkerSourceDto[files.Count]; - for (int index = 0; index < files.Count; index++) - { - HotReloadGroupFile file = files[index]; - sources[index] = new TransformWorkerSourceDto - { - sourcePath = Path.GetFullPath(file.WorkerSourcePath), - projectRelativePath = file.ProjectRelativePath, - snapshotSource = file.SnapshotSource, - reappliedSibling = file.ReappliedSibling - }; - } - - return new TransformWorkerInputDto - { - sources = sources, - defines = firstFile.CompilationAssembly.defines ?? Array.Empty(), - referencePaths = HotReloadShimReferenceBuilder.BuildWorkerReferencePaths( - firstFile.CompilationAssembly, - firstFile.Home), - targetTypesAssemblyPath = Path.GetFullPath(firstFile.Home.DllPath), - // The retained records normalize an introduced type back to the generation of the - // assembly that owns its source, so every run has to name that generation even - // before it carries a record of its own. - targetAssemblyName = firstFile.AssemblyName, - targetAssemblyMvid = HotReloadSourceSnapshotter.ReadAssemblyMvid(firstFile.Home.DllPath), - assemblySourcePaths = HotReloadPatchTargetSupport.BuildAssemblySourcePaths( - firstFile.ProjectRoot, - firstFile.CompilationAssembly.sourceFiles), - changedSiblingSourcePaths = siblingScan.ChangedSiblingAbsolutePaths, - activeMethodLabels = CollectActiveMethodLabels(files, domain) - }; - } - - // Why added members too: a skipped method keeps running the body an earlier reload - // patched into it, and a skipped added member is deactivated but stays reachable, because - // a patch this run leaves active can still call its earlier shim body. Either way the - // skipped writer may still assign the field. Both lists hold display labels, which is - // the form the worker's skipped rows use. - private static string[] CollectActiveMethodLabels( - IReadOnlyList files, - HotReloadDomain domain) - { - List labels = new List(); - foreach (HotReloadGroupFile file in files) - { - labels.AddRange(domain.ListActiveMethodKeys(file.ProjectRelativePath)); - labels.AddRange(domain.ListActiveAddedMethodKeys(file.ProjectRelativePath)); - } - - return labels.ToArray(); - } - private static List CollectProjectRelativePaths(IReadOnlyList files) { List projectRelativePaths = new List(files.Count); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs new file mode 100644 index 0000000000..0b47916256 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Builds the transform worker input that one assembly group sends to the worker. + /// + internal static class HotReloadGroupWorkerInputBuilder + { + internal static TransformWorkerInputDto BuildWorkerInput( + IReadOnlyList files, + HotReloadChangedSiblingScanResult siblingScan, + HotReloadDomain domain) + { + HotReloadGroupFile firstFile = files[0]; + TransformWorkerSourceDto[] sources = new TransformWorkerSourceDto[files.Count]; + for (int index = 0; index < files.Count; index++) + { + HotReloadGroupFile file = files[index]; + sources[index] = new TransformWorkerSourceDto + { + sourcePath = Path.GetFullPath(file.WorkerSourcePath), + projectRelativePath = file.ProjectRelativePath, + snapshotSource = file.SnapshotSource, + reappliedSibling = file.ReappliedSibling + }; + } + + return new TransformWorkerInputDto + { + sources = sources, + defines = firstFile.CompilationAssembly.defines ?? Array.Empty(), + referencePaths = HotReloadShimReferenceBuilder.BuildWorkerReferencePaths( + firstFile.CompilationAssembly, + firstFile.Home), + targetTypesAssemblyPath = Path.GetFullPath(firstFile.Home.DllPath), + // The retained records normalize an introduced type back to the generation of the + // assembly that owns its source, so every run has to name that generation even + // before it carries a record of its own. + targetAssemblyName = firstFile.AssemblyName, + targetAssemblyMvid = HotReloadSourceSnapshotter.ReadAssemblyMvid(firstFile.Home.DllPath), + assemblySourcePaths = HotReloadPatchTargetSupport.BuildAssemblySourcePaths( + firstFile.ProjectRoot, + firstFile.CompilationAssembly.sourceFiles), + changedSiblingSourcePaths = siblingScan.ChangedSiblingAbsolutePaths, + changedSiblingScanComplete = siblingScan.IsComplete, + activeMethodLabels = CollectActiveMethodLabels(files, domain) + }; + } + + // Why added members too: a skipped method keeps running the body an earlier reload + // patched into it, and a skipped added member is deactivated but stays reachable, because + // a patch this run leaves active can still call its earlier shim body. Either way the + // skipped writer may still assign the field. Both lists hold display labels, which is + // the form the worker's skipped rows use. + private static string[] CollectActiveMethodLabels( + IReadOnlyList files, + HotReloadDomain domain) + { + List labels = new List(); + foreach (HotReloadGroupFile file in files) + { + labels.AddRange(domain.ListActiveMethodKeys(file.ProjectRelativePath)); + labels.AddRange(domain.ListActiveAddedMethodKeys(file.ProjectRelativePath)); + } + + return labels.ToArray(); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs.meta new file mode 100644 index 0000000000..ff802ca27c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a8475e194ba744b278463a27ffaec6b7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs index eb6db8ff41..dbb9ef7a9c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs @@ -140,6 +140,7 @@ private static async Task CompileArtifact firstFile.Home, collaborators.Domain, firstFile.ProjectRoot, + HotReloadResolverSearchDirectories.Collect(firstFile.CompilationAssembly), collaborators.InternalAccessGrant.IsAvailable); if (!references.Success) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypeResponseSection.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypeResponseSection.cs index 24e9b1b2eb..caa8857283 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypeResponseSection.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypeResponseSection.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; using System.Globalization; +using UnityEngine; + namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// @@ -51,9 +53,11 @@ internal static bool TryBuildMessage( bool hasMethodFailure, int patchedMethodCount, int addedMethodCount, - int skippedMethodCount, + string skippedCountSuffix, out string message) { + Debug.Assert(skippedCountSuffix != null, "skippedCountSuffix must not be null."); + message = null; if (outcomes.Count == 0) { @@ -94,13 +98,7 @@ internal static bool TryBuildMessage( : CountOfKind(outcomes, HotReloadIntroducedTypeOutcomeKind.AlreadyActive)); // Why counted here: "no method body needed patching" otherwise reads as if every // method edit of the run was unchanged, while some of them were Skipped. - if (skippedMethodCount > 0) - { - message += string.Format( - CultureInfo.InvariantCulture, - HotReloadConstants.SkippedCountApplyMessageSuffixFormat, - skippedMethodCount); - } + message += skippedCountSuffix; return true; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs index 687280f41f..4a6befbb81 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using UnityEngine; +using Stopwatch = System.Diagnostics.Stopwatch; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -74,6 +75,8 @@ public async Task RunAsync( Debug.Assert(files.Count > 0, "files must not be empty."); string correlationId = VibeLogger.GenerateCorrelationId(); + Stopwatch total = Stopwatch.StartNew(); + 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. @@ -156,6 +159,7 @@ await ProcessPlannedGroupAsync( inputIndexes, slots, correlationId, + timing, ct, pathsInRun, contentPathOverrideByFile, @@ -193,7 +197,7 @@ await ProcessPlannedGroupAsync( run.ApplyOneShotCallerNotes(projectRoot); await MainThreadSwitcher.SwitchToMainThread(ct); - return run.BuildResult(correlationId); + return run.BuildResult(correlationId, timing.Complete(total.ElapsedMilliseconds)); } // Why on the inputs only: a re-applied sibling joins a group later and was never selected, @@ -214,6 +218,7 @@ private async Task ProcessPlannedGroupAsync( IReadOnlyList inputIndexes, HotReloadInputResolutionSlot[] slots, string correlationId, + HotReloadRunTiming timing, CancellationToken ct, HashSet pathsInRun, IReadOnlyDictionary contentPathOverrideByFile, @@ -245,7 +250,7 @@ private async Task ProcessPlannedGroupAsync( // ProcessGroupAsync switches back via MainThreadSwitcher (EditorApplication.update // queue) before any main-thread-only editor API or Harmony patch. IReadOnlyList groupResults = - await _groupProcessor.ProcessGroupAsync(filesOfGroup, correlationId, ct) + await _groupProcessor.ProcessGroupAsync(filesOfGroup, correlationId, timing, ct) .ConfigureAwait(false); Debug.Assert( groupResults.Count == filesOfGroup.Count, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestratorResult.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestratorResult.cs index 9ae9c24889..d829ce5be4 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestratorResult.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestratorResult.cs @@ -35,6 +35,9 @@ internal sealed class HotReloadOrchestratorResult // The serialized added fields this run's warning named for the first time. public IReadOnlyList SerializedAddedFieldsReported { get; } + // Milliseconds per phase of the run; null when the run was not measured. + public HotReloadTimingBreakdown Timing { get; } + public HotReloadOrchestratorResult( IReadOnlyList methods, IReadOnlyList warnings, @@ -52,7 +55,8 @@ public HotReloadOrchestratorResult( bool autoRefreshHoldNewlyArmed = false, int introducedTypeNoticeCount = 0, IReadOnlyList serializedAddedFieldsReported = null, - IReadOnlyList activePatchSiblingPaths = null) + IReadOnlyList activePatchSiblingPaths = null, + HotReloadTimingBreakdown timing = null) { Methods = methods; Warnings = warnings; @@ -77,6 +81,7 @@ public HotReloadOrchestratorResult( IntroducedTypeNoticeCount = introducedTypeNoticeCount; SerializedAddedFieldsReported = serializedAddedFieldsReported ?? Array.Empty(); ActivePatchSiblingPaths = activePatchSiblingPaths ?? Array.Empty(); + Timing = timing; } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs index 23f6cd73a6..8d03315cb3 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs @@ -105,7 +105,7 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( outcomes.Add( HotReloadMethodOutcome.Failed( "(file)", - "Compiled assembly not found at '" + home.DllPath + "'. Compile the project first.", + HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(projectRoot, home.DllPath), assemblyResolvePath)); return HotReloadPatchTargetResolution.EarlyExit( new HotReloadFileProcessResult(outcomes, warnings, 0)); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRequestedFileOutcomeSummary.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRequestedFileOutcomeSummary.cs index 00dc6ff4f8..71a5ce5420 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRequestedFileOutcomeSummary.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRequestedFileOutcomeSummary.cs @@ -90,5 +90,42 @@ public static int CountReappliedSiblingOutcomes( return count; } + + /// + /// Counts the Skipped rows that belong to a sibling file the run pulled in to re-apply its + /// earlier changes, as opposed to the files the caller asked about. + /// + // Why apart from the Patched and Added count: the message reports Skipped rows in a count + // of their own, which includes the sibling ones while Outcome leaves them out. + public static int CountSkippedSiblingOutcomes( + IReadOnlyList methods, + IReadOnlyCollection reappliedSiblingPaths, + Func toProjectRelativeScriptPath) + { + Debug.Assert(methods != null, "methods must not be null."); + Debug.Assert(reappliedSiblingPaths != null, "reappliedSiblingPaths must not be null."); + Debug.Assert( + toProjectRelativeScriptPath != null, + "toProjectRelativeScriptPath must not be null."); + + HotReloadReappliedSiblingFiles siblingFiles = + new HotReloadReappliedSiblingFiles(reappliedSiblingPaths, toProjectRelativeScriptPath); + int count = 0; + for (int index = 0; index < methods.Count; index++) + { + HotReloadMethodOutcome outcome = methods[index]; + if (outcome.Kind != HotReloadMethodOutcomeKind.Skipped) + { + continue; + } + + if (siblingFiles.Contains(outcome.FilePath)) + { + count++; + } + } + + return count; + } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunAccumulator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunAccumulator.cs index a5aa9dbbf3..60f4ca5013 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunAccumulator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunAccumulator.cs @@ -226,8 +226,11 @@ public void ApplyOneShotCallerNotes(string projectRoot) /// /// Appends the run-level warnings, logs the summary, syncs the Auto Refresh hold, and /// builds the final result. Requires the Unity main thread for the Auto Refresh sync. + /// is what the run measured per phase; null leaves it unmeasured. /// - public HotReloadOrchestratorResult BuildResult(string correlationId) + public HotReloadOrchestratorResult BuildResult( + string correlationId, + HotReloadTimingBreakdown timing = null) { // Why before anything reads the rows: the response copies each Skipped row's reason // into Warnings, so a step added later would reach the row but not its warning. @@ -274,7 +277,8 @@ public HotReloadOrchestratorResult BuildResult(string correlationId) autoRefreshHoldNewlyArmed: newlyArmed, introducedTypeNoticeCount: _introducedTypeNoticeCount, serializedAddedFieldsReported: _serializedAddedFieldsReported, - activePatchSiblingPaths: CollectActivePatchSiblingPaths()); + activePatchSiblingPaths: CollectActivePatchSiblingPaths(), + timing: timing); } // Why only ActiveChanges: a sibling retried after an earlier Skip or brought in as a diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunTiming.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunTiming.cs new file mode 100644 index 0000000000..2827b23c8d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunTiming.cs @@ -0,0 +1,37 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Sums the milliseconds one apply run spends in each phase across all of its groups. + /// + /// + /// Why an accumulator the run hands down: group results are per file and carry no group-level + /// data, so the run hands this accumulator down to the group processor instead. Each run makes + /// its own, so no long-lived object keeps the state of a run. + /// + internal sealed class HotReloadRunTiming + { + private long _analysisMs; + private long _shimCompileMs; + private long _patchMs; + + public void AddAnalysis(long milliseconds) + { + _analysisMs += HotReloadTimingBreakdown.RequireElapsed(milliseconds, nameof(milliseconds)); + } + + public void AddShimCompile(long milliseconds) + { + _shimCompileMs += HotReloadTimingBreakdown.RequireElapsed(milliseconds, nameof(milliseconds)); + } + + public void AddPatch(long milliseconds) + { + _patchMs += HotReloadTimingBreakdown.RequireElapsed(milliseconds, nameof(milliseconds)); + } + + public HotReloadTimingBreakdown Complete(long totalMs) + { + return new HotReloadTimingBreakdown(_analysisMs, _shimCompileMs, _patchMs, totalMs); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunTiming.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunTiming.cs.meta new file mode 100644 index 0000000000..2ef082fd63 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunTiming.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: daea5075a459d4e438418bf25a61588b +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 05ef217ec1..e0a8b48970 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadServices.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadServices.cs @@ -25,7 +25,8 @@ internal HotReloadServices( IHotReloadChangeDetector changeDetector, HotReloadUnityMessageForwarding unityMessageForwarding, HotReloadWiredValuePersistence wiredValuePersistence, - HotReloadWiredValueRestoreRefresh wiredValueRestoreRefresh) + HotReloadWiredValueRestoreRefresh wiredValueRestoreRefresh, + HotReloadSourceSnapshotCapture sourceSnapshotCapture) { Debug.Assert(domain != null, "domain must not be null."); Debug.Assert(harmony != null, "harmony must not be null."); @@ -48,6 +49,7 @@ internal HotReloadServices( Debug.Assert(wiredValuePersistence != null, "wiredValuePersistence must not be null."); Debug.Assert( wiredValueRestoreRefresh != null, "wiredValueRestoreRefresh must not be null."); + Debug.Assert(sourceSnapshotCapture != null, "sourceSnapshotCapture must not be null."); Domain = domain; Harmony = harmony; Patcher = patcher; @@ -65,6 +67,7 @@ internal HotReloadServices( UnityMessageForwarding = unityMessageForwarding; WiredValuePersistence = wiredValuePersistence; WiredValueRestoreRefresh = wiredValueRestoreRefresh; + SourceSnapshotCapture = sourceSnapshotCapture; } internal HotReloadDomain Domain { get; } @@ -115,6 +118,11 @@ internal HotReloadServices( /// internal HotReloadWiredValueRestoreRefresh WiredValueRestoreRefresh { get; } + /// + /// Captures this domain's source snapshot once, before whichever comes first reads it. + /// + internal HotReloadSourceSnapshotCapture SourceSnapshotCapture { get; } + /// /// A copy that runs instead of this one, sharing every /// other collaborator — including the domain, so installing the copy neither takes the @@ -139,7 +147,8 @@ internal HotReloadServices WithOrchestrator(IHotReloadOrchestrator orchestrator) ChangeDetector, UnityMessageForwarding, WiredValuePersistence, - WiredValueRestoreRefresh); + WiredValueRestoreRefresh, + SourceSnapshotCapture); } /// @@ -165,7 +174,36 @@ internal HotReloadServices WithChangeDetector(IHotReloadChangeDetector changeDet changeDetector, UnityMessageForwarding, WiredValuePersistence, - WiredValueRestoreRefresh); + WiredValueRestoreRefresh, + SourceSnapshotCapture); + } + + /// + /// A copy that captures the source snapshot through , + /// sharing every other collaborator. + /// + internal HotReloadServices WithSourceSnapshotCapture( + HotReloadSourceSnapshotCapture sourceSnapshotCapture) + { + return new HotReloadServices( + Domain, + Harmony, + Patcher, + FileEntryApplier, + EntryApplier, + TransformWorkerClient, + GroupStageCollaborators, + GroupCommitStage, + GroupProcessor, + Orchestrator, + StatusExecutor, + PackageRootCapture, + EditorStateSnapshotCapture, + ChangeDetector, + UnityMessageForwarding, + WiredValuePersistence, + WiredValueRestoreRefresh, + sourceSnapshotCapture); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadShimIsolation.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadShimIsolation.cs index 7b314f5859..2fb6ac1671 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadShimIsolation.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadShimIsolation.cs @@ -132,6 +132,9 @@ internal static async Task RunIsolationRetryAsync( // Why copy: retry must still scan the same snapshot-mismatched siblings so // siblingConstDriftWarnings stay populated on the retry worker output. changedSiblingSourcePaths = workerInput.changedSiblingSourcePaths, + // Why copy: without it the retry would treat the other parts of every partial type + // as unverified and skip methods the first run transformed. + changedSiblingScanComplete = workerInput.changedSiblingScanComplete, // Why copy: the retry reports the skipped-writer warnings in place of the first run. activeMethodLabels = workerInput.activeMethodLabels }; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingBreakdown.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingBreakdown.cs new file mode 100644 index 0000000000..55e5e2fe2a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingBreakdown.cs @@ -0,0 +1,60 @@ +using System; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Milliseconds an apply run spent in each phase, as reported in the response. + /// + internal sealed class HotReloadTimingBreakdown + { + public HotReloadTimingBreakdown(long analysisMs, long shimCompileMs, long patchMs, long totalMs) + { + AnalysisMs = RequireElapsed(analysisMs, nameof(analysisMs)); + ShimCompileMs = RequireElapsed(shimCompileMs, nameof(shimCompileMs)); + PatchMs = RequireElapsed(patchMs, nameof(patchMs)); + TotalMs = RequireElapsed(totalMs, nameof(totalMs)); + + // Why refuse instead of clamping: the phases are spans inside the run's total, so a + // shorter total means a phase was measured outside the run. + long otherMs = totalMs - analysisMs - shimCompileMs - patchMs; + if (otherMs < 0) + { + throw new ArgumentOutOfRangeException( + nameof(totalMs), + totalMs, + "Total milliseconds must cover the phases."); + } + + OtherMs = otherMs; + } + + // Transform worker runs, introduced-type preparation included. + public long AnalysisMs { get; } + + // The signature-change gate and the shim compile, isolation retries included. + public long ShimCompileMs { get; } + + // Applying the patches. + public long PatchMs { get; } + + // Outside the three phases: file resolution, planning, the checks that find unchanged + // methods, and the like. + public long OtherMs { get; } + + // The whole run, the three phases included. + public long TotalMs { get; } + + internal static long RequireElapsed(long milliseconds, string parameterName) + { + if (milliseconds < 0) + { + throw new ArgumentOutOfRangeException( + parameterName, + milliseconds, + "Elapsed milliseconds must not be negative."); + } + + return milliseconds; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingBreakdown.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingBreakdown.cs.meta new file mode 100644 index 0000000000..81e8c50e31 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingBreakdown.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b46ee79f0f89e4a808814234d2176e08 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingResponse.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingResponse.cs new file mode 100644 index 0000000000..7e8f04e885 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingResponse.cs @@ -0,0 +1,26 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Milliseconds an apply run spent in each phase, as the response reports them. + /// + public sealed class HotReloadTimingResponse + { + /// Transform worker runs, introduced-type preparation included. + public long AnalysisMs { get; set; } + + /// The signature-change gate and the shim compile, isolation retries included. + public long ShimCompileMs { get; set; } + + /// Applying the patches. + public long PatchMs { get; set; } + + /// + /// What is left of TotalMs after the three phases: file resolution, planning, and the + /// checks that find unchanged methods. + /// + public long OtherMs { get; set; } + + /// The whole run inside the Editor. + public long TotalMs { get; set; } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingResponse.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingResponse.cs.meta new file mode 100644 index 0000000000..555aa1c346 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingResponse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 58b4d8e341a6e4f26a966f4a5f53af2f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs index 263ef69a5e..2509b40844 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs @@ -162,6 +162,45 @@ public class HotReloadResponse : UnityCliLoopToolResponse /// public bool AutoRefreshHeld { get; set; } + /// + /// The Auto Refresh hold sentence this run appended to Message, so a caller (the CLI after + /// a fallback compile) can remove exactly that sentence. Omitted when this run did not arm + /// the hold. + /// + public string AutoRefreshHoldMessage { get; set; } = string.Empty; + + /// + /// Applied, PartiallyApplied, NothingApplied, NothingToApply, or Failed on apply runs + /// (ReplacedByCompile once the CLI's fallback compile succeeded): whether the edits of the + /// requested files are live now. Omitted on --status, --revert-all and validation failures. + /// + public string Outcome { get; set; } = string.Empty; + + /// + /// Methods rows with Kind Skipped, sibling rows included like the other totals. + /// + public int SkippedTotal { get; set; } + + /// + /// Methods rows with Kind Added. + /// + public int AddedTotal { get; set; } + + /// + /// Methods rows with Kind Failed. + /// + public int FailedTotal { get; set; } + + /// + /// Methods rows with Kind AlreadyActive. + /// + public int AlreadyActiveTotal { get; set; } + + /// + /// Methods rows with Kind Stale. + /// + public int StaleTotal { get; set; } + // Why omit empty: success and validation-only payloads must not grow a next-action // field that PausePoint-style responses leave blank on the wire. public bool ShouldSerializeRecommendedNextAction() @@ -178,11 +217,32 @@ public bool ShouldSerializeRecommendedNextAction() public string CompileFallback { get; set; } = HotReloadCompileFallbackDecision.NotNeeded.ToString(); + /// + /// Milliseconds the apply run spent per phase. Written on apply runs only; --status, + /// --revert-all, and requests refused before the run leave it out. + /// + public HotReloadTimingResponse Timing { get; set; } + public bool ShouldSerializeErrorCode() { return !string.IsNullOrEmpty(ErrorCode); } + public bool ShouldSerializeOutcome() + { + return !string.IsNullOrEmpty(Outcome); + } + + public bool ShouldSerializeAutoRefreshHoldMessage() + { + return !string.IsNullOrEmpty(AutoRefreshHoldMessage); + } + + public bool ShouldSerializeTiming() + { + return Timing != null; + } + public bool ShouldSerializeNextActions() { return NextActions != null && NextActions.Length > 0; @@ -257,6 +317,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(); HotReloadDefaultFileSelection selection = HotReloadDefaultFileSelector.Resolve( parameters.Files, services.ChangeDetector.Detect, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadUnchangedPeelFilter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadUnchangedPeelFilter.cs new file mode 100644 index 0000000000..30671bd302 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadUnchangedPeelFilter.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Tells which of a run's unchanged rows can still hold a patch of an earlier reload, so the + /// peel resolves those rows only. + /// + internal static class HotReloadUnchangedPeelFilter + { + /// The names of every method that holds a live patch, whatever file patched it. + internal static HashSet CollectPatchedMethodNames(IReadOnlyList generations) + { + Debug.Assert(generations != null, "generations must not be null."); + HashSet names = new HashSet(StringComparer.Ordinal); + foreach (HotReloadFileGeneration generation in generations) + { + foreach (MethodBase method in generation.ListActiveMethods()) + { + names.Add(method.Name); + } + } + + return names; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadUnchangedPeelFilter.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadUnchangedPeelFilter.cs.meta new file mode 100644 index 0000000000..353baa8079 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadUnchangedPeelFilter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7749ec0b568ff43c39b17550b45bfada +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadIntroducedTypeArtifactReferenceBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadIntroducedTypeArtifactReferenceBuilder.cs index ea3b64b705..8762094caf 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadIntroducedTypeArtifactReferenceBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadIntroducedTypeArtifactReferenceBuilder.cs @@ -15,6 +15,8 @@ internal static class HotReloadIntroducedTypeArtifactReferenceBuilder /// Builds the references one artifact compiles against: the worker's raw references, or, /// when is set, the same list with the target assembly /// and its retained artifacts replaced by copies whose internal members are public. + /// are where Cecil looks for the assemblies + /// those copies refer to while it writes them. /// /// /// Why only the target and its retained artifacts: the declarations belong to the target @@ -28,9 +30,10 @@ internal static HotReloadArtifactCompileReferences Build( HotReloadTypeHome targetHome, HotReloadDomain domain, string projectRoot, + IReadOnlyCollection resolverSearchDirectories, bool exposeInternals) { - ValidateBuildArguments(transformInput, targetHome, domain, projectRoot); + ValidateBuildArguments(transformInput, targetHome, domain, projectRoot, resolverSearchDirectories); if (!exposeInternals) { return HotReloadArtifactCompileReferences.Raw(BuildRawReferencePaths(transformInput)); @@ -51,7 +54,7 @@ internal static HotReloadArtifactCompileReferences Build( // and the other artifacts of the domain, and Cecil resolves them while writing a copy. IReadOnlyCollection searchDirectories = HotReloadShimReferenceBuilder.CollectArtifactSearchDirectories( retainedHomes, - ReferencePublicizer.CollectResolverSearchDirectories(references)); + resolverSearchDirectories); // Why only a resolution failure becomes a result: Cecil could not find an assembly a copy // needs, which fails this preparation like any other unusable reference. A missing file or @@ -99,7 +102,8 @@ private static void ValidateBuildArguments( TransformWorkerInputDto transformInput, HotReloadTypeHome targetHome, HotReloadDomain domain, - string projectRoot) + string projectRoot, + IReadOnlyCollection resolverSearchDirectories) { if (transformInput == null) { @@ -120,6 +124,11 @@ private static void ValidateBuildArguments( { throw new ArgumentException("projectRoot must not be null or empty.", nameof(projectRoot)); } + + if (resolverSearchDirectories == null) + { + throw new ArgumentNullException(nameof(resolverSearchDirectories)); + } } // Why the rule that deduplicates references: the worker list holds the target as Unity diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadMethodMatcher.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadMethodMatcher.cs index 244ffbb300..d260abffa2 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadMethodMatcher.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadMethodMatcher.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.IO; using System.Reflection; @@ -7,20 +9,68 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { + /// Resolves a compiled method to the live MethodBase; the shape of HotReloadMethodMatcher.Resolve. + internal delegate HotReloadMethodMatchResult HotReloadMethodResolver( + HotReloadTypeHome home, + string typeMetadataName, + string methodName, + string[] parameterTypeFullNames, + int genericArity); + + /// Reads the compiled image at a path; the shape of the matcher's assembly loader. + internal delegate AssemblyDefinition HotReloadCompiledAssemblyLoader(string dllPath); + /// - /// Resolves a hot-reload manifest entry (type metadata name + method name + parameter type - /// full names) to the matching MethodBase in the running AppDomain, using Cecil metadata - /// tokens and an Mvid guard against stale script assemblies. + /// Resolves hot-reload manifest entries (type metadata name + method name + parameter type + /// full names) to the matching MethodBase in the running AppDomain for one run, using Cecil + /// metadata tokens and an Mvid guard against stale script assemblies. Reads each compiled + /// image once and answers every later entry of the run from that read; the Mvid guard still + /// compares that read with the loaded assembly for every entry. /// - internal static class HotReloadMethodMatcher + internal sealed class HotReloadMethodMatcher : IDisposable { + private readonly HotReloadCompiledAssemblyLoader _loadAssembly; + private readonly Dictionary _assembliesByDllPath = + new Dictionary(StringComparer.Ordinal); + private bool _disposed; + + /// + /// How a compiled image is read; production passes ReadCompiledAssembly, and tests count + /// the reads. + /// + internal HotReloadMethodMatcher(HotReloadCompiledAssemblyLoader loadAssembly) + { + if (loadAssembly == null) + { + throw new ArgumentNullException(nameof(loadAssembly)); + } + + _loadAssembly = loadAssembly; + } + + /// A matcher for one run that reads the compiled images from disk. + internal static HotReloadMethodMatcher CreateReadingFromDisk() + { + return new HotReloadMethodMatcher(ReadCompiledAssembly); + } + + /// The loader production uses: reads the whole compiled image at the path. + internal static AssemblyDefinition ReadCompiledAssembly(string dllPath) + { + // InMemory: the DLL is the currently loaded script assembly; keep no file handle on it. + ReaderParameters readerParameters = new ReaderParameters { InMemory = true }; + return AssemblyDefinition.ReadAssembly(dllPath, readerParameters); + } + /// /// Resolves on inside /// the assembly names, whose parameters and generic arity match /// and /// exactly (Cecil FullName, no this). /// - public static HotReloadMethodMatchResult Resolve( + /// The matcher was disposed: its run is over. + /// The loader returned no image. + public HotReloadMethodMatchResult Resolve( HotReloadTypeHome home, string typeMetadataName, string methodName, @@ -33,18 +83,37 @@ public static HotReloadMethodMatchResult Resolve( Debug.Assert(parameterTypeFullNames != null, "parameterTypeFullNames must not be null."); Debug.Assert(genericArity >= 0, "genericArity must not be negative."); - string dllPath = home.DllPath; - - if (!File.Exists(dllPath)) + if (_disposed) { - return HotReloadMethodMatchResult.Failure( - HotReloadMethodMatchFailureReason.CompiledAssemblyNotFound, - $"Compiled assembly not found at '{dllPath}'. Compile the project first."); + throw new ObjectDisposedException(nameof(HotReloadMethodMatcher)); } - // InMemory: the DLL is the currently loaded script assembly; keep no file handle on it. - ReaderParameters readerParameters = new ReaderParameters { InMemory = true }; - using AssemblyDefinition assemblyDefinition = AssemblyDefinition.ReadAssembly(dllPath, readerParameters); + string dllPath = home.DllPath; + // Why a read answers the rest of the run even if the file changes on disk: the token + // and the Mvid below both come from that one read, and the Mvid guard still compares + // it with the loaded assembly for every entry, so no token is applied to an assembly + // it was not read from. + // Why only a read that succeeded is kept: an image that is missing or fails to read is + // looked for again by the next entry, as every entry did before, so one that appears + // or becomes readable later in the run is still found. + if (!_assembliesByDllPath.TryGetValue(dllPath, out AssemblyDefinition assemblyDefinition)) + { + if (!File.Exists(dllPath)) + { + return HotReloadMethodMatchResult.Failure( + HotReloadMethodMatchFailureReason.CompiledAssemblyNotFound, + $"Compiled assembly not found at '{dllPath}'. Compile the project first."); + } + + assemblyDefinition = _loadAssembly(dllPath); + if (assemblyDefinition == null) + { + throw new InvalidOperationException( + $"The compiled assembly loader returned no image for '{dllPath}'."); + } + + _assembliesByDllPath.Add(dllPath, assemblyDefinition); + } TypeDefinition typeDefinition = assemblyDefinition.MainModule.GetType(typeMetadataName); if (typeDefinition == null) @@ -71,6 +140,23 @@ public static HotReloadMethodMatchResult Resolve( return ResolveLoadedMethod(home, compiledMvid, metadataToken); } + /// Releases every image this matcher read; a disposed matcher resolves nothing more. + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + foreach (AssemblyDefinition assemblyDefinition in _assembliesByDllPath.Values) + { + assemblyDefinition.Dispose(); + } + + _assembliesByDllPath.Clear(); + } + private static MethodDefinition FindMatchingMethod( TypeDefinition typeDefinition, string methodName, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs new file mode 100644 index 0000000000..ae33b28ba1 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Cecil search directories for publicizing a compilation assembly's references: the + /// directories of the assembly's own references first, then those of every assembly it + /// references transitively. Cecil resolves a referenced assembly while writing a publicized + /// copy, and a precompiled DLL that only a referenced assembly lists would otherwise be + /// unreachable (a test assembly with overrideReferences does not list the game's plugins). + /// + internal static class HotReloadResolverSearchDirectories + { + internal static IReadOnlyCollection Collect(UnityCompilationAssembly rootAssembly) + { + Debug.Assert(rootAssembly != null, "rootAssembly must not be null."); + + List orderedDirectories = new List(); + HashSet seenDirectories = new HashSet(StringComparer.Ordinal); + HashSet seenReferences = new HashSet(StringComparer.Ordinal); + HashSet visitedAssemblies = new HashSet(StringComparer.Ordinal); + Queue pending = new Queue(); + pending.Enqueue(rootAssembly); + visitedAssemblies.Add(rootAssembly.name); + + // Why breadth first: the directories of the root's own references come before anything + // a transitive reference adds, so a same-named DLL keeps resolving to the root's own copy. + while (pending.Count > 0) + { + 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); + foreach (string directory in ReferencePublicizer.CollectResolverSearchDirectories(unseenReferences)) + { + if (seenDirectories.Add(directory)) + { + orderedDirectories.Add(directory); + } + } + + foreach (UnityCompilationAssembly referenced in current.assemblyReferences) + { + if (visitedAssemblies.Add(referenced.name)) + { + pending.Enqueue(referenced); + } + } + } + + return orderedDirectories; + } + + // 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) + { + List unseenReferences = new List(); + foreach (string reference in references) + { + if (seenReferences.Add(reference)) + { + unseenReferences.Add(reference); + } + } + + return unseenReferences; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs.meta new file mode 100644 index 0000000000..9a66168ca7 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0ff1a2b4b2c0e4d648dd8829a6a30440 +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 af12b18649..19f0b7eefc 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs @@ -421,8 +421,9 @@ 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. IReadOnlyCollection resolverSearchDirectories = - ReferencePublicizer.CollectResolverSearchDirectories(compilationAssembly.allReferences); + HotReloadResolverSearchDirectories.Collect(compilationAssembly); List references = new List(); string publicizedTarget = ReferencePublicizer.GetOrCreatePublicizedCopy( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index d2b4c313e7..8fd655d591 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -49,14 +49,16 @@ internal static class HotReloadConstants // Package-relative sources compiled into the worker in addition to the tilde directory. // Why: the resident-mode line protocol, the introduced-type fingerprint, the reason code - // the worker reports and the Unity messages hot reload does not forward are shared - // verbatim between the Editor host and the worker so the two ends cannot drift apart. + // the worker reports, the Unity messages hot reload does not forward and the source-path + // matching are shared verbatim between the Editor host and the worker so the two ends + // cannot drift apart. public static readonly string[] WorkerSharedSourcePackageRelativePaths = { "Editor/FirstPartyTools/HotReload/Shared/TransformWorkerServeProtocol.cs", "Editor/FirstPartyTools/HotReload/Shared/HotReloadIntroducedTypeFingerprint.cs", "Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs", - "Editor/FirstPartyTools/HotReload/Shared/HotReloadNotForwardedUnityMessageNames.cs" + "Editor/FirstPartyTools/HotReload/Shared/HotReloadNotForwardedUnityMessageNames.cs", + "Editor/FirstPartyTools/HotReload/Shared/HotReloadSourcePathMatching.cs" }; public const string WorkerDllFileName = "worker.dll"; @@ -504,6 +506,11 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE // Format: how many method outcomes of this run were Skipped. public const string SkippedCountApplyMessageSuffixFormat = " Skipped: {0}."; + // Format: how many method outcomes of this run were Skipped, and how many of those belong + // to sibling files the run re-applied on its own. + public const string SkippedCountWithSiblingRowsApplyMessageSuffixFormat = + " Skipped: {0} ({1} in sibling files the run re-applied on its own; Outcome does not count those)."; + // Format: how many types this run introduced. public const string IntroducedTypesOnlyApplyMessageFormat = "Hot reload introduced {0} type(s); no method body needed patching."; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs new file mode 100644 index 0000000000..ae4296a6f7 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Mono.Cecil; +using Mono.Cecil.Cil; +using Mono.Cecil.Pdb; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One source document a portable PDB names, with the checksum the compiler recorded for it. + /// + internal readonly struct HotReloadPdbDocument + { + public readonly string Url; + public readonly DocumentHashAlgorithm HashAlgorithm; + public readonly byte[] Hash; + + public HotReloadPdbDocument(string url, DocumentHashAlgorithm hashAlgorithm, byte[] hash) + { + Url = url; + HashAlgorithm = hashAlgorithm; + Hash = hash; + } + } + + /// + /// Keeps, per compiled assembly, the documents its sequence points refer to, so finding the + /// document of one source file does not read the dll and the PDB and walk every sequence + /// point again on each hot reload run. The files only change on a compile. A list is read + /// 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. + /// + internal sealed class HotReloadPdbDocumentIndex + { + // Identity of the files a list was read from. Why all five: the length and the write time + // are the cheap check for each file, and the MVID catches a dll rewritten to the same size + // within one timestamp tick, as in HotReloadCompiledCallSiteCache. + private readonly struct FileStamp : IEquatable + { + public readonly long DllLength; + public readonly long DllLastWriteTimeUtcTicks; + public readonly string ModuleVersionId; + public readonly long PdbLength; + public readonly long PdbLastWriteTimeUtcTicks; + + public FileStamp( + long dllLength, + long dllLastWriteTimeUtcTicks, + string moduleVersionId, + long pdbLength, + long pdbLastWriteTimeUtcTicks) + { + DllLength = dllLength; + DllLastWriteTimeUtcTicks = dllLastWriteTimeUtcTicks; + ModuleVersionId = moduleVersionId; + PdbLength = pdbLength; + PdbLastWriteTimeUtcTicks = pdbLastWriteTimeUtcTicks; + } + + public bool Equals(FileStamp other) + { + return DllLength == other.DllLength + && DllLastWriteTimeUtcTicks == other.DllLastWriteTimeUtcTicks + && string.Equals(ModuleVersionId, other.ModuleVersionId, StringComparison.Ordinal) + && PdbLength == other.PdbLength + && PdbLastWriteTimeUtcTicks == other.PdbLastWriteTimeUtcTicks; + } + + public override bool Equals(object obj) + { + return obj is FileStamp other && Equals(other); + } + + public override int GetHashCode() + { + return StringComparer.Ordinal.GetHashCode(ModuleVersionId); + } + } + + private sealed class Entry + { + public readonly FileStamp Stamp; + public readonly List Documents; + + public Entry(FileStamp stamp, List documents) + { + Stamp = stamp; + Documents = 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(); + + private readonly object _gate = new object(); + private readonly Dictionary _entries = new Dictionary(StringComparer.Ordinal); + private int _loadCount; + + /// + /// Number of times a dll and its PDB were read and walked. + /// + internal int LoadCount + { + get + { + lock (_gate) + { + return _loadCount; + } + } + } + + /// + /// Finds the document whose url names among the + /// documents a sequence point refers to. False when no sequence point refers to such a + /// document, which is the case for a file without a method body: the PDB still lists that + /// file, but only a document some code maps to is one a method can be verified against. + /// is the dll's MVID as the caller already read it. + /// + internal bool TryFindDocument( + string dllPath, + string pdbPath, + string moduleVersionId, + string projectRelativePath, + out HotReloadPdbDocument document) + { + 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(!string.IsNullOrEmpty(projectRelativePath), "projectRelativePath must not be null or empty."); + + string fullDllPath = Path.GetFullPath(dllPath); + FileStamp stamp = ReadStamp(fullDllPath, pdbPath, moduleVersionId); + lock (_gate) + { + if (!_entries.TryGetValue(fullDllPath, out Entry entry) || !entry.Stamp.Equals(stamp)) + { + // 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++; + _entries[fullDllPath] = entry; + } + + return TryFind(entry.Documents, projectRelativePath, out document); + } + } + + private static FileStamp ReadStamp(string fullDllPath, string pdbPath, string moduleVersionId) + { + FileInfo dll = new FileInfo(fullDllPath); + FileInfo pdb = new FileInfo(pdbPath); + return new FileStamp( + dll.Length, + dll.LastWriteTimeUtc.Ticks, + moduleVersionId, + pdb.Length, + pdb.LastWriteTimeUtc.Ticks); + } + + // The first document, in the order the walk met them, whose url names the path: the same + // document a walk that stopped at the first matching sequence point returns. + private static bool TryFind( + List documents, + string projectRelativePath, + out HotReloadPdbDocument document) + { + foreach (HotReloadPdbDocument candidate in documents) + { + if (HotReloadSourcePathNormalizer.PathsReferToSameFile(candidate.Url, projectRelativePath)) + { + document = candidate; + return true; + } + } + + document = default(HotReloadPdbDocument); + 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) + { + 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(); + // 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()) + { + 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; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs.meta new file mode 100644 index 0000000000..76ff89a977 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bb35116f165dd4343b5c5da77e0ea128 +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 6c7f092908..85e5985842 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs @@ -4,9 +4,7 @@ using System.Security.Cryptography; using System.Text; -using Mono.Cecil; using Mono.Cecil.Cil; -using Mono.Cecil.Pdb; using UnityEngine; @@ -27,20 +25,27 @@ public static string LoadVerifiedSnapshotSource(string projectRelativeSourcePath Debug.Assert(!string.IsNullOrEmpty(targetDllPath), "targetDllPath must not be null or empty."); string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - return LoadVerifiedSnapshotSourceAt(projectRoot, projectRelativeSourcePath, targetDllPath); + return LoadVerifiedSnapshotSourceAt( + projectRoot, + projectRelativeSourcePath, + targetDllPath, + HotReloadPdbDocumentIndex.Shared); } // projectRoot is injectable so EditMode tests can point at a tampered snapshot tree - // without expanding the public API surface. + // without expanding the public API surface, and documentIndex so they can count how + // often the PDB is read. internal static string LoadVerifiedSnapshotSourceAt( string projectRoot, string projectRelativeSourcePath, - string targetDllPath) + string targetDllPath, + HotReloadPdbDocumentIndex documentIndex) { TryLoadVerifiedSnapshotSource( projectRoot, projectRelativeSourcePath, targetDllPath, + documentIndex, out string source); return source; } @@ -50,26 +55,33 @@ internal static string LoadVerifiedSnapshotSourceAt( /// /// /// Why a second lookup rather than a richer load result: the loader has several callers - /// that only need the text, and only the missing-baseline warning needs the reason, so the - /// cost of reading the PDB again is paid on that rare path alone. + /// that only need the text, and only the missing-baseline warning needs the reason. The + /// second lookup reuses the document list the first one kept, so it does not read the PDB + /// again. /// internal static HotReloadSnapshotMissReason DescribeSnapshotMiss( string projectRelativeSourcePath, string targetDllPath) { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - return DescribeSnapshotMissAt(projectRoot, projectRelativeSourcePath, targetDllPath); + return DescribeSnapshotMissAt( + projectRoot, + projectRelativeSourcePath, + targetDllPath, + HotReloadPdbDocumentIndex.Shared); } internal static HotReloadSnapshotMissReason DescribeSnapshotMissAt( string projectRoot, string projectRelativeSourcePath, - string targetDllPath) + string targetDllPath, + HotReloadPdbDocumentIndex documentIndex) { return TryLoadVerifiedSnapshotSource( projectRoot, projectRelativeSourcePath, targetDllPath, + documentIndex, out string _); } @@ -77,11 +89,13 @@ private static HotReloadSnapshotMissReason TryLoadVerifiedSnapshotSource( string projectRoot, string projectRelativeSourcePath, string targetDllPath, + HotReloadPdbDocumentIndex documentIndex, out string source) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(projectRelativeSourcePath), "projectRelativeSourcePath must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(targetDllPath), "targetDllPath must not be null or empty."); + Debug.Assert(documentIndex != null, "documentIndex must not be null."); source = null; string pdbPath = Path.ChangeExtension(targetDllPath, ".pdb"); @@ -107,8 +121,12 @@ 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); - Document document = FindDocumentForProjectRelativePath(targetDllPath, pdbPath, slashNormalizedRelativePath); - if (document == null) + if (!documentIndex.TryFindDocument( + targetDllPath, + pdbPath, + mvid, + slashNormalizedRelativePath, + out HotReloadPdbDocument document)) { return HotReloadSnapshotMissReason.NoDocumentInPdb; } @@ -132,58 +150,6 @@ private static HotReloadSnapshotMissReason TryLoadVerifiedSnapshotSource( return HotReloadSnapshotMissReason.None; } - private static Document FindDocumentForProjectRelativePath( - string dllPath, - string pdbPath, - string projectRelativePath) - { - 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); - 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 (HotReloadSourcePathNormalizer.PathsReferToSameFile( - sequencePoint.Document.Url, - projectRelativePath)) - { - return sequencePoint.Document; - } - } - } - } - - return null; - } - private static byte[] ComputeDocumentHash(DocumentHashAlgorithm algorithm, byte[] sourceBytes) { switch (algorithm) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourcePathMatching.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourcePathMatching.cs new file mode 100644 index 0000000000..8cb9bf8a2f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourcePathMatching.cs @@ -0,0 +1,64 @@ +// This file is compiled twice: into the Unity editor assembly (host side) and into the +// out-of-process transform worker (see TransformWorkerBootstrap.CollectWorkerSourcePaths). +// It must therefore stay free of Unity, Newtonsoft and Roslyn references. +using System; +using System.IO; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Matches an absolute source path against a project-relative one regardless of the + /// directory separator, so a worker input built on Windows compares the same way. + /// + internal static class HotReloadSourcePathMatching + { + // True when absolutePath is projectRelativePath under some root. + internal static bool EndsWithProjectRelativePath(string absolutePath, string projectRelativePath) + { + if (string.IsNullOrEmpty(absolutePath) || string.IsNullOrEmpty(projectRelativePath)) + { + return false; + } + + string absolute = absolutePath.Replace('\\', '/'); + string relative = projectRelativePath.Replace('\\', '/').TrimStart('/'); + if (absolute.Length <= relative.Length) + { + return false; + } + + // Why the separator check: "Other.cs" must not match "AnOther.cs". + return absolute.EndsWith("/" + relative, PathComparison()); + } + + // The project-relative form of otherAbsolutePath, given one absolute path whose + // project-relative form is known; null when the two do not share that root. + internal static string ToProjectRelativeOrNull( + string otherAbsolutePath, + string knownAbsolutePath, + string knownProjectRelativePath) + { + if (!EndsWithProjectRelativePath(knownAbsolutePath, knownProjectRelativePath)) + { + return null; + } + + string known = knownAbsolutePath.Replace('\\', '/'); + string relative = knownProjectRelativePath.Replace('\\', '/').TrimStart('/'); + // Ends with '/', because EndsWithProjectRelativePath matched at a separator. + string root = known.Substring(0, known.Length - relative.Length); + string other = otherAbsolutePath.Replace('\\', '/'); + if (!other.StartsWith(root, PathComparison())) + { + return null; + } + + return other.Substring(root.Length); + } + + private static StringComparison PathComparison() + { + return Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourcePathMatching.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourcePathMatching.cs.meta new file mode 100644 index 0000000000..67b5d2fb56 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourcePathMatching.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9603eb2940de443dca10c07414104b8d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs new file mode 100644 index 0000000000..b0e9e8a45e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs @@ -0,0 +1,41 @@ +using System; + +using UnityEngine; + +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. + /// + internal sealed class HotReloadSourceSnapshotCapture + { + private readonly Action _capture; + private bool _captured; + + internal HotReloadSourceSnapshotCapture(Action 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. + /// + internal void EnsureCaptured() + { + if (_captured) + { + return; + } + + _capture(); + // 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. + _captured = true; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs.meta new file mode 100644 index 0000000000..03de02f0a9 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eb33669c09360491d9a66802db3e1014 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs new file mode 100644 index 0000000000..6b9655ead5 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs @@ -0,0 +1,72 @@ +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. + /// + 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); + } + + internal static string DescribeMissingCompiledAssembly(string projectRoot, string dllPath) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); + + if (!IsVirtualPlayerProjectRoot(projectRoot)) + { + return "Compiled assembly not found at '" + dllPath + "'. Compile the project first."; + } + + // 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. + return "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."; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs.meta new file mode 100644 index 0000000000..b3a1270a40 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b89d5225b34994bd3bc23f66877431a7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs index a955d4d36c..df6cec0dd1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs @@ -18,7 +18,10 @@ internal enum HotReloadWorkerReasonCode MethodTransformBaseMemberCall, MethodTransformClosureInaccessibleAccess, MethodTransformAsyncIteratorInaccessibleAccess, - MethodTransformPartialType, + MethodTransformPartialOtherPartChanged, + MethodTransformPartialOtherPartsUnverified, + MethodTransformPartialBodyUnbound, + MethodTransformUnpassedInternalMemberOutOfReach, MethodTransformStructHost, MethodTransformGenericMethodOrType, MethodTransformExplicitInterfaceImplementation, @@ -26,6 +29,7 @@ internal enum HotReloadWorkerReasonCode MethodTransformSiblingBodyUnbound, AddedMethodVirtualOrAbstract, AddedMethodGeneric, + AddedMethodSignatureTypeUnresolved, AddedMethodMethodGroupReference, AddedMethodMethodGroupSubscription, AddedMethodMethodGroupUnsubscription, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AddedMemberTemplates.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AddedMemberTemplates.cs index ea225cfe62..ba4a3a287a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AddedMemberTemplates.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AddedMemberTemplates.cs @@ -20,6 +20,16 @@ private static void AddAddedMemberTemplates( Plain( "Added generic methods are skipped; hot reload cannot emit a typed shim for them.", 0).EndingWith(CompileCallToAction)); + // Why no compile call to action is appended: the sentence already ends with the compile + // as the step after passing the file that declares the type. + templates.Add( + HotReloadWorkerReasonCode.AddedMethodSignatureTypeUnresolved, + Plain( + "The method signature names a type the hot-reload compilation could not resolve " + + "('{0}'), so hot reload cannot tell whether this method already exists in the " + + "compiled assembly. If the type is declared in another file of this edit, pass " + + "that file with --files too; otherwise run 'uloop compile'.", + 1)); // Why each method-group reason names a step before the compile: a call to an added // method is rewritten, so a lambda applies without leaving Play Mode, and the compile // the reason used to name alone sent readers out of Play Mode for an edit that applies. @@ -100,7 +110,9 @@ private static void AddAddedMemberTemplates( "The added member's body could not be fully bound in the hot-reload compilation ({0}); " + "hot reload cannot verify a member it cannot bind, so it is skipped. If the name is " + "declared in a new file, pass that file to --files too (new files are not selected " - + "automatically); run 'uloop compile' only if it still does not bind.", + + "automatically). If the name is generated at compile time, for example by a source " + + "generator, hot reload cannot see it: run 'uloop compile'. Also run 'uloop compile' if " + + "it still does not bind.", 1)); // Why the next step is not worded here: which step works (pass the file, leave it // out, undo the edit and leave it out, or compile) depends on how the file entered the diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs index 9559dccaf0..2d77bcc13d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs @@ -35,10 +35,38 @@ private static void AddMethodTransformTemplates( AccessorRewriteUnavailableSeparator, string.Empty)); templates.Add( - HotReloadWorkerReasonCode.MethodTransformPartialType, + HotReloadWorkerReasonCode.MethodTransformPartialOtherPartChanged, Plain( - "Partial types are skipped because a single file cannot provide a complete semantic model.", + "Another part of this partial type changed since the last compile ({0}), so hot reload " + + "cannot bind this method against the compiled type. Pass that file with --files too, " + + "or run 'uloop compile'.", + 1)); + templates.Add( + HotReloadWorkerReasonCode.MethodTransformPartialOtherPartsUnverified, + Plain( + "The other parts of this partial type could not be checked against the last compile " + + "(no source snapshot for the assembly, or too many changed files to scan), so hot " + + "reload cannot bind this method against the compiled type. Run 'uloop compile'.", 0)); + templates.Add( + HotReloadWorkerReasonCode.MethodTransformPartialBodyUnbound, + Plain( + "{0}. None of this partial type's source files known to hot reload declares that name: " + + "a part generated at compile time is not visible to it, and a file added since the " + + "last compile must be passed with --files. Otherwise run 'uloop compile'.", + 1)); + templates.Add( + HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberOutOfReach, + Plain( + "{0}. That member is internal to {1}, whose source this reload was not given. Hot reload patches " + + "a use of such a member only where it is a field, a property or a method call written with its " + + "receiver ('this.Name', 'Type.Name', 'value.Name') in the method's own statements: not a bare " + + "name, a method passed as a delegate, or a use inside a lambda, local function, query, iterator " + + "or async method, or in a body patched through a delegating shim. A lambda, local function or " + + "query that works with a value hot reload could not resolve, such as the member's result, keeps " + + "the whole body out as well. Qualify a bare name with 'this.' or the type name, or run " + + "'uloop compile'.", + 2)); templates.Add( HotReloadWorkerReasonCode.MethodTransformStructHost, Plain( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerDtos.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerDtos.cs index f98808ebdb..94b4fcd59c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerDtos.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerDtos.cs @@ -39,6 +39,11 @@ internal sealed class TransformWorkerInputDto // Null/omitted is treated as empty. public string[] changedSiblingSourcePaths; + // True only when changedSiblingSourcePaths is the complete list: the snapshot directory was + // scanned and the list was not truncated. False/omitted makes the worker treat the other + // parts of every partial type as unverified. + public bool changedSiblingScanComplete; + // Retained introduced-type assemblies the worker may bind against. Each record carries the // complete identity and the reference path together, so the worker can confirm the file it // resolved really is the assembly the record claims before it normalizes anything through @@ -367,8 +372,8 @@ internal sealed class TransformWorkerEntryDto public int sourceStartLine; public int sourceEndLine; - // Null/empty when the method is not a one-shot lifecycle method and is not only called - // from them inside this file. + // Null/empty unless the method is a one-shot lifecycle method or an added method with a + // test attribute. public string lifecycleNote; // True when this addedMethod entry replaces a compiled method whose return type changed. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md index 865e43479e..78ea33a883 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md @@ -12,6 +12,8 @@ member access, static methods, return values, async methods, and iterators all w the limits below, including private access inside async, iterator, lambda, local-function, and LINQ-query bodies. Methods that cannot be patched are reported as `Skipped` or `Failed`; one unpatchable method never aborts the rest of the run. +`Outcome` says whether the edit is live (`ReplacedByCompile`: a fallback compile succeeded, so +it is compiled in and no patch is active). ## Usage @@ -106,4 +108,4 @@ All files live in `references/` beside this skill; read the one whose trigger ma - `references/pause-point-interaction.md` — how patches re-target or suppress armed pause points; one-way reachability checks. - `references/introduced-types.md` — types a reload can introduce: supported shapes, internal access, refusal wording, identity and lifetime, why a new file is never selected automatically. - `references/added-field-wiring.md` — putting a value into an added field without a compile. -- `references/output.md` — every response field: `ErrorCode`, `NextActions`, `Methods` rows, `Warnings`, totals. +- `references/output.md` — "Is my edit live?" (`Outcome`), `Timing`, every response field: `ErrorCode`, `NextActions`, `Methods` rows, `Warnings`, totals. 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 60d403d48b..c0be60faa4 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 @@ -3,7 +3,7 @@ ## How a Reload Applies 1. Resolves each file to its compiled assembly via `CompilationPipeline`, then groups the files by that assembly. Each group runs the worker once and produces one shim assembly, so the members one file adds are visible to the bodies edited in its siblings; groups are processed one after another and never affect each other. -2. Rewrites each editable method body of the group into a static shim in an out-of-process Roslyn worker. When an async, iterator, lambda, local-function, or LINQ-query body touches private/internal members, those accesses are rewritten to accessor delegates so the body can compile and run from the shim assembly (the delegation shape in step 4). +2. Rewrites each editable method body of the group into a static shim in an out-of-process Roslyn worker. When an async, iterator, lambda, local-function, or LINQ-query body touches private/internal members, those accesses are rewritten to accessor delegates so the body can compile and run from the shim assembly (the delegation shape in step 4). An `internal` member of a type the reload was not given cannot be rewritten this way, so such a body is `Skipped` (`scope-and-limits.md`). 3. Compiles the group's shims into one assembly against publicized reference copies, loads the result into the Editor domain, and binds every shim type's accessor delegates (`__BindAccessors`) before any patch is applied. 4. Patches each original method with a Harmony transpiler (ID `io.github.hatayama.uloop.hot-reload`) in one of two shapes: transplant copies the shim's IL into the original method, while delegation rewrites the original to forward its arguments to the shim, which runs as normally compiled code. @@ -24,7 +24,7 @@ patch binds to the newest shim. Edit the file and reload again to apply new chan behavior converges by construction. - Patches and loaded shim assemblies are static Editor state and disappear on the next domain reload — that includes entering Play Mode with Domain Reload enabled (the - default for projects created before Unity 6.6), `uloop compile`, and `uloop run-tests`. `uloop control-play-mode --action Play` warns with + 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. - Never reflected by hot reload: initializer changes on compiled fields and new @@ -42,6 +42,19 @@ patch binds to the newest shim. Edit the file and reload again to apply new chan before any method of that assembly is transformed, so the run applies no method body from any file of that assembly; files in other assemblies still apply. +## Running tests while patches are live + +- `uloop run-tests` compiles first by default. That compile reloads the domain and drops every live + patch; the edited source is compiled in, so the code changes stay without re-applying them, but + values wired into fields that hot reload added are not kept (they return to their initializer or + default values). The response's `CompileNote` carries the count, in the form + `1 active hot-reload change(s) were live when this compile was requested.` +- `--skip-compile` runs the tests against the live patches; added `[Test]` methods are not enumerated + that way, so compile first for those. Its `Warning` is a fixed notice that changes were live when the + run started and does not say whether they survived; read `uloop hot-reload --status` for that. While + hot reload holds Auto Refresh (the state an apply leaves), a verified run kept the patches live. +- There is no default switch: choose per call. + ## Editor-Code Iteration Without PlayMode Hot reload also patches static methods in Editor assemblies. Combined with diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md index b40dd86a84..586599bc4e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md @@ -1,18 +1,35 @@ # Hot Reload Output Fields +## Is my edit live? Read `Outcome` first + +- `Applied` — the edits of the files you asked about are live (`Patched`, `Added`, or `AlreadyActive` rows, or introduced types), and none was `Skipped`. Nothing else to do. +- `PartiallyApplied` — some of them are live, some were `Skipped`; read the `Methods[]` rows with `Kind: "Skipped"`. +- `NothingApplied` — none of them is live, and some were `Skipped`. `Warnings` (or `Methods[].Reason`) says why. +- `NothingToApply` — nothing changed against the compiled code (see `UnchangedTotal`), the files hold no method bodies, or their only rows are `Stale`. +- `Failed` — at least one `Failed` row, so `Success` is `false` — unless a fallback compile then succeeded, which turns the whole answer into `ReplacedByCompile`. +- `ReplacedByCompile` — a fallback compile ran in this same command and succeeded (`Compile` holds its response): every edit is compiled in, and `Success` is the compile's. The compile reloaded the domain, so none of this run's patches survive; the totals and `Methods[]` describe the reload that ran before the compile — read `--status` for the state after it. + +`Outcome` is written on apply runs only and judges the files you asked about; rows of a sibling file the run re-applied on its own (`ReappliedFromSibling: true`) do not change it, while `CompileFallback` may still be `Requested` for a retried sibling row. The totals beside it (`PatchedTotal`, `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal`) count every `Methods[]` row by `Kind`, siblings included. When some `Skipped` rows are sibling rows, `Message` says how many (`Skipped: 3 (2 in sibling files …)`). + +## Fields + Returns JSON with: - `Success` (boolean): `false` on parameter validation failure or when any method outcome is `Failed`, or when any `IntroducedTypes` row is `Failed`. `Skipped` outcomes alone never force `false` - `ErrorCode` (string, optional): Present on parameter validation failure. Values are `HOT_RELOAD_FILES_REQUIRED` when an omitted apply has no compile snapshots, `HOT_RELOAD_NO_CHANGED_FILES` when snapshots contain no changed `.cs` files, `HOT_RELOAD_INVALID_FILES` when `--files` contains a null or empty path, and `HOT_RELOAD_STATUS_CONFLICT` when `--status` is combined with `--files` or `--revert-all`. - `NextActions` (array, optional): Ordered recovery steps, present only with `ErrorCode` on a parameter validation failure. Omitted from every other response, including successful apply, plain `--status`, and `--revert-all` runs. -- `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` +- `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. On an `Added` row whose method carries a test attribute (`[Test]`, `[UnityTest]`, `[SetUp]`, and the like), `LifecycleNote` says the Unity Test Runner will not discover it until `uloop compile`. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` - `Warnings` (array): Non-fatal notes — one aggregated line listing the patched methods at risk of being already JIT-inlined into existing callers — those marked `[AggressiveInlining]`, plus (only when Code Optimization is Release) those with tiny pre-patch bodies — meaning the change may not show at those call sites, the pause-point interaction (see [pause-point-interaction.md](pause-point-interaction.md)), and the const drift, outside-body drift, missing-baseline, and left-out enum file entries described in [scope-and-limits.md](scope-and-limits.md). Skipped outcomes are echoed here as `Skipped : `, or as one `Skipped N methods: ()` line per reason when several share it, so checking `Warnings` alone is enough to see that an edit was not applied. When a reload re-applies unchanged files so their patches bind to this run's shim, Warnings includes `Also re-applied N unchanged file(s) with active patches in assembly '...' so their patches bind to this reload's shim: ...`. When a pulled-in sibling fails in that reload, Warnings includes `'...' was pulled in to re-bind its active patches but this reload failed for it; see its rows for which patches changed and run uloop compile to clear the run.` instead of the re-applied line. When every row of that sibling was `Skipped` and none failed, Warnings instead includes `'...' was pulled in to re-bind its active patches, but every method there was Skipped this time; see its rows for the reasons. Any earlier patches there stay active until uloop compile clears the run.` When the reload stopped before re-applying anything at all, so that sibling has no rows, Warnings instead includes `'...' was pulled in to re-bind its active patches, but this reload stopped before re-applying them, so its active patches are unchanged. Fix the refused declaration and rerun, or run uloop compile to clear the run.` When the whole reload was refused, so that sibling's only rows are `Method` = `(file)` `Failed` rows repeating the refusal and none of its unchanged patches were reverted, Warnings instead includes `'...' was pulled in to re-bind its active patches, but the whole reload was refused before re-applying them, so its active patches are unchanged; its rows repeat the refusal reason. Fix that and rerun, or run uloop compile to clear the run.` When a sibling still has active patches but its source changed since they were applied, Warnings includes `'...' has active patches but its source changed since they were applied, so it was not re-applied; pass it to hot-reload to update it.` When a patch or added member that an earlier reload applied still calls an added member that is no longer registered — a later reload changed its signature, deleted it, or skipped it while the caller did not apply again — Warnings includes `Methods that earlier hot reloads patched or added still call added members that are no longer registered: calls , .... Those calls still run the members' earlier bodies, which match neither the compiled assembly nor the source on disk. Reload until the calling methods apply again, or run 'uloop compile'.` on every reload that includes the caller's file or the member's file; see [troubleshooting.md](troubleshooting.md). When a run carries two or more warnings and all of them are hot reload warnings, the Message ends with "A single 'uloop compile' clears all of them at once when you want them gone; none of them has to be cleared before you keep working." — it is the shortest recovery, not an obligation to compile immediately. Pause-point warnings carry their own recovery steps, so that line does not appear when they are present. Nor does it appear when an `IntroducedTypes` row is `Failed` or a warning says a declared type requires a compile, because that type does not exist until one. It is also left off when any `Methods` row is `Failed`, or when a `Methods` row of a file you passed, or of a sibling retried after an earlier Skip, is `Skipped`: that body is not running yet, so it needs a fix or a compile before you keep working. A `Skipped` row of a sibling pulled in only to re-bind its active patches does not leave it off, because the earlier patches there keep running. +- `Outcome` (string, apply runs only): `Applied`, `PartiallyApplied`, `NothingApplied`, `NothingToApply`, `Failed`, or `ReplacedByCompile` — whether the edits of the files you asked about are live; see "Is my edit live?" above. Omitted on `--status`, `--revert-all`, and validation failures. - `PatchedTotal` (number): Methods patched in this run +- `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal` (number): `Methods[]` rows of this run whose `Kind` is `Skipped`, `Added`, `Failed`, `AlreadyActive`, or `Stale`, sibling rows included, as in `PatchedTotal`. `0` on `--status`, `--revert-all`, and validation failures. - `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. It is always empty on `--status` and `--revert-all` runs; the live list is the `Methods` rows with `Kind` `AddedField`, counted by `AddedFieldTotal`. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. - `AddedConsts` (array): source-level names ("Type.const") of consts this reload added. They are folded into edited bodies as literals, so they are not listed in AddedFields and do not emit the added-field lifetime warning. - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `ActivePatchTotal` (number): Active changes after this run — patched methods plus added members. Introduced types are not counted here; `ActiveIntroducedTypeTotal` reports those. `--revert-all` clears the patched methods and added members counted here and reports their combined count in `ClearedCount`; introduced types stay loaded until the next Domain Reload and remain in `ActiveIntroducedTypeTotal`. Does not include `AddedField` rows. Validation failures (`HOT_RELOAD_NO_CHANGED_FILES` and the other `ErrorCode` cases) also report the live ledger value, not the default 0. - `AutoRefreshHeld` (boolean): True while Auto Refresh is held because at least one hot-reload change is still active. The first apply that arms the hold appends a Message sentence telling the caller to run `uloop compile` to release it, and that `--revert-all` releases it only when no introduced type remains — a revert cannot unload the assembly carrying an introduced type. A release during Play adds a Warning that pending script edits import on the next focus return or `uloop compile`. If the post-release Refresh is skipped because an open dirty scene also changed on disk, Warnings include the sentence telling the caller to resolve that scene and then run `uloop compile`. `--status` and `--revert-all` report the live value. +- `AutoRefreshHoldMessage` (string, optional): The hold sentence this run appended to `Message` when it armed the hold; omitted when the run did not arm it. After a successful fallback compile, the CLI removes that sentence from `Message` and keeps this field as the record of what it removed. +- `Timing` (object, apply runs only): milliseconds this run spent per phase — `AnalysisMs` (transform worker runs, introduced-type preparation included), `ShimCompileMs` (signature gate and shim compile, isolation retries included), `PatchMs` (patch application), `OtherMs` (`TotalMs` minus the three phases: file resolution, planning, and the checks that find unchanged methods), `TotalMs` (the whole run inside the Editor). When the CLI ran the fallback compile, it adds `FallbackCompileMs`, the wall time of that compile, success or failure. Omitted on `--status` and `--revert-all`. - `AddedFieldTotal` (number): Live added-field ledger rows after this run or on `--status`, added field-like events included. Those rows appear as `Kind` `AddedField` on `--status` only; they are not counted in `ActivePatchTotal` - `DroppedByPlayModeEntryCount` (number): Remaining patched-method, added-member, and introduced-type identities discarded by the Play-entry domain reload that have not been recovered by a later apply (`Patched` / `Added` methods, `Introduced` / `AlreadyActive` types — a recovered type also recovers the patches and added members inside it), `--revert-all`, or a successful compile. Omitted when the count is 0. Re-apply `uloop hot-reload`, or edit the files and run `uloop compile` - `RestoredWiredValueCount` (number, `--status` only): Values written through the added-field wiring call that the last scene reload (entering or leaving Play Mode with domain reload disabled) gave back to the rebuilt objects. It also counts the values `--status` itself gave back by reading them for a host that is back at its place. Omitted when 0. See [added-field-wiring.md](added-field-wiring.md) @@ -20,8 +37,8 @@ Returns JSON with: - `ClearedCount` (number): Patches removed by `--revert-all`, or stale patches reverted because their source matched the compiled baseline again - `IntroducedTypes` (array): Per-type `{ Kind, TypeName, AssemblyName, FilePath, Reason }` rows for the type declarations a reload met, always present and empty when there are none. On apply runs `Kind` is `Introduced` (this reload compiled the declaration into a retained assembly and it is now loaded), `AlreadyActive` (the declaration is bound from an assembly an earlier reload retained, so this reload introduced nothing for it), or `Failed` (the declaration was refused — a redefinition of a type already active, the same type declared in more than one file of the group, or a failed artifact compilation; `Reason` says which, and a `Failed` row alone makes `Success` false). On `--status` every row is `Kind` `Active` and lists a type this domain still holds. Declarations a reload simply cannot introduce are reported as `Warnings`, not rows. These rows are never counted in `PatchedTotal`, `ActivePatchTotal`, `AddedFieldTotal`, or `ClearedCount` - `ActiveIntroducedTypeTotal` (number): Introduced types this domain holds after this run or on `--status`, counted per type rather than per compiled artifact; always present and `0` when there is none. `--revert-all` cannot unload them, so its Message says how many stay loaded until the next Domain Reload, and that Auto Refresh stays held for them until `uloop compile` -- `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` +- `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 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. (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. -- `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` and ends `Message` with a sentence saying the compile succeeded; a failed one 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. +- `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/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md index 86a71ae1f0..be4772af36 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 @@ -9,6 +9,8 @@ implementations) are never scanned: **edits** to them produce **no per-method entry at all** and are silently not applied — use `uloop compile` for those. Adding a constructor, operator, or explicit event accessor is reported as `Skipped` as well, same as an edit to an existing one. +Methods and property getters of `partial` types are patched like any other; the +other parts of the type are read from the assembly's source files. ## Added methods and fields @@ -175,7 +177,10 @@ baseline is available (next paragraph), other outside-body drift — existing-fi initializers, attributes, and other declaration edits — is reported as a `Warnings` entry as well (handled added members and reported removed members are excluded from this generic warning); without a baseline it stays silent. Either way, use -`uloop compile` for such edits. +`uloop compile` for such edits. The comparison is against the source the loaded +assembly was compiled from, so an earlier edit outside method bodies keeps this +warning on every reload until `uloop compile`. Comment-only edits (line, block, and +XML documentation comments) do not count. ## Signature changes: return type, rename, parameters @@ -396,7 +401,12 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | Condition | Why | |-----------|-----| -| Method on a `partial` type (including a type nested inside a partial outer type) | A single file cannot provide a complete semantic model | +| Method on a `partial` type when another part of the type changed since the last compile and was not passed, or when a file that names the type has syntax errors (passed or not) | Hot reload binds against the compiled type; pass that file with `--files` too, or run `uloop compile`. For a file with syntax errors, fix it and run hot reload again | +| Method on a `partial` type when the other parts could not be checked against the last compile (no source snapshot yet, or more than 50 changed files in the assembly) | Run `uloop compile` | +| Method on a `partial` type whose body names a member no source file of the assembly declares | A part generated at compile time (a source generator's output) is not visible to hot reload; run `uloop compile` | +| Method or getter whose body uses an `internal` member of a type the reload was not given, by its bare name, inside a lambda, local function, query, iterator or async method, in a body where a lambda, local function or query works with a value hot reload could not resolve (such as the member's result), or in a body patched through a delegating shim | Hot reload reaches such a member only in the method's own statements, written with its receiver (`this.Name`, `Type.Name`, `value.Name`); qualify a bare name, or run `uloop compile` | +| Method or getter on a `partial` type whose body passes an `internal` method of such a type as a delegate, uses an `internal` event of it, or names an `internal` member of it in an object initializer or a property pattern | On a `partial` type hot reload patches such a member only as a field, a property or a method call written with its receiver; run `uloop compile` | +| Method or getter on a `partial` type whose body uses an `internal` member of a type in another assembly (through `InternalsVisibleTo`) | Reported as a name no source file of the `partial` type declares; hot reload does not patch this use from a `partial` type yet. Run `uloop compile` | | Method on a struct (value type) | Value-type patching is out of scope | | Generic method, or method on a generic type | Harmony cannot safely patch open generics | | Explicit interface implementation | Dotted metadata names cannot be expressed as shim identifiers | @@ -420,6 +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) | | Loaded assembly differs from the one on disk (pending compile) | Run `uloop compile` first, then retry | | Source file fails to parse | Per-file `Failed` entry with `Method` = `(file)` carrying the parse errors; nothing from that file is applied, its earlier patches stay active, and `Success` is false | | Method signature not found in the loaded assembly | Usually a stale assembly; run `uloop compile`. In-file renames and signature changes are classified as added members before reaching this point | diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedPropertyAccessorGuard.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedPropertyAccessorGuard.cs index eb82affc42..f881ea2ced 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedPropertyAccessorGuard.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedPropertyAccessorGuard.cs @@ -146,7 +146,8 @@ private static WorkerReason EvaluateAccessor( typeState.SourceUnit.SemanticModel, typeState.CompiledType, addedMemberAccess: null, - typeState.AddedEvents); + typeState.AddedEvents, + typeState.SourceUnit.PartialTypeParts); if (decision.SkipReason != null) { return decision.SkipReason; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/CompiledSignatureSplitCollector.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/CompiledSignatureSplitCollector.cs index 6056c84145..a82d55460b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/CompiledSignatureSplitCollector.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/CompiledSignatureSplitCollector.cs @@ -21,10 +21,13 @@ internal static CompiledSignatureSplit Collect( IReadOnlyList bindingErrorSpans, IntroducedTypeArtifactMap artifactMap, IAssemblySymbol targetAssembly, - IReadOnlyDictionary projectRelativePathsByBindingTree) + IReadOnlyDictionary projectRelativePathsByBindingTree, + PartialTypeParts partialTypeParts) { IAssemblySymbol sourceAssembly = semanticModel.Compilation.Assembly; - CompiledSignatureSplitNames names = new CompiledSignatureSplitNames(projectRelativePathsByBindingTree); + CompiledSignatureSplitNames names = new CompiledSignatureSplitNames( + projectRelativePathsByBindingTree, + partialTypeParts); foreach (SyntaxNode node in body.DescendantNodesAndSelf()) { // Why only uses an error touches: a compiled API elsewhere in the body that binds is @@ -172,6 +175,7 @@ private static void AddSplit( signatureType, sourceAssembly, names.ProjectRelativePathsByBindingTree, + names.PartialTypeParts, names.ArtifactBoundDeclaringFiles); continue; } @@ -188,6 +192,7 @@ private static void AddSplit( signatureType, sourceAssembly, names.ProjectRelativePathsByBindingTree, + names.PartialTypeParts, names.SplitSourceFiles); found = true; } @@ -205,14 +210,23 @@ private static void AddSourceCopyFiles( INamedTypeSymbol signatureType, IAssemblySymbol sourceAssembly, IReadOnlyDictionary projectRelativePathsByBindingTree, + PartialTypeParts partialTypeParts, SortedSet sourceCopyFiles) { string reflectionName = CecilTypeNames.ToMetadataName(signatureType.OriginalDefinition).Replace('/', '+'); INamedTypeSymbol sourceCopy = sourceAssembly.GetTypeByMetadataName(reflectionName); foreach (SyntaxReference declaration in sourceCopy.DeclaringSyntaxReferences) { - // The compilation holds only the run's binding trees, so a declaration outside them - // means the map was built from other trees than the ones this model binds. + // A part of a partial type the run did not receive is not one of the files this reload + // builds the type from, so it is not a file to name. + if (partialTypeParts.IsBindingOnlyTree(declaration.SyntaxTree)) + { + continue; + } + + // Besides those parts the compilation holds only the run's binding trees, so a + // declaration outside them means the map was built from other trees than the ones this + // model binds. if (!projectRelativePathsByBindingTree.TryGetValue( declaration.SyntaxTree, out string projectRelativePath)) @@ -312,14 +326,19 @@ private static void AddType(ITypeSymbol type, List types) // The sorted names one collection gathers, kept together so each use adds to all of them. internal sealed class CompiledSignatureSplitNames { - internal CompiledSignatureSplitNames(IReadOnlyDictionary projectRelativePathsByBindingTree) + internal CompiledSignatureSplitNames( + IReadOnlyDictionary projectRelativePathsByBindingTree, + PartialTypeParts partialTypeParts) { ProjectRelativePathsByBindingTree = projectRelativePathsByBindingTree ?? throw new ArgumentNullException(nameof(projectRelativePathsByBindingTree)); + PartialTypeParts = partialTypeParts ?? throw new ArgumentNullException(nameof(partialTypeParts)); } internal IReadOnlyDictionary ProjectRelativePathsByBindingTree { get; } + internal PartialTypeParts PartialTypeParts { get; } + internal SortedSet SplitTypes { get; } = new SortedSet(StringComparer.Ordinal); internal SortedSet DeclaringTypes { get; } = new SortedSet(StringComparer.Ordinal); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ConstDriftCollector.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ConstDriftCollector.cs index 557ab9e61c..99e9139003 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ConstDriftCollector.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ConstDriftCollector.cs @@ -35,11 +35,14 @@ internal static class ConstDriftCollector /// C# inlines const values at compile time and shims compile against the already-compiled /// assembly, so value edits silently keep the old value at runtime; new consts fold into /// the bodies patched by this reload but fail shim compilation in files outside it. + /// A type whose metadata name is already in seenTypeMetadataNames is not compared again, and + /// every type compared is added to it. ///
internal static List CollectConstDriftWarnings( CompilationUnitSyntax root, SemanticModel semanticModel, - WorkerTypeHome home) + WorkerTypeHome home, + HashSet seenTypeMetadataNames) { List warnings = new List(); if (home.AssemblySymbol == null) @@ -47,7 +50,6 @@ internal static List CollectConstDriftWarnings( return warnings; } - HashSet seenTypeMetadataNames = new HashSet(StringComparer.Ordinal); foreach (BaseTypeDeclarationSyntax typeDeclaration in root.DescendantNodes().OfType()) { @@ -57,8 +59,9 @@ in root.DescendantNodes().OfType()) continue; } - // Partial declarations in one file resolve to the same merged type symbol, and - // comparing its members once per declaration would duplicate every warning. + // Partial declarations resolve to the same merged type symbol, whether they sit in one + // file or in several files of one compilation, and comparing its members once per + // declaration would duplicate every warning. string typeMetadataName = ToReflectionMetadataName(sourceType); if (!seenTypeMetadataNames.Add(typeMetadataName)) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/HarmonyAccessorShimRewrite.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/HarmonyAccessorShimRewrite.cs index 9d35fe1158..26dd76be8b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/HarmonyAccessorShimRewrite.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/HarmonyAccessorShimRewrite.cs @@ -300,4 +300,13 @@ internal static bool IsObjectOrCollectionInitializerMemberName(SimpleNameSyntax return assignment.Parent is InitializerExpressionSyntax; } + + // `x is { Member: 1 }` names a member of the matched value: the name is not an expression, + // so it cannot take a receiver. + internal static bool IsSubpatternMemberName(SimpleNameSyntax node) + { + return node.Parent is NameColonSyntax nameColon + && nameColon.Name == node + && nameColon.Parent is SubpatternSyntax; + } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/IntroducedTypePreparation.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/IntroducedTypePreparation.cs index a68398cff5..60a7ba3588 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/IntroducedTypePreparation.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/IntroducedTypePreparation.cs @@ -28,7 +28,9 @@ private static CSharpCompilation CreateConstDriftCompilation( CSharpParseOptions parseOptions, List syntaxTrees, List references, - CSharpCompilation planningCompilation) + CSharpCompilation planningCompilation, + IReadOnlyList assemblyGlobalUsings, + IReadOnlyList analyzableRoots) { List siblingTrees = SiblingConstDriftCollector.ParseChangedSiblings( input.ChangedSiblingSourcePaths, @@ -38,9 +40,24 @@ private static CSharpCompilation CreateConstDriftCompilation( return planningCompilation; } - List allTrees = new List(syntaxTrees.Count + siblingTrees.Count); + // Why a tree of its own rather than the planning one: a changed sibling is one of the + // assembly's other files, so the global usings it declares are already among + // assemblyGlobalUsings, and its own tree now joins the compilation as well. + List allRoots = new List(analyzableRoots); + foreach (SyntaxTree siblingTree in siblingTrees) + { + allRoots.Add(siblingTree.GetCompilationUnitRoot()); + } + + List allTrees = new List(syntaxTrees.Count + siblingTrees.Count + 1); allTrees.AddRange(syntaxTrees); allTrees.AddRange(siblingTrees); + SyntaxTree globalUsingTree = WorkerGlobalUsingBindingTree.Build(assemblyGlobalUsings, allRoots, parseOptions); + if (globalUsingTree != null) + { + allTrees.Add(globalUsingTree); + } + return CSharpCompilation.Create( assemblyName: "UloopHotReloadIntroducedTypeConstVerification", syntaxTrees: allTrees, @@ -72,23 +89,29 @@ internal static WorkerOutput Prepare(WorkerInput input) WorkerGroupPipeline.CollectMetadataReferences(input, referenceParseErrors); List<(WorkerIntroducedTypeArtifact Artifact, MetadataReference Reference)> artifactReferences = IntroducedTypeArtifactReferences.Collect(input, references, referenceParseErrors); - CSharpCompilation compilation = CSharpCompilation.Create( - assemblyName: "UloopHotReloadIntroducedTypePlanning", - syntaxTrees: syntaxTrees, - references: references, - options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - AppendUnreadableReferenceErrors(compilation, references, referenceParseErrors); - IAssemblySymbol targetAssembly = - WorkerCompiledAssemblySymbols.ResolveWithAllMembers(compilation, targetTypesReference); - WorkerTypeHome home = new WorkerTypeHome(input.TargetAssemblyName, targetAssembly); List analyzableRoots = new List(analyzableUnits.Count); foreach (WorkerSourceUnit analyzableUnit in analyzableUnits) { analyzableRoots.Add(analyzableUnit.Root); } + // Why collected before planning: a base or member type the source reaches only through + // another file's global using must bind in the planning compilation too, or a Unity object + // ancestor stays an unresolved error type and the declaration is planned instead of refused. List assemblyGlobalUsings = WorkerUsingCollector.CollectAssemblyGlobalUsings(input, parseOptions, analyzableRoots); + SyntaxTree globalUsingTree = WorkerGlobalUsingBindingTree.Build(assemblyGlobalUsings, analyzableRoots, parseOptions); + // 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( + assemblyName: "UloopHotReloadIntroducedTypePlanning", + syntaxTrees: WorkerGlobalUsingBindingTree.Append(syntaxTrees, globalUsingTree), + references: references, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + AppendUnreadableReferenceErrors(compilation, references, referenceParseErrors); + IAssemblySymbol targetAssembly = + WorkerCompiledAssemblySymbols.ResolveWithAllMembers(compilation, targetTypesReference); + WorkerTypeHome home = new WorkerTypeHome(input.TargetAssemblyName, targetAssembly); WorkerReason incompleteInputsDiagnostic = DescribeIncompleteCompilationInputs(input, targetAssembly, referenceParseErrors); IntroducedTypeArtifactMap artifactMap = IntroducedTypeArtifactMap.Empty; @@ -111,7 +134,9 @@ internal static WorkerOutput Prepare(WorkerInput input) parseOptions, syntaxTrees, references, - compilation); + compilation, + assemblyGlobalUsings, + analyzableRoots); AddedMemberReferenceClassifier addedMemberClassifier = new AddedMemberReferenceClassifier( compilation, home, @@ -156,7 +181,8 @@ internal static WorkerOutput Prepare(WorkerInput input) declarationDriftWarnings[index] = ConstDriftCollector.CollectConstDriftWarnings( unit.Root, unit.ConstDriftSemanticModel, - home).ToArray(); + home, + new HashSet(StringComparer.Ordinal)).ToArray(); } else { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs index 8b6af895a3..f77cda50f0 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs @@ -28,13 +28,15 @@ internal static MethodTransformDecision DecideMethodTransform( SemanticModel semanticModel, INamedTypeSymbol compiledType, AddedMemberAccessLookup addedMemberAccess, - AddedEventLookup addedEvents) + AddedEventLookup addedEvents, + PartialTypeParts partialTypeParts) { WorkerReason hardSkip = EvaluateHardSkipReason( typeDeclaration, typeSymbol, methodDeclaration, - methodSymbol); + methodSymbol, + partialTypeParts); if (hardSkip != null) { return MethodTransformDecision.Skip(hardSkip); @@ -195,17 +197,15 @@ internal static WorkerReason EvaluateHardSkipReason( TypeDeclarationSyntax typeDeclaration, INamedTypeSymbol typeSymbol, MethodDeclarationSyntax methodDeclaration, - IMethodSymbol methodSymbol) + IMethodSymbol methodSymbol, + PartialTypeParts partialTypeParts) { - // A nested type inside a partial outer type still has an incomplete single-file model. - for (TypeDeclarationSyntax declaration = typeDeclaration; - declaration != null; - declaration = declaration.Parent as TypeDeclarationSyntax) + // Why only an untrusted part skips: the loader put every trusted part into the binding, + // so the type is complete unless a part changed after the last compile, or could not be checked. + WorkerReason untrustedPart = partialTypeParts.FindUntrustedOtherPartReasonOrNull(typeDeclaration); + if (untrustedPart != null) { - if (declaration.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.PartialKeyword))) - { - return WorkerReason.Of(HotReloadWorkerReasonCode.MethodTransformPartialType); - } + return untrustedPart; } if (typeSymbol.TypeKind == TypeKind.Struct || typeSymbol.IsValueType) @@ -313,7 +313,8 @@ private static WorkerReason DescribeUnboundBody( Diagnostic bindingError, IntroducedTypeArtifactMap artifactMap, IAssemblySymbol targetAssembly, - IReadOnlyDictionary projectRelativePathsByBindingTree) + IReadOnlyDictionary projectRelativePathsByBindingTree, + PartialTypeParts partialTypeParts) { string diagnosticText = bindingError.Id + ": " + bindingError.GetMessage(CultureInfo.InvariantCulture); CompiledSignatureSplit split = CompiledSignatureSplitCollector.Collect( @@ -322,7 +323,8 @@ private static WorkerReason DescribeUnboundBody( AddedMemberBindingGuard.FindBindingErrorSpans(semanticModel, methodBodyNode), artifactMap, targetAssembly, - projectRelativePathsByBindingTree); + projectRelativePathsByBindingTree, + partialTypeParts); // Checked first: a compile clears this split and any other one, while the advice to // pass a file would leave this one in place. if (split.ArtifactHostMetadataNames.Count > 0) @@ -386,7 +388,8 @@ internal static MethodTransformDecision DecideAddedMethodAccessors( bindingError, sourceUnit.ArtifactMap, targetAssembly, - sourceUnit.RunProjectRelativePathsByBindingTree)); + sourceUnit.RunProjectRelativePathsByBindingTree, + sourceUnit.PartialTypeParts)); } // Checked before the delegation path too: a closure reaching the member through an accessor @@ -457,6 +460,15 @@ internal static WorkerReason EvaluateAddedMethodSkipReason( IMethodSymbol methodSymbol, MethodDeclarationSyntax methodDeclaration) { + // Why first: a signature type that does not bind is what made the method miss its compiled + // counterpart, so a generic or virtual reason would describe a method that may not be new. + if (UnresolvedSignatureTypes.TryFindInSignature(methodSymbol, out ITypeSymbol unresolvedType)) + { + return WorkerReason.Of( + HotReloadWorkerReasonCode.AddedMethodSignatureTypeUnresolved, + unresolvedType.ToDisplayString()); + } + if (methodSymbol.IsAbstract || methodSymbol.IsVirtual || methodSymbol.IsOverride) { return WorkerReason.Of(HotReloadWorkerReasonCode.AddedMethodVirtualOrAbstract); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/NameofRules.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/NameofRules.cs index 8252eed776..61603c07dc 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/NameofRules.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/NameofRules.cs @@ -40,4 +40,24 @@ public static bool IsInsideNameofArgument(SyntaxNode node) return false; } + + // Returns the name a bound nameof evaluates to, or null when the nameof must not be folded. + public static string FindBoundNameofValueOrNull( + InvocationExpressionSyntax nameofInvocation, + SemanticModel semanticModel) + { + // Why the error check before the constant: an operand that does not bind, or binds only + // in part, can still yield a constant, and that value need not be the name the code + // would compile with. + foreach (Diagnostic diagnostic in semanticModel.GetDiagnostics(nameofInvocation.Span)) + { + if (diagnostic.Severity == DiagnosticSeverity.Error) + { + return null; + } + } + + Optional constant = semanticModel.GetConstantValue(nameofInvocation); + return constant.HasValue ? constant.Value as string : null; + } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs index be2bfdf0fb..450816aa6d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs @@ -283,7 +283,8 @@ internal static MethodTransformDecision DecideOrdinaryMethodTransform( semanticModel, typeState.CompiledType, typeState.AddedMemberAccess, - typeState.AddedEvents); + typeState.AddedEvents, + typeState.SourceUnit.PartialTypeParts); if (isAddedMethod && decision.SkipReason == null) { decision = MethodTransformDecider.DecideAddedMethodAccessors( @@ -302,13 +303,34 @@ internal static MethodTransformDecision DecideOrdinaryMethodTransform( WorkerReason siblingSkip = ReappliedSiblingBodyGuard.DescribeSkipOrNull( semanticModel, methodBodyNode, - typeState.TargetAssembly); + typeState.TargetAssembly, + methodDeclaration, + decision, + typeState.TypeSymbol); if (siblingSkip != null) { decision = MethodTransformDecision.Skip(siblingSkip); } } + // Why existing methods only: an added method's unbound body is already skipped by the + // added-member binding check, with its own reason. + if (!isAddedMethod && decision.SkipReason == null) + { + WorkerReason unresolvedNameSkip = UnresolvedBodyNameGuard.DescribeSkipOrNull( + typeState.TypeDeclaration, + semanticModel, + methodBodyNode, + methodDeclaration, + decision, + typeState.TypeSymbol, + typeState.TargetAssembly); + if (unresolvedNameSkip != null) + { + decision = MethodTransformDecision.Skip(unresolvedNameSkip); + } + } + return decision; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OutsideMethodBodyDriftChecker.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OutsideMethodBodyDriftChecker.cs index 273d15b87e..37e48b9d16 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OutsideMethodBodyDriftChecker.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OutsideMethodBodyDriftChecker.cs @@ -18,10 +18,10 @@ internal static class OutsideMethodBodyDriftChecker { internal const string OutsideMethodBodyDriftWarningFormat = - "Edits outside method bodies in {0} (fields, initializers, or attributes) are not applied by hot reload; run uloop compile to pick them up."; + "Edits outside method bodies in {0} (fields, initializers, or attributes) since the last compile are not applied by hot reload; run uloop compile to pick them up."; internal const string OutsideMethodBodyNamedDriftWarningFormat = - "Edits outside method bodies in {0} ({1}) are not applied by hot reload; run uloop compile to pick them up."; + "Edits outside method bodies in {0} ({1}) since the last compile are not applied by hot reload; run uloop compile to pick them up."; internal static void AppendOutsideMethodBodyDriftWarningIfNeeded( CompilationUnitSyntax snapshotRoot, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PartialTypePartLoader.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PartialTypePartLoader.cs new file mode 100644 index 0000000000..70185f04d9 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PartialTypePartLoader.cs @@ -0,0 +1,374 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +/// +/// Finds, among the assembly's other source files, the declarations that complete the +/// partial types the run's files declare. +/// +internal static class PartialTypePartLoader +{ + // runUnits is every source of the run, transformUnits the ones the transform reads. + internal static PartialTypeParts Load( + WorkerInput input, + CSharpParseOptions parseOptions, + IReadOnlyList runUnits, + IReadOnlyList transformUnits) + { + NeededPartialTypes neededTypes = new NeededPartialTypes(); + foreach (WorkerSourceUnit unit in transformUnits) + { + neededTypes.CollectFrom(unit.Root); + } + + // Why no file is read without a partial type: no other file can add a member to the run's + // types then, and reading every source of the assembly would slow every such run down. + if (neededTypes.MetadataNames.Count == 0) + { + return PartialTypeParts.None; + } + + // Why before any file is read: an incomplete scan cannot tell a changed part from an + // unchanged one, so no part on disk can be trusted to match the compiled type. + if (!input.ChangedSiblingScanComplete) + { + return PartialTypeParts.Unverified; + } + + Dictionary changedPartPathByTypeMetadataName = + new Dictionary(StringComparer.Ordinal); + RecordRunFilesLeftOutOfTheTransform(runUnits, transformUnits, neededTypes, changedPartPathByTypeMetadataName); + + List bindingOnlyTrees = new List(); + foreach (string assemblySourcePath in input.AssemblySourcePaths) + { + SyntaxTree bindingOnlyTree = LoadTrustedPartOrNull( + input, + parseOptions, + assemblySourcePath, + neededTypes, + changedPartPathByTypeMetadataName); + if (bindingOnlyTree != null) + { + bindingOnlyTrees.Add(bindingOnlyTree); + } + } + + return new PartialTypeParts(bindingOnlyTrees, changedPartPathByTypeMetadataName, everyPartialTypeUnverified: false); + } + + // Why a run file the transform dropped is recorded: it does not reach the binding either, so + // without this the run's other files would bind against its types without its part. + private static void RecordRunFilesLeftOutOfTheTransform( + IReadOnlyList runUnits, + IReadOnlyList transformUnits, + NeededPartialTypes neededTypes, + Dictionary changedPartPathByTypeMetadataName) + { + foreach (WorkerSourceUnit unit in runUnits) + { + if (transformUnits.Contains(unit)) + { + continue; + } + + // Why every type when the file could not be read: nothing tells which types it holds + // parts of. A file that was read has syntax errors, so only the names in its text count. + IEnumerable untrustedMetadataNames = neededTypes.MetadataNames; + if (unit.SyntaxTree != null) + { + untrustedMetadataNames = neededTypes.MentionedIn(unit.SyntaxTree.GetText().ToString()); + } + + RecordUntrusted(untrustedMetadataNames, unit.Input.ProjectRelativePath, changedPartPathByTypeMetadataName); + } + } + + // The reduced tree of one file of the assembly when it holds parts of the run's partial types + // that match the compiled type. When the parts may not match, the types they belong to are + // recorded as changed instead and no tree is returned. + private static SyntaxTree LoadTrustedPartOrNull( + WorkerInput input, + CSharpParseOptions parseOptions, + string path, + NeededPartialTypes neededTypes, + Dictionary changedPartPathByTypeMetadataName) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path) || IsRunFile(input, path)) + { + return null; + } + + // The same decoding WorkerUsingCollector reads the assembly's other files with. + string text = File.ReadAllText(path, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + bool isChangedSibling = IsChangedSibling(input, path); + if (!MayDeclareNeededPart(text, neededTypes, isChangedSibling)) + { + return null; + } + + SyntaxTree tree = CSharpSyntaxTree.ParseText(SourceText.From(text, Encoding.UTF8), parseOptions, path); + + // Why the names in the text and not the declarations in the tree: a tree with syntax errors + // can drop a declaration or nest it under the wrong type, so no type its text names is trusted. + if (HasErrors(tree)) + { + RecordUntrusted(neededTypes.MentionedIn(text), DescribePath(input, path), changedPartPathByTypeMetadataName); + return null; + } + + CompilationUnitSyntax root = tree.GetCompilationUnitRoot(); + List declaredMetadataNames = CollectDeclaredNeededTypes(root, neededTypes.MetadataNames); + if (declaredMetadataNames.Count == 0) + { + return null; + } + + // Why a changed part is never bound against: its text may declare members the compiled type + // does not have, and a body bound to those would be applied to a type that cannot run it. + if (isChangedSibling) + { + RecordUntrusted(declaredMetadataNames, DescribePath(input, path), changedPartPathByTypeMetadataName); + return null; + } + + CompilationUnitSyntax reduced = + (CompilationUnitSyntax)new PartialTypePartReducer(neededTypes.MetadataNames).Visit(root); + return CSharpSyntaxTree.Create(reduced, parseOptions, path, Encoding.UTF8); + } + + // The first file recorded for a type is the one its skip reason names. + private static void RecordUntrusted( + IEnumerable metadataNames, + string shownPath, + Dictionary changedPartPathByTypeMetadataName) + { + foreach (string metadataName in metadataNames) + { + if (!changedPartPathByTypeMetadataName.ContainsKey(metadataName)) + { + changedPartPathByTypeMetadataName[metadataName] = shownPath; + } + } + } + + private static List CollectDeclaredNeededTypes(CompilationUnitSyntax root, HashSet neededMetadataNames) + { + List declared = new List(); + foreach (TypeDeclarationSyntax declaration in root.DescendantNodes().OfType()) + { + if (!PartialTypeParts.IsPartial(declaration)) + { + continue; + } + + string metadataName = WorkerSyntaxIndex.BuildTypeMetadataNameFromSyntax(declaration); + if (neededMetadataNames.Contains(metadataName) && !declared.Contains(metadataName)) + { + declared.Add(metadataName); + } + } + + return declared; + } + + // A run file: the path the worker reads, or the real file an edited copy stands for. + private static bool IsRunFile(WorkerInput input, string path) + { + foreach (WorkerSourceInput source in input.Sources) + { + if (WorkerUsingCollector.PathsReferToSameSourceFile(path, source.SourcePath)) + { + return true; + } + + if (HotReloadSourcePathMatching.EndsWithProjectRelativePath(path, source.ProjectRelativePath)) + { + return true; + } + } + + return false; + } + + private static bool IsChangedSibling(WorkerInput input, string path) + { + foreach (string changedSiblingPath in input.ChangedSiblingSourcePaths) + { + if (WorkerUsingCollector.PathsReferToSameSourceFile(path, changedSiblingPath)) + { + return true; + } + } + + return false; + } + + private static bool HasErrors(SyntaxTree tree) + { + foreach (Diagnostic diagnostic in tree.GetDiagnostics()) + { + if (diagnostic.Severity == DiagnosticSeverity.Error) + { + return true; + } + } + + return false; + } + + // Why a text check before parsing: a run with a partial type reads every source of the + // assembly, and only a file that mentions one of the type names can hold a part. An unchanged + // file also has to say "partial"; a changed one need not, because a misspelled "partial" is a + // syntax error that still has to be recorded. + private static bool MayDeclareNeededPart(string text, NeededPartialTypes neededTypes, bool isChangedSibling) + { + if (!isChangedSibling && text.IndexOf("partial", StringComparison.Ordinal) < 0) + { + return false; + } + + return neededTypes.MentionedIn(text).Count > 0; + } + + // Project-relative when a run file anchors the root, else the file name. + private static string DescribePath(WorkerInput input, string path) + { + foreach (string assemblySourcePath in input.AssemblySourcePaths) + { + foreach (WorkerSourceInput source in input.Sources) + { + string relative = HotReloadSourcePathMatching.ToProjectRelativeOrNull( + path, + assemblySourcePath, + source.ProjectRelativePath); + if (relative != null) + { + return relative; + } + } + } + + return Path.GetFileName(path); + } +} + +/// +/// The partial types the run's files declare, by metadata name and by the simple name source +/// spells them with, which is all a file with syntax errors can be checked for. +/// +internal sealed class NeededPartialTypes +{ + private readonly Dictionary _simpleNameByMetadataName = + new Dictionary(StringComparer.Ordinal); + + internal HashSet MetadataNames { get; } = new HashSet(StringComparer.Ordinal); + + internal void CollectFrom(CompilationUnitSyntax root) + { + foreach (TypeDeclarationSyntax declaration in root.DescendantNodes().OfType()) + { + if (!PartialTypeParts.IsPartial(declaration)) + { + continue; + } + + string metadataName = WorkerSyntaxIndex.BuildTypeMetadataNameFromSyntax(declaration); + if (MetadataNames.Add(metadataName)) + { + _simpleNameByMetadataName[metadataName] = declaration.Identifier.ValueText; + } + } + } + + // Why any occurrence of the name counts, inside a longer word too: a looser match only makes + // more types untrusted, which is the safe side when the declarations cannot be relied on. + internal List MentionedIn(string text) + { + List mentioned = new List(); + foreach (KeyValuePair simpleNameByMetadataName in _simpleNameByMetadataName) + { + if (text.IndexOf(simpleNameByMetadataName.Value, StringComparison.Ordinal) >= 0) + { + mentioned.Add(simpleNameByMetadataName.Key); + } + } + + return mentioned; + } +} + +/// Keeps the needed partial declarations of a file and drops everything else that declares something. +internal sealed class PartialTypePartReducer : CSharpSyntaxRewriter +{ + private readonly HashSet _neededMetadataNames; + + internal PartialTypePartReducer(HashSet neededMetadataNames) + { + _neededMetadataNames = neededMetadataNames ?? throw new ArgumentNullException(nameof(neededMetadataNames)); + } + + // Why the global usings and the assembly and module attributes go: the run's global-using tree + // already carries every global using of the assembly, and an attribute of the assembly would be + // declared twice. + public override SyntaxNode VisitCompilationUnit(CompilationUnitSyntax node) + { + CompilationUnitSyntax visited = (CompilationUnitSyntax)base.VisitCompilationUnit(node); + SyntaxList ordinaryUsings = SyntaxFactory.List( + visited.Usings.Where(usingDirective => !usingDirective.GlobalKeyword.IsKind(SyntaxKind.GlobalKeyword))); + return visited + .WithUsings(ordinaryUsings) + .WithAttributeLists(default(SyntaxList)); + } + + public override SyntaxNode VisitClassDeclaration(ClassDeclarationSyntax node) + { + return Keep(node); + } + + public override SyntaxNode VisitStructDeclaration(StructDeclarationSyntax node) + { + return Keep(node); + } + + public override SyntaxNode VisitInterfaceDeclaration(InterfaceDeclarationSyntax node) + { + return Keep(node); + } + + public override SyntaxNode VisitRecordDeclaration(RecordDeclarationSyntax node) + { + return Keep(node); + } + + public override SyntaxNode VisitEnumDeclaration(EnumDeclarationSyntax node) + { + return null; + } + + public override SyntaxNode VisitDelegateDeclaration(DelegateDeclarationSyntax node) + { + return null; + } + + private SyntaxNode Keep(TypeDeclarationSyntax node) + { + // Why the node is returned whole, without descending: a needed type's members, + // nested types included, are all part of the type the edited bodies bind against. + if (PartialTypeParts.IsPartial(node) + && _neededMetadataNames.Contains(WorkerSyntaxIndex.BuildTypeMetadataNameFromSyntax(node))) + { + return node; + } + + return null; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PartialTypeParts.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PartialTypeParts.cs new file mode 100644 index 0000000000..d1970fc845 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PartialTypeParts.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +/// +/// What the run knows about the parts of its partial types that live in files it was not +/// given: the trees that complete the binding, and which types cannot be completed. +/// +internal sealed class PartialTypeParts +{ + // No trees and nothing untrusted: a run that declares no partial type. + internal static readonly PartialTypeParts None = new PartialTypeParts( + Array.Empty(), + new Dictionary(StringComparer.Ordinal), + everyPartialTypeUnverified: false); + + // Nothing was checked, so no partial type may be bound against its other parts. Also what + // a source unit holds until a pipeline loads the parts, so a pipeline that never does + // keeps skipping partial types instead of binding them incomplete. + internal static readonly PartialTypeParts Unverified = new PartialTypeParts( + Array.Empty(), + new Dictionary(StringComparer.Ordinal), + everyPartialTypeUnverified: true); + + private readonly HashSet _bindingOnlyTreeSet; + private readonly Dictionary _changedPartPathByTypeMetadataName; + private readonly bool _everyPartialTypeUnverified; + + internal PartialTypeParts( + IReadOnlyList bindingOnlyTrees, + Dictionary changedPartPathByTypeMetadataName, + bool everyPartialTypeUnverified) + { + BindingOnlyTrees = bindingOnlyTrees ?? throw new ArgumentNullException(nameof(bindingOnlyTrees)); + _changedPartPathByTypeMetadataName = changedPartPathByTypeMetadataName + ?? throw new ArgumentNullException(nameof(changedPartPathByTypeMetadataName)); + _everyPartialTypeUnverified = everyPartialTypeUnverified; + _bindingOnlyTreeSet = new HashSet(bindingOnlyTrees); + } + + // The reduced trees of the other parts, which enter the binding compilation but are not files + // of the run: nothing is transformed or reported from them. + internal IReadOnlyList BindingOnlyTrees { get; } + + internal bool IsBindingOnlyTree(SyntaxTree tree) + { + return _bindingOnlyTreeSet.Contains(tree); + } + + // The reason typeDeclaration (or a partial type enclosing it) cannot be bound against the + // compiled type, or null when every other part is trusted. + internal WorkerReason FindUntrustedOtherPartReasonOrNull(TypeDeclarationSyntax typeDeclaration) + { + for (TypeDeclarationSyntax declaration = typeDeclaration; + declaration != null; + declaration = declaration.Parent as TypeDeclarationSyntax) + { + if (!IsPartial(declaration)) + { + continue; + } + + if (_everyPartialTypeUnverified) + { + return WorkerReason.Of(HotReloadWorkerReasonCode.MethodTransformPartialOtherPartsUnverified); + } + + string metadataName = WorkerSyntaxIndex.BuildTypeMetadataNameFromSyntax(declaration); + if (_changedPartPathByTypeMetadataName.TryGetValue(metadataName, out string changedPartPath)) + { + return WorkerReason.Of(HotReloadWorkerReasonCode.MethodTransformPartialOtherPartChanged, changedPartPath); + } + } + + return null; + } + + internal static bool IsPartial(TypeDeclarationSyntax declaration) + { + return declaration.Modifiers.Any(SyntaxKind.PartialKeyword); + } + + // True when declaration or a type enclosing it is partial, which is when another file can + // declare members the body binds to. + internal static bool IsPartialOrNestedInPartial(TypeDeclarationSyntax declaration) + { + for (TypeDeclarationSyntax current = declaration; current != null; current = current.Parent as TypeDeclarationSyntax) + { + if (IsPartial(current)) + { + return true; + } + } + + return false; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterClassifier.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterClassifier.cs index 460410111d..3f705d9658 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterClassifier.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterClassifier.cs @@ -68,6 +68,8 @@ internal static (bool SkipGetter, MethodTransformDecision Decision) TrySkipPrope AddedMethodCatalog addedMethodCatalog, AddedFieldCatalog addedFieldCatalog, AddedPropertyCatalog addedPropertyCatalog, + PartialTypeParts partialTypeParts, + IAssemblySymbol targetAssembly, List skipped) { MethodTransformDecision decision = MethodTransformDecider.DecideMethodTransform( @@ -79,7 +81,8 @@ internal static (bool SkipGetter, MethodTransformDecision Decision) TrySkipPrope semanticModel, compiledType, addedMemberAccess: null, - addedEvents); + addedEvents, + partialTypeParts); if (decision.SkipReason != null) { skipped.Add(new WorkerSkipped @@ -91,6 +94,25 @@ internal static (bool SkipGetter, MethodTransformDecision Decision) TrySkipPrope return (true, decision); } + WorkerReason unresolvedNameSkip = UnresolvedBodyNameGuard.DescribeSkipOrNull( + typeDeclaration, + semanticModel, + getterBodyNode, + methodDeclarationOrNull: null, + decision, + typeSymbol, + targetAssembly); + if (unresolvedNameSkip != null) + { + skipped.Add(new WorkerSkipped + { + SourceProjectRelativePath = sourceProjectRelativePath, + Method = WorkerMethodKeys.FormatMethodLabel(getterSymbol), + Reason = unresolvedNameSkip + }); + return (true, MethodTransformDecision.Skip(unresolvedNameSkip)); + } + (WorkerReason addedCallSiteSkip, string calledAddedMethodKey) = AddedCallSiteGuard.EvaluateAddedCallSiteSkipReason( getterBodyNode, semanticModel, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterEmitter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterEmitter.cs index 1838568dae..202bf8802b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterEmitter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterEmitter.cs @@ -104,7 +104,8 @@ internal static void EmitPropertyGettersForType( addedPropertyCatalog, typeState.HomeAssemblyName, typeState.SourceUnit.Input.ReappliedSibling, - typeState.TargetAssembly); + typeState.TargetAssembly, + typeState.SourceUnit.PartialTypeParts); } } @@ -135,7 +136,8 @@ internal static ShimTypeBuilder AppendPropertyGetterEntry( AddedPropertyCatalog addedPropertyCatalog, string homeAssemblyName, bool reappliedSibling, - IAssemblySymbol targetAssembly) + IAssemblySymbol targetAssembly, + PartialTypeParts partialTypeParts) { IPropertySymbol propertySymbol = semanticModel.GetDeclaredSymbol(propertyDeclaration); if (propertySymbol == null || propertySymbol.GetMethod == null) @@ -213,6 +215,8 @@ internal static ShimTypeBuilder AppendPropertyGetterEntry( addedMethodCatalog, addedFieldCatalog, addedPropertyCatalog, + partialTypeParts, + targetAssembly, skipped); if (skipGetter) { @@ -227,7 +231,10 @@ internal static ShimTypeBuilder AppendPropertyGetterEntry( WorkerReason siblingSkip = ReappliedSiblingBodyGuard.DescribeSkipOrNull( semanticModel, getterBodyNode, - targetAssembly); + targetAssembly, + methodDeclarationOrNull: null, + decision, + typeSymbol); if (siblingSkip != null) { skipped.Add(new WorkerSkipped diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ReappliedSiblingBodyGuard.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ReappliedSiblingBodyGuard.cs index 23fdef6758..d198d7422a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ReappliedSiblingBodyGuard.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ReappliedSiblingBodyGuard.cs @@ -7,7 +7,9 @@ /// /// Skips an existing method of a file the run pulled in to re-bind its active patches when that -/// method's body no longer binds, and names the file the reader can pass so it binds again. +/// method's body no longer binds, and names the file the reader can pass so it binds again. A body +/// whose only errors are internal members of unpassed compiled types that the patched method +/// reaches itself goes through, because the shim compile binds them. /// /// /// Why only such files: the binding guard runs for added methods alone, so an existing body @@ -22,7 +24,10 @@ internal static class ReappliedSiblingBodyGuard internal static WorkerReason DescribeSkipOrNull( SemanticModel semanticModel, SyntaxNode methodBodyNode, - IAssemblySymbol targetAssembly) + IAssemblySymbol targetAssembly, + MethodDeclarationSyntax methodDeclarationOrNull, + MethodTransformDecision decision, + INamedTypeSymbol typeSymbol) { Diagnostic bindingError = AddedMemberBindingGuard.FindFirstBindingError(semanticModel, methodBodyNode); if (bindingError == null) @@ -30,6 +35,17 @@ internal static WorkerReason DescribeSkipOrNull( return null; } + if (EveryErrorCanBePatchedInPlace( + semanticModel, + methodBodyNode, + methodDeclarationOrNull, + decision, + typeSymbol, + targetAssembly)) + { + return null; + } + string diagnosticText = bindingError.Id + ": " + bindingError.GetMessage(CultureInfo.InvariantCulture); INamedTypeSymbol receiver = FindCompiledReceiverOfUnboundMember(semanticModel, methodBodyNode, targetAssembly); if (receiver == null) @@ -44,6 +60,41 @@ internal static WorkerReason DescribeSkipOrNull( "'" + receiver.Name + "'"); } + // Why every error has to qualify, and the skip keeps today's reason otherwise: one other error + // still fails the file in the shim, or a use out of the patched method's reach throws once + // called, and the first error is what the reason has always named. + private static bool EveryErrorCanBePatchedInPlace( + SemanticModel semanticModel, + SyntaxNode methodBodyNode, + MethodDeclarationSyntax methodDeclarationOrNull, + MethodTransformDecision decision, + INamedTypeSymbol typeSymbol, + IAssemblySymbol targetAssembly) + { + foreach (Diagnostic diagnostic in semanticModel.GetDiagnostics(methodBodyNode.Span)) + { + if (diagnostic.Severity != DiagnosticSeverity.Error) + { + continue; + } + + UnpassedInternalMemberUse use = UnpassedInternalMemberUse.FindOrNull( + diagnostic, + semanticModel, + methodBodyNode, + methodDeclarationOrNull, + decision, + typeSymbol, + targetAssembly); + if (use == null || !use.CanBePatchedInPlace) + { + return false; + } + } + + return true; + } + // Why not CompiledSignatureSplitCollector: it names a compiled API whose signature still takes // the compiled copy of a type this run declares, while here the member itself is missing from // the compiled type, so there is no split for it to find. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/RetainedDeclarationStage.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/RetainedDeclarationStage.cs index 01bef44319..53fd27518b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/RetainedDeclarationStage.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/RetainedDeclarationStage.cs @@ -16,13 +16,15 @@ internal static class RetainedDeclarationStage // unchanged, records the ones whose method bodies this edit changed and binds them with the // accessibility their artifact was compiled with, and reports why the artifacts could not be // used at all. Returns null when the run has no artifact to bind against, which is every run - // until introduced types are in play. + // until introduced types are in play. globalUsingTree is the assembly's global usings built + // against these units' roots, or null when they already declare every one. internal static string PrepareBindingTrees( WorkerInput input, List loadedUnits, List references, MetadataReference targetTypesReference, - CSharpParseOptions parseOptions) + CSharpParseOptions parseOptions, + SyntaxTree globalUsingTree) { if (input.IntroducedTypeArtifacts.Length == 0) { @@ -47,7 +49,7 @@ internal static string PrepareBindingTrees( // trees as written; the binding compilation is built from what survives the removal. CSharpCompilation verificationCompilation = CSharpCompilation.Create( assemblyName: "UloopHotReloadRetainedDeclarationVerification", - syntaxTrees: editedTrees, + syntaxTrees: WorkerGlobalUsingBindingTree.Append(editedTrees, globalUsingTree), references: references, options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); IAssemblySymbol targetAssembly = WorkerCompiledAssemblySymbols.ResolveWithAllMembers( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimBodyRewriter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimBodyRewriter.cs index 46209b44df..ff3872c016 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimBodyRewriter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimBodyRewriter.cs @@ -145,6 +145,18 @@ public override SyntaxNode VisitInvocationExpression(InvocationExpressionSyntax return folded; } + // Why fold instead of rewriting the operand: the shim is a static method of another + // type, so nothing needs the operand rebound there, and a rewritten operand + // (__uloopInstance.x) names a parameter a static method's shim does not have. + string boundName = NameofRules.FindBoundNameofValueOrNull(node, _semanticModel); + if (boundName != null) + { + return SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(boundName)) + .WithTriviaFrom(node); + } + return base.VisitInvocationExpression(node); } @@ -506,6 +518,14 @@ private SyntaxNode VisitName(SimpleNameSyntax node, SyntaxNode original) return original; } + // Why only before the final qualification, not with the name-side checks at the top: + // returning there would also skip the accessor-read and added-field rewrites above, and + // a pattern naming a member the shim cannot reach could then compile and fail at run time. + if (HarmonyAccessorShimRewrite.IsSubpatternMemberName(node)) + { + return original; + } + return HarmonyAccessorShimRewrite.QualifyOwnedMemberAccess(node, ownership.isStatic, ownership.containingType); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimMethodEmitter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimMethodEmitter.cs index 348f7551f8..ba638dbb3d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimMethodEmitter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimMethodEmitter.cs @@ -113,10 +113,7 @@ internal static void EmitQueuedMethods( CalledAddedMethodKeys = calledAddedMethodKeys, SourceStartLine = queued.SourceStartLine, SourceEndLine = queued.SourceEndLine, - LifecycleNote = ComputeLifecycleNote( - queued.MethodDeclaration, - queued.MethodSymbol, - typeState.TypeSymbol), + LifecycleNote = ComputeEntryLifecycleNote(queued, typeState.TypeSymbol), ReplacesCompiledMethod = queued.ReplacesCompiledMethod, HomeAssemblyName = typeState.HomeAssemblyName }); @@ -186,6 +183,28 @@ internal static string ComputeLifecycleNote( return string.Format(LifecycleNotes.SelectDirectFormat(methodName), methodName); } + /// + /// The note a queued method's entry carries: the one-shot lifecycle note when the method is + /// one, else the Test Runner note when it is an added test method, else none. + /// + // Why the one-shot note first: the rows that carry it today keep it unchanged. Why the + // added-test condition is the warning's: a row gets the note exactly when the run warns. + internal static string ComputeEntryLifecycleNote(QueuedShimMethod queued, INamedTypeSymbol typeSymbol) + { + string oneShotNote = ComputeLifecycleNote(queued.MethodDeclaration, queued.MethodSymbol, typeSymbol); + if (oneShotNote != null) + { + return oneShotNote; + } + + if (queued.IsAddedMethod && TestAttributeNames.HasTestAttribute(queued.MethodDeclaration)) + { + return TestAttributeNames.AddedTestMethodLifecycleNote; + } + + return null; + } + internal static bool IsOneShotLifecycleMethodName(string methodName) { for (int index = 0; index < LifecycleNotes.OneShotLifecycleMethodNames.Length; index++) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/SiblingConstDriftCollector.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/SiblingConstDriftCollector.cs index b4679c4078..210787ed30 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/SiblingConstDriftCollector.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/SiblingConstDriftCollector.cs @@ -12,13 +12,15 @@ internal static class SiblingConstDriftCollector /// /// Parses each changed sibling source and reuses ConstDriftCollector against the compiled /// target-types assembly. The edited file is already scanned on its in-memory tree; siblings - /// are the files TransformWorker's single-file compilation cannot see. + /// are the files TransformWorker's single-file compilation cannot see. Each sibling binds + /// together with the assembly's global usings it does not declare itself. /// internal static List CollectConstDriftWarnings( string[] changedSiblingSourcePaths, CSharpParseOptions parseOptions, IReadOnlyList references, - WorkerTypeHome home) + WorkerTypeHome home, + IReadOnlyList assemblyGlobalUsings) { List warnings = new List(); if (changedSiblingSourcePaths == null @@ -38,9 +40,13 @@ internal static List CollectConstDriftWarnings( SyntaxTree syntaxTree = ParseSibling(siblingPath, parseOptions); CompilationUnitSyntax root = syntaxTree.GetCompilationUnitRoot(); + // Why built per sibling: the sibling may itself be a file that declares global + // usings, so a tree built for the whole run would repeat those directives here. + SyntaxTree siblingGlobalUsingTree = + WorkerGlobalUsingBindingTree.Build(assemblyGlobalUsings, new[] { root }, parseOptions); CSharpCompilation siblingCompilation = CSharpCompilation.Create( assemblyName: "UloopHotReloadSiblingConstDriftCompilation", - syntaxTrees: new[] { syntaxTree }, + syntaxTrees: WorkerGlobalUsingBindingTree.Append(new[] { syntaxTree }, siblingGlobalUsingTree), references: references, options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); SemanticModel semanticModel = siblingCompilation.GetSemanticModel( @@ -50,7 +56,8 @@ internal static List CollectConstDriftWarnings( ConstDriftCollector.CollectConstDriftWarnings( root, semanticModel, - home)); + home, + new HashSet(StringComparer.Ordinal))); } return warnings; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TestAttributeNames.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TestAttributeNames.cs index 4ad505a21b..976708727a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TestAttributeNames.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TestAttributeNames.cs @@ -23,6 +23,11 @@ internal static class TestAttributeNames + "'uloop run-tests --skip-compile' will not find or run it. " + "Run 'uloop compile' (or 'uloop run-tests' without --skip-compile) first."; + // The note on the entry of an added test method, so its row says what the warning says. + public const string AddedTestMethodLifecycleNote = + "Test method: not discovered by the Unity Test Runner until 'uloop compile'; " + + "'uloop run-tests --skip-compile' will not find or run it."; + private static readonly HashSet Names = new HashSet(StringComparer.Ordinal) { "Test", diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUse.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUse.cs new file mode 100644 index 0000000000..f32ae221f2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUse.cs @@ -0,0 +1,320 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +/// +/// A use, inside a method body, of an internal member declared by a compiled type of the target +/// assembly that the run does not declare: the worker's binding reports the member as missing, +/// while the shim compile binds it and the patched method can call it. +/// +/// +/// Why the worker reports such a member as missing rather than inaccessible: its binding +/// compilation imports a referenced assembly through the public surface only, so an internal +/// member of a compiled type is not there at all. The shim compile references a copy of the +/// target assembly with every member made public. +/// +internal sealed class UnpassedInternalMemberUse +{ + private const string SimpleNameLookupDiagnosticId = "CS0103"; + + private static readonly HashSet MemberNotFoundDiagnosticIds = + new HashSet(StringComparer.Ordinal) { SimpleNameLookupDiagnosticId, "CS1061", "CS0117" }; + + private UnpassedInternalMemberUse( + INamedTypeSymbol declaringType, + bool canBePatchedInPlace, + bool mayRunOutsideThePatchedMethod, + bool isSimpleNameLookup) + { + DeclaringType = declaringType; + CanBePatchedInPlace = canBePatchedInPlace; + MayRunOutsideThePatchedMethod = mayRunOutsideThePatchedMethod; + IsSimpleNameLookup = isSimpleNameLookup; + } + + /// The compiled type that declares the member, read with every member visible. + internal INamedTypeSymbol DeclaringType { get; } + + /// True when the patched method itself runs the use, so a guard may let the body through. + internal bool CanBePatchedInPlace { get; } + + /// + /// True when the use runs outside the patched method (in a closure, an async or iterator state + /// machine, or a delegating shim), or when a closure in the body works with a value the worker + /// could not resolve, so the worker cannot rule out that the closure reaches the member. + /// + internal bool MayRunOutsideThePatchedMethod { get; } + + /// + /// True when the lookup of a bare name failed (CS0103), such as an inherited member named + /// without 'this.'. Not a member name of an object initializer or a property pattern, which has + /// no receiver either but is looked up on the type being created or matched. + /// + internal bool IsSimpleNameLookup { get; } + + /// + /// The use reports, or null when the error is not a missing member that + /// turns out to be an internal member of a compiled type of the target assembly the run does + /// not declare. + /// + internal static UnpassedInternalMemberUse FindOrNull( + Diagnostic error, + SemanticModel semanticModel, + SyntaxNode bodyNode, + MethodDeclarationSyntax methodDeclarationOrNull, + MethodTransformDecision decision, + INamedTypeSymbol enclosingType, + IAssemblySymbol targetAssembly) + { + Debug.Assert(error != null, "error must not be null."); + Debug.Assert(semanticModel != null, "semanticModel must not be null."); + Debug.Assert(bodyNode != null, "bodyNode must not be null."); + Debug.Assert(decision != null, "decision must not be null."); + Debug.Assert(enclosingType != null, "enclosingType must not be null."); + + if (targetAssembly == null || !MemberNotFoundDiagnosticIds.Contains(error.Id)) + { + return null; + } + + SimpleNameSyntax name = FindReportedNameOrNull(error, bodyNode); + if (name == null) + { + return null; + } + + bool hasReceiver = HasReceiver(name); + INamedTypeSymbol start = hasReceiver ? FindReceiverTypeOrNull(name, semanticModel) : enclosingType; + if (start == null) + { + return null; + } + + ISymbol member = FindInternalMemberOfUnpassedTypeOrNull( + start, + name.Identifier.ValueText, + semanticModel, + targetAssembly); + if (member == null) + { + return null; + } + + bool mayRunOutsideThePatchedMethod = + RunsOutsideThePatchedMethod(name, bodyNode, methodDeclarationOrNull, decision) + || HasAClosureOverAnUnresolvedValue(bodyNode, semanticModel); + + // Why a bare name is out of reach: the shim is a static method, and it qualifies a bare + // member name only when the worker binds the name. This member never binds there, so the + // name would reach the shim compile unqualified and fail the whole file. + bool canBePatchedInPlace = hasReceiver + && IsPatchableKind(member, name) + && !mayRunOutsideThePatchedMethod; + return new UnpassedInternalMemberUse( + member.ContainingType, + canBePatchedInPlace, + mayRunOutsideThePatchedMethod, + error.Id == SimpleNameLookupDiagnosticId); + } + + private static SimpleNameSyntax FindReportedNameOrNull(Diagnostic error, SyntaxNode bodyNode) + { + Location location = error.Location; + if (!location.IsInSource + || location.SourceTree != bodyNode.SyntaxTree + || !bodyNode.Span.Contains(location.SourceSpan)) + { + return null; + } + + // Why a member binding is unwrapped: for 'value?.Name' the compiler reports the whole + // '.Name' binding, while for 'value.Name' and a bare name it reports the name itself. + SyntaxNode reported = bodyNode.FindNode(location.SourceSpan, getInnermostNodeForTie: true); + if (reported is MemberBindingExpressionSyntax binding) + { + return binding.Name; + } + + return reported as SimpleNameSyntax; + } + + // 'value.Name', 'Type.Name' and 'this.Name', or 'value?.Name'. + private static bool HasReceiver(SimpleNameSyntax name) + { + if (name.Parent is MemberAccessExpressionSyntax access) + { + return access.Name == name; + } + + return name.Parent is MemberBindingExpressionSyntax binding && binding.Name == name; + } + + private static INamedTypeSymbol FindReceiverTypeOrNull(SimpleNameSyntax name, SemanticModel semanticModel) + { + ExpressionSyntax receiver = name.Parent is MemberAccessExpressionSyntax access + ? access.Expression + : name.FirstAncestorOrSelf()?.Expression; + if (receiver == null) + { + return null; + } + + // Why not a type parameter, pointer, dynamic or Nullable receiver: the member is not + // looked up on a compiled type of the target assembly through any of them. + INamedTypeSymbol type = semanticModel.GetTypeInfo(receiver).Type as INamedTypeSymbol; + if (type == null || type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + return null; + } + + return type; + } + + private static ISymbol FindInternalMemberOfUnpassedTypeOrNull( + INamedTypeSymbol start, + string memberName, + SemanticModel semanticModel, + IAssemblySymbol targetAssembly) + { + INamedTypeSymbol compiled = FindInTargetAssemblyOrNull(start.OriginalDefinition, semanticModel, targetAssembly); + for (INamedTypeSymbol type = compiled; type != null; type = type.BaseType?.OriginalDefinition) + { + // Why only types of the target assembly: the shim compile makes every member public in + // the target assembly and in some project assemblies, and the worker cannot tell which + // others. The full import shows the internal members of another assembly's base type too. + if (!IsOfAssembly(type, targetAssembly)) + { + continue; + } + + // Why skip a type the run declares: its members come from the source parts the run + // reads, so a member missing there is a part the worker cannot see, which is what the + // missing-name reason explains. Its compiled copy would still list that member. + if (IsDeclaredByTheRun(type, semanticModel)) + { + continue; + } + + // Why internal only: another type's private member is a use the real compiler rejects + // too, and protected or protected internal members already bind in the worker. + foreach (ISymbol member in type.GetMembers(memberName)) + { + if (member.DeclaredAccessibility == Accessibility.Internal) + { + return member; + } + } + } + + return null; + } + + // Why names and not symbols: the target assembly comes from a separate compilation that + // imports every member, so its types are other symbols than the ones this body binds to. + private static INamedTypeSymbol FindInTargetAssemblyOrNull( + INamedTypeSymbol type, + SemanticModel semanticModel, + IAssemblySymbol targetAssembly) + { + IAssemblySymbol owner = type.ContainingAssembly; + if (owner == null) + { + return null; + } + + bool declaredByTheRun = owner.Identity.Equals(semanticModel.Compilation.Assembly.Identity); + if (!declaredByTheRun && !owner.Identity.Equals(targetAssembly.Identity)) + { + return null; + } + + return targetAssembly.GetTypeByMetadataName(ConstDriftCollector.ToReflectionMetadataName(type)); + } + + private static bool IsOfAssembly(INamedTypeSymbol type, IAssemblySymbol assembly) + { + return type.ContainingAssembly != null && type.ContainingAssembly.Identity.Equals(assembly.Identity); + } + + // Why the compilation's own assembly: it holds only the types the run declares from source, + // while Compilation.GetTypeByMetadataName would also find every referenced type. + private static bool IsDeclaredByTheRun(INamedTypeSymbol type, SemanticModel semanticModel) + { + return semanticModel.Compilation.Assembly.GetTypeByMetadataName(ConstDriftCollector.ToReflectionMetadataName(type)) != null; + } + + // Why only fields, properties and invoked methods: those are the uses a run has shown to bind + // in the shim and to work once patched. A method passed as a delegate and an event have not + // been run that way, so they stay skipped. + private static bool IsPatchableKind(ISymbol member, SimpleNameSyntax name) + { + if (member is IFieldSymbol || member is IPropertySymbol) + { + return true; + } + + return member is IMethodSymbol method && method.MethodKind == MethodKind.Ordinary && IsInvoked(name); + } + + private static bool IsInvoked(SimpleNameSyntax name) + { + SyntaxNode callee = HasReceiver(name) ? name.Parent : name; + return callee.Parent is InvocationExpressionSyntax invocation && invocation.Expression == callee; + } + + // Why these places are out of reach: a closure body, an async or iterator state machine, and a + // delegating shim run as ordinary code of the shim assembly, which the runtime checks for + // access, so a call to an internal member there throws MethodAccessException after the run + // reported success. Only the statements copied into the patched method skip that check. + private static bool RunsOutsideThePatchedMethod( + SyntaxNode name, + SyntaxNode bodyNode, + MethodDeclarationSyntax methodDeclarationOrNull, + MethodTransformDecision decision) + { + if (decision.UsesDelegation) + { + return true; + } + + if (MethodTransformDecider.IsAsyncOrIterator(methodDeclarationOrNull, bodyNode)) + { + return true; + } + + foreach (SyntaxNode closureBody in MethodTransformDecider.FindClosureBodies(bodyNode)) + { + if (closureBody.Span.Contains(name.Span)) + { + return true; + } + } + + return false; + } + + // Why a closure over a value the worker could not resolve keeps every use in the body out: the + // worker reports no error for a use that follows such a value, like the result of an internal + // member, so it cannot tell whether the closure reaches an internal member through it. A closure + // runs as ordinary code of the shim assembly, where that use throws once called. The method's + // own statements run inside the patched method, where a following internal use works, so only + // closures are checked. + private static bool HasAClosureOverAnUnresolvedValue(SyntaxNode bodyNode, SemanticModel semanticModel) + { + foreach (SyntaxNode closureBody in MethodTransformDecider.FindClosureBodies(bodyNode)) + { + foreach (SyntaxNode node in closureBody.DescendantNodesAndSelf()) + { + if (node is ExpressionSyntax expression + && semanticModel.GetTypeInfo(expression).Type?.TypeKind == TypeKind.Error) + { + return true; + } + } + } + + return false; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnresolvedBodyNameGuard.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnresolvedBodyNameGuard.cs new file mode 100644 index 0000000000..9dee9ab115 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnresolvedBodyNameGuard.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +/// +/// Skips an edited body that names something the worker cannot resolve, where emitting it would +/// break. On a partial type, a name no visible part declares is skipped instead of failing the +/// whole file in the shim compile: a part generated at compile time is invisible to the worker, +/// so the name may be perfectly valid. An internal member of a compiled type the run was not given +/// looks just as missing to the worker, on a partial or a plain type; a use of it the patched +/// method cannot reach is skipped with a reason that says why. +/// +internal static class UnresolvedBodyNameGuard +{ + // Why only these: other binding errors are expected (a compiled API still expects the + // compiled copy of a type the run declares from source) and the shim compile settles + // them. An unresolved name is what a missing part looks like, and it cannot compile in the + // shim either, except for an internal member of a compiled type of the target assembly, + // which the shim compile sees. + private static readonly HashSet UnresolvedNameDiagnosticIds = + new HashSet(StringComparer.Ordinal) { "CS0103", "CS1061", "CS0117", "CS0246" }; + + /// + /// The skip reason for the body, or null when every name in it the worker cannot resolve is an + /// internal member the patched method can reach, or, on a plain type, is not such a member at + /// all, which the shim compile settles as before. + /// + internal static WorkerReason DescribeSkipOrNull( + TypeDeclarationSyntax typeDeclaration, + SemanticModel semanticModel, + SyntaxNode bodyNode, + MethodDeclarationSyntax methodDeclarationOrNull, + MethodTransformDecision decision, + INamedTypeSymbol typeSymbol, + IAssemblySymbol targetAssembly) + { + if (bodyNode == null) + { + return null; + } + + bool isPartial = PartialTypeParts.IsPartialOrNestedInPartial(typeDeclaration); + + // Why every error and not the first: an internal member the patched method can reach says + // nothing about the next error, which may be a name nothing declares. + foreach (Diagnostic diagnostic in semanticModel.GetDiagnostics(bodyNode.Span)) + { + if (diagnostic.Severity != DiagnosticSeverity.Error) + { + continue; + } + + if (!UnresolvedNameDiagnosticIds.Contains(diagnostic.Id)) + { + continue; + } + + string diagnosticText = diagnostic.Id + ": " + diagnostic.GetMessage(CultureInfo.InvariantCulture); + UnpassedInternalMemberUse use = UnpassedInternalMemberUse.FindOrNull( + diagnostic, + semanticModel, + bodyNode, + methodDeclarationOrNull, + decision, + typeSymbol, + targetAssembly); + if (use == null) + { + if (isPartial) + { + return WorkerReason.Of(HotReloadWorkerReasonCode.MethodTransformPartialBodyUnbound, diagnosticText); + } + + // Why a plain type goes on: some of these names bind in the shim compile, such as an + // internal member of another assembly that grants access through InternalsVisibleTo, + // and such a body is patched and runs today. The shim compile reports the rest. + continue; + } + + if (IsWithinReach(use, isPartial)) + { + continue; + } + + return WorkerReason.Of( + HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberOutOfReach, + diagnosticText, + "'" + use.DeclaringType.Name + "'"); + } + + return null; + } + + // Why the two rules differ: a partial type skips a body whose names do not resolve, and lets + // through only the uses a run has shown to work in place. A plain type emits such a body, so + // only the uses known to break are closed there: one that runs outside the patched method, + // where the runtime checks access and the call throws, and a bare name, which reaches the shim + // compile unqualified and fails the whole file. A method passed as a delegate, an event, and a + // member named in an object initializer or a property pattern stay patched on a plain type, + // as they were. + private static bool IsWithinReach(UnpassedInternalMemberUse use, bool isPartial) + { + if (isPartial) + { + return use.CanBePatchedInPlace; + } + + return !use.MayRunOutsideThePatchedMethod && !use.IsSimpleNameLookup; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnresolvedSignatureTypes.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnresolvedSignatureTypes.cs new file mode 100644 index 0000000000..51589a1969 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnresolvedSignatureTypes.cs @@ -0,0 +1,73 @@ +using Microsoft.CodeAnalysis; + +// Finds a type in a method signature that the worker compilation could not bind. An +// inaccessible type is not unresolved: an internal type of the compiled assembly binds as an +// error type with CandidateReason.Inaccessible, its candidate still names the compiled type, and +// such a method matches its compiled signature, so only a missing type counts. +internal static class UnresolvedSignatureTypes +{ + internal static bool TryFindInSignature(IMethodSymbol methodSymbol, out ITypeSymbol unresolvedType) + { + if (TryFind(methodSymbol.ReturnType, out unresolvedType)) + { + return true; + } + + foreach (IParameterSymbol parameter in methodSymbol.Parameters) + { + if (TryFind(parameter.Type, out unresolvedType)) + { + return true; + } + } + + return false; + } + + // Same walk as AddedFieldClassifier.TryFindUnresolvedType (error type, array element, type + // arguments), except that an inaccessible error type is treated as resolved. That walk stays + // as it is so added fields and properties keep reporting what they report today. + internal static bool TryFind(ITypeSymbol typeSymbol, out ITypeSymbol unresolvedType) + { + unresolvedType = null; + if (typeSymbol == null) + { + return false; + } + + if (typeSymbol is IErrorTypeSymbol errorType) + { + if (errorType.CandidateReason == CandidateReason.Inaccessible + && errorType.CandidateSymbols.Length > 0) + { + return false; + } + + unresolvedType = typeSymbol; + return true; + } + + if (typeSymbol is ITypeParameterSymbol) + { + return false; + } + + if (typeSymbol is IArrayTypeSymbol arrayType) + { + return TryFind(arrayType.ElementType, out unresolvedType); + } + + if (typeSymbol is INamedTypeSymbol namedType) + { + foreach (ITypeSymbol typeArgument in namedType.TypeArguments) + { + if (TryFind(typeArgument, out unresolvedType)) + { + return true; + } + } + } + + return false; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerEntry.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerEntry.cs index 042f9d532a..6579a036c6 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerEntry.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerEntry.cs @@ -43,7 +43,8 @@ internal sealed class WorkerEntry public int SourceEndLine { get; set; } - // Null when the method is not a one-shot lifecycle method and is not only called from them. + // Null unless the method is a one-shot lifecycle method or an added method with a test + // attribute. public string LifecycleNote { get; set; } public bool ReplacesCompiledMethod { get; set; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerGlobalUsingBindingTree.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerGlobalUsingBindingTree.cs new file mode 100644 index 0000000000..d08ab27ff6 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerGlobalUsingBindingTree.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +// Builds the syntax tree that carries the assembly's global using directives into a worker +// compilation. The compilations hold only the edited files, so a type a sibling file imports +// with a global using would otherwise not bind, and an existing method whose signature names +// such a type would be classified as added. +internal static class WorkerGlobalUsingBindingTree +{ + internal const string TreePath = "UloopHotReloadAssemblyGlobalUsings.cs"; + + // assemblyGlobalUsings is the list WorkerUsingCollector.CollectAssemblyGlobalUsings returns + // (global keyword already stripped). Returns null when every directive is already declared + // as a global using by one of the roots that go into the same compilation, so the tree never + // repeats a directive the compilation already has. + internal static SyntaxTree Build( + IReadOnlyList assemblyGlobalUsings, + IReadOnlyList rootsInCompilation, + CSharpParseOptions parseOptions) + { + List declaredByRoots = CollectRootGlobalUsings(rootsInCompilation); + List remaining = new List(); + foreach (UsingDirectiveSyntax assemblyUsing in assemblyGlobalUsings) + { + // Why compare the stripped directive as it is: UsingDirectivesMatch ignores the global + // keyword, so it matches the roots' own global directives directly. + if (WorkerUsingCollector.ContainsEquivalentUsing(declaredByRoots, assemblyUsing)) + { + continue; + } + + remaining.Add(assemblyUsing.WithGlobalKeyword(SyntaxFactory.Token(SyntaxKind.GlobalKeyword))); + } + + if (remaining.Count == 0) + { + return null; + } + + // Why directives only: a declaration here would collide with the compiled reference + // (CS0436) and change what the edited files bind to. + CompilationUnitSyntax unit = SyntaxFactory.CompilationUnit() + .WithUsings(SyntaxFactory.List(remaining)) + .NormalizeWhitespace(); + return CSharpSyntaxTree.Create(unit, parseOptions, TreePath, Encoding.UTF8); + } + + // Returns the trees with the global-using tree appended, or the trees unchanged when there is none. + internal static List Append(IReadOnlyList trees, SyntaxTree globalUsingTree) + { + List appended = new List(trees.Count + 1); + appended.AddRange(trees); + if (globalUsingTree != null) + { + appended.Add(globalUsingTree); + } + + return appended; + } + + private static List CollectRootGlobalUsings( + IReadOnlyList rootsInCompilation) + { + List declaredByRoots = new List(); + foreach (CompilationUnitSyntax root in rootsInCompilation) + { + foreach (UsingDirectiveSyntax usingDirective in root.Usings) + { + if (usingDirective.GlobalKeyword.IsKind(SyntaxKind.GlobalKeyword)) + { + declaredByRoots.Add(usingDirective); + } + } + } + + return declaredByRoots; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerGroupPipeline.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerGroupPipeline.cs index 86c56c574c..643bacd8dd 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerGroupPipeline.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerGroupPipeline.cs @@ -71,6 +71,21 @@ internal static WorkerOutput Transform(WorkerInput input) } List transformUnits = SelectTransformableUnits(loadedUnits); + List editedRoots = new List(transformUnits.Count); + foreach (WorkerSourceUnit transformUnit in transformUnits) + { + editedRoots.Add(transformUnit.Root); + } + + // 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); + + // 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); // 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 @@ -81,7 +96,8 @@ internal static WorkerOutput Transform(WorkerInput input) transformUnits, references, targetTypesReference, - parseOptions); + parseOptions, + globalUsingTree); if (artifactFailure != null) { return CreateRunFailureOutput(artifactFailure); @@ -93,9 +109,11 @@ internal static WorkerOutput Transform(WorkerInput input) bindingTrees.Add(transformUnit.BindingSyntaxTree); } + bindingTrees.AddRange(partialTypeParts.BindingOnlyTrees); + CSharpCompilation compilation = CSharpCompilation.Create( assemblyName: "UloopHotReloadTransformWorkerCompilation", - syntaxTrees: bindingTrees, + syntaxTrees: WorkerGlobalUsingBindingTree.Append(bindingTrees, globalUsingTree), references: references, options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); Dictionary projectRelativePathsByBindingTree = new Dictionary(); @@ -108,24 +126,18 @@ internal static WorkerOutput Transform(WorkerInput input) { unit.SemanticModel = compilation.GetSemanticModel(unit.BindingSyntaxTree, ignoreAccessibility: true); unit.RunProjectRelativePathsByBindingTree = projectRelativePathsByBindingTree; + unit.PartialTypeParts = partialTypeParts; } WorkerTypeHome home = new WorkerTypeHome( input.TargetAssemblyName, WorkerCompiledAssemblySymbols.ResolveWithAllMembers(compilation, targetTypesReference)); - List editedRoots = new List(transformUnits.Count); - foreach (WorkerSourceUnit transformUnit in transformUnits) - { - editedRoots.Add(transformUnit.Root); - } - - List assemblyGlobalUsings = - WorkerUsingCollector.CollectAssemblyGlobalUsings(input, parseOptions, editedRoots); List siblingConstDriftWarnings = SiblingConstDriftCollector.CollectConstDriftWarnings( input.ChangedSiblingSourcePaths, parseOptions, references, - home); + home, + assemblyGlobalUsings); List entries = new List(); List skipped = new List(); @@ -135,6 +147,9 @@ internal static WorkerOutput Transform(WorkerInput input) AddedFieldCatalog addedFieldCatalog = new AddedFieldCatalog(); AddedPropertyCatalog addedPropertyCatalog = new AddedPropertyCatalog(); ShimNameAllocator shimNames = new ShimNameAllocator(); + // Why one set for the run: every file that declares a part of a partial type binds to the + // whole merged type, so a const of it would otherwise be reported once per passed part. + HashSet constDriftSeenTypeMetadataNames = new HashSet(StringComparer.Ordinal); foreach (WorkerSourceUnit unit in transformUnits) { QueueUnit( @@ -142,6 +157,7 @@ internal static WorkerOutput Transform(WorkerInput input) input, parseOptions, home, + constDriftSeenTypeMetadataNames, assemblyGlobalUsings, shimTypes, addedMethodCatalog, @@ -288,6 +304,7 @@ private static void QueueUnit( WorkerInput input, CSharpParseOptions parseOptions, WorkerTypeHome home, + HashSet constDriftSeenTypeMetadataNames, List assemblyGlobalUsings, List shimTypes, AddedMethodCatalog addedMethodCatalog, @@ -301,7 +318,8 @@ private static void QueueUnit( ConstDriftCollector.CollectConstDriftWarnings( unit.BindingRoot, unit.SemanticModel, - home)); + home, + constDriftSeenTypeMetadataNames)); // Why here: a compiled property/event can disappear or change kind with no // touched body, so the generic outside-body warning would bury the name. unit.KindChangeSyntaxKeys = diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerInput.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerInput.cs index 8f1f45d32b..668c9df027 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerInput.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerInput.cs @@ -50,6 +50,10 @@ internal sealed class WorkerInput // Null/omitted is treated as empty (no sibling const-drift scan). public string[] ChangedSiblingSourcePaths { get; set; } + // False/omitted means ChangedSiblingSourcePaths may be incomplete, so no other part of a + // partial type is trusted. + public bool ChangedSiblingScanComplete { get; set; } + // Retained introduced-type assemblies this run may bind against. // Null/omitted is treated as empty (nothing is normalized through an artifact). public WorkerIntroducedTypeArtifact[] IntroducedTypeArtifacts { get; set; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerSourceUnit.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerSourceUnit.cs index 5bf69c680c..be911647e4 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerSourceUnit.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerSourceUnit.cs @@ -101,6 +101,11 @@ internal sealed class WorkerSourceUnit public IReadOnlyDictionary RunProjectRelativePathsByBindingTree { get; set; } = new Dictionary(); + // The run's knowledge of the parts of its partial types in other files. Why Unverified until a + // pipeline loads them: a pipeline that never does must keep skipping partial types rather than + // bind them against a type that is missing those parts. + public PartialTypeParts PartialTypeParts { get; set; } = PartialTypeParts.Unverified; + public CompiledMemberKindChangeWarnings.SyntaxKeys KindChangeSyntaxKeys { get; set; } public List TypeEmitStates { get; set; } = new List(); diff --git a/Packages/src/Editor/FirstPartyTools/RunTests/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/RunTests/Skill/SKILL.md index 2b20b507cd..08e4579b22 100644 --- a/Packages/src/Editor/FirstPartyTools/RunTests/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/RunTests/Skill/SKILL.md @@ -8,13 +8,13 @@ description: "Run Unity Test Runner and report detailed results. Use for EditMod Execute Unity Test Runner. When a test or suite fails or a test ends inconclusive, NUnit XML results with failure messages, stack traces, and inconclusive reasons are automatically saved. Read the XML file at `XmlPath` for detailed diagnosis. -`uloop run-tests` automatically compiles pending script changes before running tests. Pass `--skip-compile` only while validating active hot-reload patches, because the compile clears those patches; otherwise let the default compile surface errors and run against current scripts. `--skip-compile` skips only the CLI-side compile: Unity still imports script edits saved since the last compile, and that import reloads the domain as soon as the run releases its assembly lock, discarding active patches and ending the request. +`uloop run-tests` automatically compiles pending script changes before running tests. Pass `--skip-compile` only while validating active hot-reload patches, because the compile clears those patches; otherwise let the default compile surface errors and run against current scripts. `--skip-compile` skips only the CLI-side compile: script edits saved since the last compile can still be imported during the run; such an import reloads the domain as soon as the run releases its assembly lock, discarding active patches and ending the request. While hot reload holds Auto Refresh (the state an apply leaves), a verified run kept the patches live. Check `uloop hot-reload --status` after the run instead of assuming either outcome. Before executing tests, `uloop run-tests` handles unsaved loaded Scene and Prefab Stage changes according to `--unsaved-changes` (default `save`): `save` writes them first, `fail` stops if any remain, and `discard` reloads disk state so tests run against saved files. Untitled scenes cannot be discarded and fail. If the chosen mode cannot proceed, it returns `Success: false`, keeps `TestCount` at `0`, lists the items in `Message`, and does not start the Unity Test Runner. Active pause points are automatically cleared (the underlying code patches are removed as well) before test execution begins. Cleared IDs are reported in the response's `ClearedPausePointIds` field. -A test run can end by discarding active hot-reload changes: script edits imported during the run are compiled when the test runner releases its assembly-reload lock, and that deferred domain reload wipes the patches even though the tests themselves ran patched. The response's Warning field reports this; re-apply 'uloop hot-reload' or bake the edits in with 'uloop compile' before the next Play or test run. +A test run can end by discarding active hot-reload changes: script edits imported during the run are compiled when the test runner releases its assembly-reload lock, and that deferred domain reload wipes the patches even though the tests themselves ran patched. The response's Warning field appears whenever changes were live when the run started and does not say whether they survived; check 'uloop hot-reload --status', then re-apply 'uloop hot-reload' or bake the edits in with 'uloop compile' before the next Play or test run. `NoTestsFound` means zero tests matched — not a test failure. Check `NoTestsFoundExplanation` and `Message` for asmdef hints. When an unfiltered run finds no tests and the project has no test assembly for the TestMode, `ProposedTestAsmdef` carries a ready-to-write `.asmdef`: save `Content` at `AssetPath`, move the test scripts under that folder, then compile and rerun. @@ -34,7 +34,7 @@ uloop run-tests [options] | `--filter-type` | string | `all` | Filter type: `all`, `exact`, `regex`, `assembly`, `class` | | `--filter-value` | string | - | Filter value (test name, pattern, assembly, or class name) | | `--unsaved-changes` | string | `save` | `save` writes unsaved Scene/Prefab Stage changes; `fail` stops if any remain; `discard` reloads disk state (Untitled scenes fail) | -| `--skip-compile` | flag | - | Skip the CLI-side compile before running tests; use only while validating active hot-reload patches. Unity still imports script edits saved since the last compile. | +| `--skip-compile` | flag | - | Skip the CLI-side compile before running tests; use only while validating active hot-reload patches. Script edits saved since the last compile can still be imported during the run. | | `--timeout-seconds` | integer | `600` | Maximum seconds to wait for RunFinished before canceling the await (max `1500`). Increase for long suites; on timeout the Test Runner may still be running until stop handling lands | | `--respect-enter-play-mode-settings` | flag | - | PlayMode only: keep the project's Enter Play Mode settings instead of forcing Domain Reload off. A Domain Reload during the run is survived; the result is recovered after the reload. Use for projects whose libraries require a Domain Reload on Play entry. | | `--rerun-failed` | flag | - | Rerun only the tests that failed or were inconclusive in the most recent completed run of the same --test-mode (whole fixtures for a failed OneTimeSetUp/OneTimeTearDown). Cannot be combined with --filter-type or --filter-value | diff --git a/cli/dispatcher/internal/dispatcher/skills_dispatch.go b/cli/dispatcher/internal/dispatcher/skills_dispatch.go index 0220937aa0..607bcd6190 100644 --- a/cli/dispatcher/internal/dispatcher/skills_dispatch.go +++ b/cli/dispatcher/internal/dispatcher/skills_dispatch.go @@ -209,9 +209,18 @@ func runSkillsInstallWithGuidance( stdout io.Writer, stderr io.Writer, ) int { + // The help text promises that targets already holding uloop skills never go stale, even + // when their flag is omitted, so only ask where to install when no target holds one yet. if len(options.targets) == 0 { - printSkillsTargetGuidance("install", stdout) - return 0 + detected, err := detectInstalledSkillTargets(projectRoot, skills, options) + if err != nil { + clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ProjectRoot: projectRoot, Command: clicore.SkillsCommandName}) + return 1 + } + if len(detected) == 0 { + printSkillsTargetGuidance("install", stdout) + return 0 + } } return runSkillsInstall(projectRoot, skills, options, stdout, stderr) } diff --git a/cli/dispatcher/internal/dispatcher/skills_dispatch_test.go b/cli/dispatcher/internal/dispatcher/skills_dispatch_test.go index 88ffab8bc0..ab018e2d25 100644 --- a/cli/dispatcher/internal/dispatcher/skills_dispatch_test.go +++ b/cli/dispatcher/internal/dispatcher/skills_dispatch_test.go @@ -72,7 +72,7 @@ func TestTryHandleSkillsRequestInstallListUninstallRoundTrip(t *testing.T) { } func TestTryHandleSkillsRequestPrintsTargetGuidanceWithoutTargets(t *testing.T) { - // Verifies install and uninstall without target flags only print guidance and change nothing. + // Verifies install and uninstall without target flags only print guidance and change nothing when no target holds a uloop skill yet. projectRoot := createSkillsTestProject(t) for _, subcommand := range []string{"install", "uninstall"} { code, stdout, stderr := runSkillsRequestForTest(t, projectRoot, subcommand) @@ -85,6 +85,86 @@ func TestTryHandleSkillsRequestPrintsTargetGuidanceWithoutTargets(t *testing.T) } } +func TestRunSkillsSubcommandInstallWithoutTargetRefreshesDetectedInstall(t *testing.T) { + // Verifies install without a target flag refreshes a target that already holds a uloop skill instead of printing guidance. + root := t.TempDir() + skill := writeDirModeSkillSource(t, root, "uloop-sample") + skills := []skillDefinition{skill} + claudeOptions := skillCommandOptions{targets: []skillTarget{targetConfigs["claude"]}} + var setupStderr bytes.Buffer + if code := runSkillsSubcommand("install", root, skills, claudeOptions, &bytes.Buffer{}, &setupStderr); code != 0 { + t.Fatalf("initial install failed: code=%d stderr=%s", code, setupStderr.String()) + } + baseDir, err := getSkillsBaseDir(root, targetConfigs["claude"], claudeOptions.global) + if err != nil { + t.Fatalf("failed to resolve the skills base dir: %v", err) + } + installedSkillFile := filepath.Join(getPreferredSkillDir(baseDir, skill.name, groupManagedSkillsForOptions(claudeOptions)), "SKILL.md") + writeDispatcherTestFile(t, installedSkillFile, "stale") + sourceContent, err := os.ReadFile(filepath.Join(skill.sourceDirectory, "SKILL.md")) + if err != nil { + t.Fatalf("failed to read the skill source: %v", err) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runSkillsSubcommand("install", root, skills, skillCommandOptions{}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("install without a target failed: code=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "Auto-refreshing") || strings.Contains(stdout.String(), "Please specify at least one target") { + t.Fatalf("install without a target must refresh the detected install instead of printing guidance:\n%s", stdout.String()) + } + assertFileContent(t, installedSkillFile, string(sourceContent)) +} + +func TestRunSkillsSubcommandInstallWithoutTargetAndNoInstallPrintsGuidance(t *testing.T) { + // Verifies install without a target flag still prints guidance and writes no skill when no target holds a uloop skill. + root := t.TempDir() + skill := writeDirModeSkillSource(t, root, "uloop-sample") + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runSkillsSubcommand("install", root, []skillDefinition{skill}, skillCommandOptions{}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("install without a target failed: code=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "Please specify at least one target for 'install'") || strings.Contains(stdout.String(), "Auto-refreshing") { + t.Fatalf("install without a target must only print guidance when nothing is installed:\n%s", stdout.String()) + } + walkErr := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() && path == skill.sourceDirectory { + return filepath.SkipDir + } + if !entry.IsDir() && entry.Name() == "SKILL.md" { + t.Errorf("install without a target must not write a skill file: %s", path) + } + return nil + }) + if walkErr != nil { + t.Fatalf("failed to walk the project root: %v", walkErr) + } +} + +func TestRunSkillsSubcommandInstallWithoutTargetReportsDetectionErrors(t *testing.T) { + // Verifies install without a target flag fails with code 1 instead of printing guidance when installed targets cannot be detected. + stubSkillsUserHomeDir(t, "", errors.New("home unavailable")) + skill := skillDefinition{name: "uloop-sample", content: []byte(sampleSkillContent)} + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runSkillsSubcommand("install", t.TempDir(), []skillDefinition{skill}, skillCommandOptions{global: true}, &stdout, &stderr) + + if code != 1 || !strings.Contains(stderr.String(), "home unavailable") || strings.Contains(stdout.String(), "Please specify at least one target") { + t.Fatalf("expected the detection error: code=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } +} + func TestTryHandleSkillsRequestInstallsIntoOutputDir(t *testing.T) { // Verifies --output-dir routes the request to dir mode and installs and removes the skill there. projectRoot := createSkillsTestProject(t) diff --git a/cli/project-runner/internal/projectrunner/compile_fresh_recovery.go b/cli/project-runner/internal/projectrunner/compile_fresh_recovery.go new file mode 100644 index 0000000000..359e48de3b --- /dev/null +++ b/cli/project-runner/internal/projectrunner/compile_fresh_recovery.go @@ -0,0 +1,188 @@ +package projectrunner + +import ( + "context" + "encoding/json" + "errors" + "io" + "time" + + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/common/vibelog" + + "github.com/hatayama/unity-cli-loop/common/clicore" + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +const ( + // The first send and two resends. + freshCompileMaxAttempts = 3 + // Why: with less wait time left, a resent compile would start in Unity just as the command + // times out, which adds a compile and turns a rejection into a timeout. + compileResendMinimumBudget = 10 * time.Second +) + +var errCompileRequestMissing = errors.New("the compile request has no record in Unity") + +// freshCompileAttemptOutcome says how one fresh compile attempt ended, so the caller can decide +// whether to send the compile again. +type freshCompileAttemptOutcome int + +const ( + // The returned result is the command's result. + freshCompileAttemptFinal freshCompileAttemptOutcome = iota + // Unity lost the request, so sending it again is safe. + freshCompileAttemptRequestMissing + // Unity rejected the compile because it was compiling or updating, and the wait has seen it + // Ready since. + freshCompileAttemptEditorBusy +) + +// freshCompileAttemptOptions configures one fresh compile attempt. +type freshCompileAttemptOptions struct { + // Zero never reports RequestMissing or EditorBusy, which keeps the behavior of a compile that + // is never sent again. Otherwise they are reported only while at least + // compileResendMinimumBudget is left before this moment. + resendBefore time.Time + // Zero waits as long as --timeout-seconds says, for the first attempt and for entries that + // never send again. A positive value is this attempt's wait limit. + timeoutOverride time.Duration +} + +// runFreshCompileRecoveringWithDeps sends a fresh compile and sends it again when Unity lost the +// request or rejected it because it was still compiling or updating, at most +// freshCompileMaxAttempts sends in all within the one wait the caller asked for. +func runFreshCompileRecoveringWithDeps( + ctx context.Context, + connection unityipc.Connection, + params map[string]any, + stderr io.Writer, + compileWait compileWaitDeps, +) compileExecutionResult { + waitTimeout, err := compileWaitTimeoutFromParams(params) + if err != nil { + // The entry that never resends reports an invalid --timeout-seconds the way it always has. + return runFreshCompileWithDomainReloadWaitResultWithDeps(ctx, connection, params, stderr, compileWait) + } + resendBefore := time.Now().Add(waitTimeout) + options := freshCompileAttemptOptions{resendBefore: resendBefore} + for attempt := 1; ; attempt++ { + if attempt >= freshCompileMaxAttempts { + // Why: the last attempt behaves exactly like a compile that is never resent, so reaching + // the limit never makes a new kind of failure. + options.resendBefore = time.Time{} + } + result, outcome := runFreshCompileAttempt(ctx, connection, params, stderr, compileWait, options) + if outcome == freshCompileAttemptFinal { + return result + } + logCompileRequestResend(connection, params, outcome, attempt) + // Why a new request ID: Unity keeps the rejection it stored under the old one, and a status + // query with that ID would return it at once. + delete(params, compileRequestIDParam) + // Why positive: an attempt reports a resend only while canResendCompile holds. + options.timeoutOverride = time.Until(resendBefore) + } +} + +// compileRequestMissingTracker recognizes a request that Unity lost: Ready answers without a result +// that keep coming after Unity's server was recreated by a domain reload or a restart. +type compileRequestMissingTracker struct { + serverRestartSeen bool + missingStreak int +} + +// observe records one status query and reports whether the request is now known to be lost. +// Why a restart must be seen first: a request that reached Unity's main thread has a result by the +// first Ready answer after a domain reload, because Unity builds one from the pending request it +// registered there. So Ready answers without a result after the server was recreated come only +// from a request that never got there. Without a restart, a live request answers the same way for +// the seconds before its compile starts, and a resend then would be rejected by the single-flight +// slot the first request still holds. +func (tracker *compileRequestMissingTracker) observe(status compileStatusResponse, err error) bool { + if err != nil { + if isServerGoneError(err) { + tracker.serverRestartSeen = true + } + tracker.missingStreak = 0 + return false + } + if status.IsDomainReloadInProgress { + tracker.serverRestartSeen = true + } + if !status.Ready || status.HasResult { + tracker.missingStreak = 0 + return false + } + tracker.missingStreak++ + return tracker.serverRestartSeen && tracker.missingStreak >= compileAttachMissingResultStreak +} + +// isServerGoneError reports whether a failed status query shows that Unity's server went away: the +// connection dropped mid-query, or nobody was listening. +func isServerGoneError(err error) bool { + if clierrors.IsTransportDisconnectError(err) { + return true + } + var connectionErr *unityipc.ConnectionAttemptError + if !errors.As(err, &connectionErr) { + return false + } + // Why not a connect timeout or a denied connect: a Windows named pipe times out while all of its + // instances are busy, and a sandbox can deny the connect, both while the server is alive. + return !clierrors.IsFinalResponseTimeoutError(err) && !clierrors.IsPermanentConnectError(err) +} + +// canResendCompile reports whether at least compileResendMinimumBudget is left before resendBefore. +// A zero resendBefore never allows a resend. +func canResendCompile(resendBefore time.Time) bool { + return !resendBefore.IsZero() && time.Until(resendBefore) >= compileResendMinimumBudget +} + +type compileErrorCodeProbe struct { + ErrorCode string `json:"ErrorCode"` +} + +// isCompileEditorBusyRejection reports whether a compile result only says that Unity was still +// compiling or updating when the request arrived. +// Why ErrorCode only: the collision is a structured compile result, not a message string. +func isCompileEditorBusyRejection(raw []byte) bool { + var probe compileErrorCodeProbe + if json.Unmarshal(raw, &probe) != nil { + return false + } + return probe.ErrorCode == compileAlreadyInProgressErrorCode || + probe.ErrorCode == compileEditorUpdatingErrorCode +} + +func logCompileRequestResend( + connection unityipc.Connection, + params map[string]any, + outcome freshCompileAttemptOutcome, + attempt int, +) { + requestID, _ := params[compileRequestIDParam].(string) + writeCompileVibeLog(connection.ProjectRoot, func() vibelog.CLIVibeLogEntry { + return vibelog.CLIVibeLogEntry{ + Level: "INFO", + Operation: "cli_compile_request_resend", + Message: "Sending the compile request again.", + Context: map[string]any{ + "command": clicore.CompileCommandName, + "request_id": requestID, + "reason": compileResendReason(outcome), + "attempt": attempt, + "project_identity": vibelog.ProjectIdentity(connection.ProjectRoot), + "endpoint": connection.Endpoint.Address, + }, + CorrelationID: requestID, + } + }) +} + +func compileResendReason(outcome freshCompileAttemptOutcome) string { + if outcome == freshCompileAttemptEditorBusy { + return "editor_busy" + } + return "request_missing" +} diff --git a/cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go b/cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go new file mode 100644 index 0000000000..937ee396ef --- /dev/null +++ b/cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go @@ -0,0 +1,648 @@ +package projectrunner + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "strings" + "testing" + "time" + + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +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. + compileRecoveryDefinitiveResult = `{"Success":false,"ErrorCount":1,"WarningCount":0,"ErrorCode":null}` + compileRecoveryAlreadyInProgressResult = `{"Success":false,"ErrorCode":"COMPILE_ALREADY_IN_PROGRESS","ErrorCount":1}` +) + +// compileRecoverySend is one scripted end of a compile send. +type compileRecoverySend struct { + outcome unityipc.UnitySendOutcome + err error +} + +// compileRecoveryAnswer is one scripted answer to a compile status query. +type compileRecoveryAnswer struct { + status compileStatusResponse + err error +} + +// compileRecoveryScenario scripts the compile sends in order and, for the request each send +// carried, the status answers in order. A request's last answer repeats once its script runs out. +type compileRecoveryScenario struct { + t *testing.T + sends []compileRecoverySend + answers [][]compileRecoveryAnswer + sentIDs []string + attemptOf map[string]int + nextAnswer map[string]int + queries map[string]int + totalQueries int + cancel context.CancelFunc + cancelAttempt int + cancelAtQuery int +} + +func newCompileRecoveryScenario( + t *testing.T, + sends []compileRecoverySend, + answers ...[]compileRecoveryAnswer, +) *compileRecoveryScenario { + t.Helper() + return &compileRecoveryScenario{ + t: t, + sends: sends, + answers: answers, + attemptOf: map[string]int{}, + nextAnswer: map[string]int{}, + queries: map[string]int{}, + } +} + +// cancelWhen cancels the command once the request of the given send (zero-based) has been queried +// queryCount times. +func (scenario *compileRecoveryScenario) cancelWhen(cancel context.CancelFunc, attempt int, queryCount int) { + scenario.cancel = cancel + scenario.cancelAttempt = attempt + scenario.cancelAtQuery = queryCount +} + +func (scenario *compileRecoveryScenario) deps() compileWaitDeps { + deps := compileWaitTestDeps(scenario.query) + deps.sendCompile = scenario.send + deps.freshWaitPollInterval = time.Millisecond + deps.startStallFocusThreshold = time.Hour + return deps +} + +func (scenario *compileRecoveryScenario) send( + _ context.Context, + _ unityipc.Connection, + _ string, + params map[string]any, + _ unityipc.ProgressFunc, + _ time.Duration, +) (unityipc.UnitySendOutcome, error) { + attempt := len(scenario.sentIDs) + if attempt >= len(scenario.sends) { + scenario.t.Fatalf("unexpected compile send #%d: the scenario allows %d", attempt+1, len(scenario.sends)) + } + requestID, _ := params[compileRequestIDParam].(string) + scenario.sentIDs = append(scenario.sentIDs, requestID) + scenario.attemptOf[requestID] = attempt + scenario.nextAnswer[requestID] = 0 + step := scenario.sends[attempt] + return step.outcome, step.err +} + +func (scenario *compileRecoveryScenario) query( + ctx context.Context, + _ unityipc.Connection, + requestID string, +) (compileStatusResponse, error) { + scenario.totalQueries++ + if scenario.totalQueries > compileRecoveryQueryLimit { + scenario.t.Fatalf("compile status was queried more than %d times: the wait never ended", compileRecoveryQueryLimit) + } + attempt, ok := scenario.attemptOf[requestID] + if !ok || attempt >= len(scenario.answers) { + scenario.t.Fatalf("compile status was queried for a request with no scripted answers: %q", requestID) + } + answers := scenario.answers[attempt] + index := scenario.nextAnswer[requestID] + if index < len(answers)-1 { + scenario.nextAnswer[requestID] = index + 1 + } + // Why count only before the cancellation: the wait may poll once more after it, because its + // select picks at random when the poll tick and the cancellation are both ready. + if ctx.Err() == nil { + scenario.queries[requestID]++ + if scenario.cancel != nil && attempt == scenario.cancelAttempt && scenario.queries[requestID] == scenario.cancelAtQuery { + scenario.cancel() + } + } + answer := answers[index] + return answer.status, answer.err +} + +func (scenario *compileRecoveryScenario) sendCount() int { + return len(scenario.sentIDs) +} + +// queriesOf returns how often the request of the given send was queried before any cancellation. +func (scenario *compileRecoveryScenario) queriesOf(attempt int) int { + return scenario.queries[scenario.sentIDs[attempt]] +} + +func compileRecoveryAcceptedOutcome() unityipc.UnitySendOutcome { + return unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true} +} + +// recoverySendDisconnected is a send that Unity accepted before the connection dropped. +func recoverySendDisconnected() compileRecoverySend { + return compileRecoverySend{outcome: compileRecoveryAcceptedOutcome(), err: io.EOF} +} + +// recoverySendTimedOut is a send that Unity accepted without a final response in time. +func recoverySendTimedOut(t *testing.T) compileRecoverySend { + t.Helper() + if !clierrors.IsFinalResponseTimeoutError(os.ErrDeadlineExceeded) { + t.Fatal("os.ErrDeadlineExceeded must classify as a final response timeout") + } + return compileRecoverySend{outcome: compileRecoveryAcceptedOutcome(), err: os.ErrDeadlineExceeded} +} + +// recoverySendAnswered is a send that received Unity's final response. +func recoverySendAnswered() compileRecoverySend { + return compileRecoverySend{outcome: compileRecoveryAcceptedOutcome()} +} + +// recoveryMissing is Unity Ready with no result for the request. +func recoveryMissing() compileRecoveryAnswer { + return compileRecoveryAnswer{status: compileStatusResponse{Ready: true}} +} + +func recoveryMissingTimes(count int) []compileRecoveryAnswer { + answers := make([]compileRecoveryAnswer, 0, count) + for range count { + answers = append(answers, recoveryMissing()) + } + return answers +} + +func recoveryDone(result string) compileRecoveryAnswer { + return compileRecoveryAnswer{status: compileStatusResponse{Ready: true, HasResult: true, Result: json.RawMessage(result)}} +} + +func recoveryCompiling() compileRecoveryAnswer { + return compileRecoveryAnswer{status: compileStatusResponse{IsCompiling: true}} +} + +func recoveryQueryFailure(err error) compileRecoveryAnswer { + return compileRecoveryAnswer{err: err} +} + +func compileRecoveryRejection(errorCode string) string { + return `{"Success":false,"ErrorCode":"` + errorCode + `","ErrorCount":1}` +} + +// compileRecoveryBusyRejectionAnswers is Unity storing a busy rejection while it still compiles, +// then turning Ready. +func compileRecoveryBusyRejectionAnswers(errorCode string) []compileRecoveryAnswer { + return []compileRecoveryAnswer{ + {status: compileStatusResponse{HasResult: true, IsCompiling: true, Result: json.RawMessage(compileRecoveryRejection(errorCode))}}, + recoveryDone(compileRecoveryRejection(errorCode)), + } +} + +// runCompileRecovery runs the resending entry against the scenario. +func runCompileRecovery( + t *testing.T, + ctx context.Context, + scenario *compileRecoveryScenario, + params map[string]any, +) (compileExecutionResult, string) { + t.Helper() + var stderr bytes.Buffer + result := runFreshCompileRecoveringWithDeps(ctx, unreachableConnection(t.TempDir()), params, &stderr, scenario.deps()) + return result, stderr.String() +} + +func vibeLogContextNumber(t *testing.T, entry map[string]any, key string) float64 { + t.Helper() + contextMap, ok := entry["context"].(map[string]any) + if !ok { + t.Fatalf("vibe log context missing: %#v", entry) + } + value, ok := contextMap[key].(float64) + if !ok { + t.Fatalf("vibe log context %s is not a number: %#v", key, contextMap[key]) + } + return value +} + +// compileResendLogEntry returns the only resend entry in the project's CLI vibe log. +func compileResendLogEntry(t *testing.T, projectRoot string) map[string]any { + t.Helper() + entries := cliVibeEntriesForOperation(t, readOnlyCliVibeLog(t, projectRoot), "cli_compile_request_resend") + if len(entries) != 1 { + t.Fatalf("compile resend log entries = %d, want 1", len(entries)) + } + return entries[0] +} + +// Verifies a request Unity lost across a server restart is sent again with a new request ID after +// three Ready answers without a result, instead of waiting out the whole timeout, and that the +// resend is logged as a lost request under the first request's ID. +func TestFreshCompileRecoveryResendsWhenUnityLostTheRequest(t *testing.T) { + enableCliVibeLog(t) + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendDisconnected(), recoverySendAnswered()}, + []compileRecoveryAnswer{recoveryMissing()}, + []compileRecoveryAnswer{recoveryDone(compileRecoveryDefinitiveResult)}, + ) + projectRoot := t.TempDir() + var stderrBuffer bytes.Buffer + + result := runFreshCompileRecoveringWithDeps(context.Background(), unreachableConnection(projectRoot), map[string]any{}, &stderrBuffer, scenario.deps()) + stderr := stderrBuffer.String() + + if scenario.sendCount() != 2 { + t.Fatalf("compile sends = %d, want 2", scenario.sendCount()) + } + if scenario.sentIDs[0] == scenario.sentIDs[1] { + t.Fatalf("the resent compile must carry a new request ID: %q", scenario.sentIDs[0]) + } + if string(result.result) != compileRecoveryDefinitiveResult { + t.Fatalf("result = %s, want the resent compile's result", result.result) + } + if strings.Contains(stderr, "COMPILE_WAIT_TIMEOUT") { + t.Fatalf("a resent compile must not report a wait timeout:\n%s", stderr) + } + if queries := scenario.queriesOf(0); queries != 3 { + t.Fatalf("queries for the lost request = %d, want 3", queries) + } + resend := compileResendLogEntry(t, projectRoot) + if reason := vibeLogContextString(t, resend, "reason"); reason != "request_missing" { + t.Fatalf("resend reason = %q, want request_missing", reason) + } + if attempt := vibeLogContextNumber(t, resend, "attempt"); attempt != 1 { + t.Fatalf("resend attempt = %v, want 1", attempt) + } + if requestID := vibeLogContextString(t, resend, "request_id"); requestID != scenario.sentIDs[0] { + t.Fatalf("resend request_id = %q, want the lost request's ID %q", requestID, scenario.sentIDs[0]) + } +} + +// Verifies a status query that finds the server gone, dropped mid-query or with nobody listening, +// counts as a server restart, so a request still missing afterwards is sent again. +func TestFreshCompileRecoveryResendsWhenAStatusQueryLosesTheServer(t *testing.T) { + cases := []struct { + name string + queryErr error + }{ + {name: "dropped mid-query", queryErr: io.EOF}, + {name: "nobody listening", queryErr: &unityipc.ConnectionAttemptError{Cause: errors.New("connect: connection refused")}}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendTimedOut(t), recoverySendAnswered()}, + append([]compileRecoveryAnswer{recoveryQueryFailure(testCase.queryErr)}, recoveryMissingTimes(3)...), + []compileRecoveryAnswer{recoveryDone(compileRecoveryDefinitiveResult)}, + ) + + result, _ := runCompileRecovery(t, context.Background(), scenario, map[string]any{}) + + if scenario.sendCount() != 2 { + t.Fatalf("compile sends = %d, want 2", scenario.sendCount()) + } + if string(result.result) != compileRecoveryDefinitiveResult { + t.Fatalf("result = %s, want the resent compile's result", result.result) + } + }) + } +} + +// Verifies an answer that reports a domain reload in progress counts as a server restart, so a +// request still missing afterwards is sent again. +func TestFreshCompileRecoveryResendsAfterSeeingTheDomainReloadFlag(t *testing.T) { + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendTimedOut(t), recoverySendAnswered()}, + append( + []compileRecoveryAnswer{{status: compileStatusResponse{IsDomainReloadInProgress: true}}}, + recoveryMissingTimes(3)..., + ), + []compileRecoveryAnswer{recoveryDone(compileRecoveryDefinitiveResult)}, + ) + + result, _ := runCompileRecovery(t, context.Background(), scenario, map[string]any{}) + + if scenario.sendCount() != 2 { + t.Fatalf("compile sends = %d, want 2", scenario.sendCount()) + } + if string(result.result) != compileRecoveryDefinitiveResult { + t.Fatalf("result = %s, want the resent compile's result", result.result) + } +} + +// Verifies Ready answers without a result never lead to a resend when nothing showed the server +// went away: a live request answers that way until its compile starts, and a resend then would be +// rejected as busy. +func TestFreshCompileRecoveryKeepsWaitingWhenTheServerWasNeverLost(t *testing.T) { + cases := []struct { + name string + firstAnswers []compileRecoveryAnswer + }{ + {name: "no failed query"}, + { + name: "query acknowledged but unanswered", + firstAnswers: []compileRecoveryAnswer{recoveryQueryFailure(&compileStatusUnansweredError{cause: os.ErrDeadlineExceeded})}, + }, + { + name: "other query error", + firstAnswers: []compileRecoveryAnswer{recoveryQueryFailure(errors.New("unity error: boom"))}, + }, + { + name: "connect timed out", + firstAnswers: []compileRecoveryAnswer{recoveryQueryFailure(&unityipc.ConnectionAttemptError{Cause: context.DeadlineExceeded})}, + }, + { + name: "connect denied", + firstAnswers: []compileRecoveryAnswer{recoveryQueryFailure(&unityipc.ConnectionAttemptError{Cause: os.ErrPermission})}, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + script := append([]compileRecoveryAnswer{}, testCase.firstAnswers...) + script = append(script, recoveryMissingTimes(6)...) + script = append(script, recoveryDone(compileRecoveryDefinitiveResult)) + scenario := newCompileRecoveryScenario(t, []compileRecoverySend{recoverySendTimedOut(t)}, script) + + result, _ := runCompileRecovery(t, context.Background(), scenario, map[string]any{}) + + if scenario.sendCount() != 1 { + t.Fatalf("compile sends = %d, want 1", scenario.sendCount()) + } + if string(result.result) != compileRecoveryDefinitiveResult { + t.Fatalf("result = %s, want the compile's result", result.result) + } + }) + } +} + +// Verifies two Ready answers without a result are not enough for a resend: a single such answer +// can race Unity storing the result. +func TestFreshCompileRecoveryDoesNotResendBeforeThreeMissingAnswers(t *testing.T) { + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendDisconnected()}, + append(recoveryMissingTimes(2), recoveryDone(compileRecoveryDefinitiveResult)), + ) + + result, _ := runCompileRecovery(t, context.Background(), scenario, map[string]any{}) + + if scenario.sendCount() != 1 { + t.Fatalf("compile sends = %d, want 1", scenario.sendCount()) + } + if string(result.result) != compileRecoveryDefinitiveResult { + t.Fatalf("result = %s, want the compile's result", result.result) + } +} + +// Verifies a compiling answer between Ready answers without a result restarts their count. +func TestFreshCompileRecoveryMissingStreakRestartsAfterABusyAnswer(t *testing.T) { + script := recoveryMissingTimes(2) + script = append(script, recoveryCompiling()) + script = append(script, recoveryMissingTimes(2)...) + script = append(script, recoveryDone(compileRecoveryDefinitiveResult)) + scenario := newCompileRecoveryScenario(t, []compileRecoverySend{recoverySendDisconnected()}, script) + + result, _ := runCompileRecovery(t, context.Background(), scenario, map[string]any{}) + + if scenario.sendCount() != 1 { + t.Fatalf("compile sends = %d, want 1", scenario.sendCount()) + } + if string(result.result) != compileRecoveryDefinitiveResult { + t.Fatalf("result = %s, want the compile's result", result.result) + } +} + +// Verifies a compile Unity rejected because it was compiling or updating is sent again with a new +// request ID once the wait has seen the Editor Ready. +func TestFreshCompileRecoveryResendsAfterUnityRejectedTheCompileAsBusy(t *testing.T) { + for _, errorCode := range []string{"COMPILE_ALREADY_IN_PROGRESS", "COMPILE_EDITOR_UPDATING"} { + t.Run(errorCode, func(t *testing.T) { + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendAnswered(), recoverySendAnswered()}, + compileRecoveryBusyRejectionAnswers(errorCode), + []compileRecoveryAnswer{recoveryDone(compileRecoveryDefinitiveResult)}, + ) + + result, _ := runCompileRecovery(t, context.Background(), scenario, map[string]any{}) + + if scenario.sendCount() != 2 { + t.Fatalf("compile sends = %d, want 2", scenario.sendCount()) + } + if scenario.sentIDs[0] == scenario.sentIDs[1] { + t.Fatalf("the resent compile must carry a new request ID: %q", scenario.sentIDs[0]) + } + if string(result.result) != compileRecoveryDefinitiveResult { + t.Fatalf("result = %s, want the resent compile's result", result.result) + } + }) + } +} + +// Verifies a definitive compile failure is returned as it is, without a resend. +func TestFreshCompileRecoveryReturnsADefinitiveFailureWithoutResending(t *testing.T) { + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendAnswered()}, + []compileRecoveryAnswer{recoveryDone(compileRecoveryDefinitiveResult)}, + ) + + result, _ := runCompileRecovery(t, context.Background(), scenario, map[string]any{}) + + if scenario.sendCount() != 1 { + t.Fatalf("compile sends = %d, want 1", scenario.sendCount()) + } + if string(result.result) != compileRecoveryDefinitiveResult { + t.Fatalf("result = %s, want the compile's result", result.result) + } +} + +// Verifies a compile rejected as busy on every attempt is sent three times in all, and the last +// rejection is returned the way a rejection is returned without resending. +func TestFreshCompileRecoveryReturnsTheRejectionAfterTheAttemptLimit(t *testing.T) { + rejected := []compileRecoveryAnswer{recoveryDone(compileRecoveryAlreadyInProgressResult)} + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendAnswered(), recoverySendAnswered(), recoverySendAnswered()}, + rejected, rejected, rejected, + ) + + result, _ := runCompileRecovery(t, context.Background(), scenario, map[string]any{}) + + if scenario.sendCount() != 3 { + t.Fatalf("compile sends = %d, want 3", scenario.sendCount()) + } + if string(result.result) != compileRecoveryAlreadyInProgressResult { + t.Fatalf("result = %s, want the last rejection", result.result) + } + if result.exitCode != 1 { + t.Fatalf("exit code = %d, want 1", result.exitCode) + } +} + +// Verifies the last attempt neither detects a lost request nor sends it again: it keeps waiting the +// way a compile that is never resent does, until the command ends. +func TestFreshCompileRecoveryStopsDetectingOnTheLastAttempt(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + missing := []compileRecoveryAnswer{recoveryMissing()} + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendDisconnected(), recoverySendDisconnected(), recoverySendDisconnected()}, + missing, missing, missing, + ) + scenario.cancelWhen(cancel, 2, 6) + + result, stderr := runCompileRecovery(t, ctx, scenario, map[string]any{}) + + if scenario.sendCount() != 3 { + t.Fatalf("compile sends = %d, want 3", scenario.sendCount()) + } + if result.exitCode != 1 || len(result.result) != 0 { + t.Fatalf("unexpected result: %#v", result) + } + if !strings.Contains(stderr, context.Canceled.Error()) { + t.Fatalf("stderr must report the cancellation:\n%s", stderr) + } +} + +// Verifies the entry that never resends, used by pause-point recovery, keeps waiting for a lost +// request until the command ends instead of resending it or returning without a result. +func TestFreshCompileWithoutRecoveryDoesNotResend(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendDisconnected()}, + []compileRecoveryAnswer{recoveryMissing()}, + ) + scenario.cancelWhen(cancel, 0, 6) + var stderr bytes.Buffer + + result := runFreshCompileWithDomainReloadWaitResultWithDeps( + ctx, unreachableConnection(t.TempDir()), map[string]any{}, &stderr, scenario.deps()) + + if scenario.sendCount() != 1 { + t.Fatalf("compile sends = %d, want 1", scenario.sendCount()) + } + if result.exitCode != 1 || len(result.result) != 0 { + t.Fatalf("unexpected result: %#v", result) + } + if !strings.Contains(stderr.String(), context.Canceled.Error()) { + t.Fatalf("stderr must report the cancellation:\n%s", stderr.String()) + } + if queries := scenario.queriesOf(0); queries != 6 { + t.Fatalf("queries for the lost request = %d, want 6", queries) + } +} + +// Verifies the hot-reload compile fallback also sends a compile rejected as busy again. +func TestHotReloadFallbackCompileResendsAfterABusyRejection(t *testing.T) { + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendAnswered(), recoverySendAnswered()}, + compileRecoveryBusyRejectionAnswers("COMPILE_ALREADY_IN_PROGRESS"), + []compileRecoveryAnswer{recoveryDone(compileRecoveryDefinitiveResult)}, + ) + var stderr bytes.Buffer + + result := hotReloadFallbackCompileWithDeps(context.Background(), unreachableConnection(t.TempDir()), &stderr, scenario.deps()) + + if scenario.sendCount() != 2 { + t.Fatalf("compile sends = %d, want 2", scenario.sendCount()) + } + if string(result.result) != compileRecoveryDefinitiveResult { + t.Fatalf("result = %s, want the resent compile's result", result.result) + } +} + +// Verifies a busy rejection is returned as it is when too little wait time is left for another +// attempt: a resent compile would start in Unity just as the command times out. +func TestFreshCompileRecoveryDoesNotResendARejectionWhenLittleWaitTimeIsLeft(t *testing.T) { + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendAnswered()}, + []compileRecoveryAnswer{recoveryDone(compileRecoveryAlreadyInProgressResult)}, + ) + + result, _ := runCompileRecovery(t, context.Background(), scenario, map[string]any{compileWaitTimeoutParam: 5}) + + if scenario.sendCount() != 1 { + t.Fatalf("compile sends = %d, want 1", scenario.sendCount()) + } + if string(result.result) != compileRecoveryAlreadyInProgressResult { + t.Fatalf("result = %s, want the rejection", result.result) + } + if result.exitCode != 1 { + t.Fatalf("exit code = %d, want 1", result.exitCode) + } +} + +// Verifies a lost request is not sent again when too little wait time is left for another attempt; +// the wait goes on the way it does without resending. +func TestFreshCompileRecoveryKeepsWaitingForALostRequestWhenLittleWaitTimeIsLeft(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendDisconnected()}, + []compileRecoveryAnswer{recoveryMissing()}, + ) + scenario.cancelWhen(cancel, 0, 6) + + result, stderr := runCompileRecovery(t, ctx, scenario, map[string]any{compileWaitTimeoutParam: 5}) + + if scenario.sendCount() != 1 { + t.Fatalf("compile sends = %d, want 1", scenario.sendCount()) + } + if result.exitCode != 1 { + t.Fatalf("exit code = %d, want 1", result.exitCode) + } + if !strings.Contains(stderr, context.Canceled.Error()) { + t.Fatalf("stderr must report the cancellation:\n%s", stderr) + } +} + +// Verifies the first attempt waits exactly as long as --timeout-seconds says, while a resent +// compile waits only for the time that is left, and that the resend after a busy rejection is +// logged as one. +func TestFreshCompileRecoveryGivesAResendOnlyTheTimeThatIsLeft(t *testing.T) { + enableCliVibeLog(t) + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendAnswered(), recoverySendAnswered()}, + compileRecoveryBusyRejectionAnswers("COMPILE_ALREADY_IN_PROGRESS"), + []compileRecoveryAnswer{recoveryDone(compileRecoveryDefinitiveResult)}, + ) + projectRoot := t.TempDir() + var stderr bytes.Buffer + + runFreshCompileRecoveringWithDeps(context.Background(), unreachableConnection(projectRoot), map[string]any{}, &stderr, scenario.deps()) + + prepared := cliVibeEntriesForOperation(t, readOnlyCliVibeLog(t, projectRoot), "cli_compile_request_prepared") + if len(prepared) != 2 { + t.Fatalf("prepared compile requests = %d, want 2", len(prepared)) + } + if first := vibeLogContextNumber(t, prepared[0], "timeout_ms"); first != 600000 { + t.Fatalf("first attempt timeout_ms = %v, want 600000", first) + } + if second := vibeLogContextNumber(t, prepared[1], "timeout_ms"); second >= 600000 { + t.Fatalf("resent attempt timeout_ms = %v, want less than 600000", second) + } + resend := compileResendLogEntry(t, projectRoot) + if reason := vibeLogContextString(t, resend, "reason"); reason != "editor_busy" { + t.Fatalf("resend reason = %q, want editor_busy", reason) + } +} + +// Verifies a status query against an endpoint nobody listens on fails with an error that counts as +// the server being gone, so the scripted connection failure above matches what the real query +// returns. +func TestStatusQueryAgainstNoListenerCountsAsTheServerBeingGone(t *testing.T) { + _, err := queryCompileStatusFromUnity(context.Background(), unreachableConnection(t.TempDir()), "request") + + if err == nil { + t.Fatal("a status query against an endpoint nobody listens on must fail") + } + if errors.Is(err, os.ErrPermission) { + t.Skipf("the environment denied the connection itself, so it cannot show a missing listener: %v", err) + } + if !isServerGoneError(err) { + t.Fatalf("a status query with no listener must count as the server being gone: %v", err) + } +} diff --git a/cli/project-runner/internal/projectrunner/compile_wait.go b/cli/project-runner/internal/projectrunner/compile_wait.go index 55f44fa197..5a587909c4 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait.go +++ b/cli/project-runner/internal/projectrunner/compile_wait.go @@ -54,6 +54,11 @@ type compileCompletionOptions struct { untilEditorReady bool timeout time.Duration pollInterval time.Duration + // resendBefore, when not zero, lets a fresh compile's wait end with errCompileRequestMissing + // once the request is known to be lost and enough time is left to send it again. + resendBefore time.Time + // serverRestartSeen says the compile send already ended with the connection dropping. + serverRestartSeen bool } type compileStatusResponse struct { @@ -204,6 +209,7 @@ func waitForCompileCompletionWithDeps( logCompileStatusPollStart(options, startedAt, deadline) interim := newCompileWaitInterimState(compileWaitNow(deps)) + missing := compileRequestMissingTracker{serverRestartSeen: options.serverRestartSeen} ticker := time.NewTicker(options.pollInterval) defer ticker.Stop() @@ -226,6 +232,10 @@ func waitForCompileCompletionWithDeps( lastStatus = status observedStatus = true } + if missing.observe(status, err) && canResendCompile(options.resendBefore) { + logCompileStatusPollObservedIfChanged(options, startedAt, attempts, status, err, &lastObservationKey) + return nil, false, lastObservedCompileStatus(lastStatus, observedStatus), errCompileRequestMissing + } logCompileStatusPollObservedIfChanged(options, startedAt, attempts, status, err, &lastObservationKey) observeCompileWaitInterim(&interim, deps, status, err) // Why: queryCompileStatus can return after the wait deadline. Focusing then diff --git a/cli/project-runner/internal/projectrunner/compile_wait_deps.go b/cli/project-runner/internal/projectrunner/compile_wait_deps.go index 8882e19919..b63f259aa6 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait_deps.go +++ b/cli/project-runner/internal/projectrunner/compile_wait_deps.go @@ -27,7 +27,10 @@ type compileWaitDeps struct { reportInterim compileWaitInterimReporter // Zero keeps compileStartStallFocusThreshold. Tests shorten it so they do not wait 10s. startStallFocusThreshold time.Duration - focus connectionRetryDeps + // 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 { @@ -37,6 +40,13 @@ func compileStartStallFocusThresholdFor(deps compileWaitDeps) time.Duration { return compileStartStallFocusThreshold } +func freshWaitPollIntervalFor(deps compileWaitDeps) time.Duration { + if deps.freshWaitPollInterval > 0 { + return deps.freshWaitPollInterval + } + return compileWaitPollInterval +} + func compileWaitFocusDeps(deps compileWaitDeps) connectionRetryDeps { merged := defaultConnectionRetryDeps() if deps.focus.findRunningUnityProcess != nil { diff --git a/cli/project-runner/internal/projectrunner/connection_retry_flow.go b/cli/project-runner/internal/projectrunner/connection_retry_flow.go index cfdeb61dcb..856512656f 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_flow.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_flow.go @@ -70,17 +70,20 @@ func finishNonRetryableConnectionAttempt( responseTimeout time.Duration, focusController *connectionRetryFocusController, ) (unityipc.UnitySendOutcome, error) { - // A transport error after a busy response in this window must not mask the - // busy; the server answered moments ago, so busy is the truer diagnosis. - // An RPC error is a real Unity answer, not a transport artifact, and must - // surface as-is. The transport error is not compared against the window - // deadline because the connection deadline can fire microseconds before - // the context reports expiry. + // A busy answer earlier in this window wins only over an attempt whose request never reached + // Unity, such as a failed connect or write: nothing ran, so busy is the truer diagnosis. A + // request that reached Unity may already be running, so that attempt's own error and outcome + // take the same path as a first attempt's, and the caller recovers from them (compile, for + // one, asks Unity for its compile status). An RPC error is a real Unity answer, not a + // transport artifact, and must surface as-is. if currentAttempt.err != nil && !isRPCError(currentAttempt.err) && isUnityServerBusyRPCError(lastAttempt.err) { + // A caller that cancelled gets the cancellation back, whichever attempt failed. if ctx.Err() != nil { return currentAttempt.outcome, ctx.Err() } - return lastAttempt.outcome, lastAttempt.err + if !currentAttempt.outcome.RequestDispatched { + return lastAttempt.outcome, lastAttempt.err + } } if reason, ok := connectionRetryFocusReasonForError(currentAttempt.err, currentAttempt.outcome, responseTimeout); ok { focusController.tryFocus(ctx, reason, 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 9f7b7fa13e..fa4e5aa890 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_flow_test.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_flow_test.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -211,11 +212,11 @@ func TestFinishBusyRetryStopsWithTheRightError(t *testing.T) { } } -// Verifies a transport error right after a busy answer reports the busy answer, unless the caller -// cancelled, in which case the cancellation wins. -func TestFinishNonRetryableConnectionAttemptPrefersBusyOverTransportError(t *testing.T) { +// Verifies a transport error from an attempt that never reached Unity reports the earlier busy +// answer, unless the caller cancelled, in which case the cancellation wins. +func TestFinishNonRetryableConnectionAttemptPrefersBusyOverAnUndispatchedTransportError(t *testing.T) { busy := serverBusyRPCError(t) - current := sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true}, err: io.ErrUnexpectedEOF} + current := sendAttempt{err: io.ErrUnexpectedEOF} last := sendAttempt{err: busy} _, err := finishNonRetryableConnectionAttempt(context.Background(), current, last, 0, nil) @@ -229,6 +230,104 @@ func TestFinishNonRetryableConnectionAttemptPrefersBusyOverTransportError(t *tes } } +// Verifies a dropped connection, a timeout, or any other non-RPC error from an attempt that reached +// Unity after a busy answer comes back as that attempt's own error and outcome, so the caller can +// recover from what really happened, and that a caller's cancellation still wins over it. +func TestFinishNonRetryableConnectionAttemptKeepsADispatchedFailureAfterBusy(t *testing.T) { + busy := serverBusyRPCError(t) + last := busyAttemptAfterAccept(busy) + cases := []struct { + name string + current sendAttempt + }{ + { + name: "dropped after the accept", + current: sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}, err: io.ErrUnexpectedEOF}, + }, + { + name: "dropped before the accept", + current: sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true}, err: io.EOF}, + }, + { + name: "final response timed out after the accept", + current: sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}, err: os.ErrDeadlineExceeded}, + }, + { + // Stands for an error that is neither a disconnect nor a timeout, such as a final response + // that fails to decode: the rule does not depend on the kind of error. + name: "failed another way after the accept", + current: sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}, err: errors.New("final response could not be decoded")}, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.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) + 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) + } + if !reflect.DeepEqual(outcome, testCase.current.outcome) { + t.Fatalf("outcome = %+v, want the dispatched attempt's outcome %+v", outcome, testCase.current.outcome) + } + }) + t.Run("reports the cancellation", func(t *testing.T) { + _, err := finishNonRetryableConnectionAttempt(cancelledContext(), testCase.current, last, time.Second, nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } + }) + }) + } +} + +// busyAttemptAfterAccept returns a busy answer to an accepted request. Its distinct timing tells its +// outcome apart from a current attempt whose flags are the same, so a test can see which outcome +// came back. +func busyAttemptAfterAccept(busy error) sendAttempt { + return sendAttempt{ + outcome: unityipc.UnitySendOutcome{ + RequestDispatched: true, + RequestAccepted: true, + Timing: unityipc.UnitySendTiming{Total: time.Millisecond}, + }, + err: busy, + } +} + +// Verifies an editor-unresponsive error from an attempt that Unity accepted after a busy answer comes +// back as that attempt's own error and outcome, and goes through the main-thread-stall focus handling +// a first attempt would get, instead of being reported as the busy answer. +func TestFinishNonRetryableConnectionAttemptKeepsAnEditorUnresponsiveErrorAfterBusy(t *testing.T) { + busy := serverBusyRPCError(t) + last := busyAttemptAfterAccept(busy) + current := sendAttempt{ + outcome: unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}, + err: &unityipc.EditorUnresponsiveError{StallSeconds: 30}, + } + processLookups := 0 + deps := defaultConnectionRetryDeps() + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + processLookups++ + return nil, nil + } + // This error enters the focus handling whatever the response timeout is, so it needs a real focus + // 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) + if !errors.Is(err, current.err) || isUnityServerBusyRPCError(err) { + t.Errorf("err = %v, want the accepted attempt's own error %v", err, current.err) + } + if !reflect.DeepEqual(outcome, current.outcome) { + t.Errorf("outcome = %+v, want the accepted attempt's outcome %+v", outcome, current.outcome) + } + if processLookups != 1 { + t.Errorf("Unity process lookups = %d, want 1 from the main-thread-stall focus handling", processLookups) + } +} + // 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 93defc4123..fa120a499d 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_test.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "os" "path/filepath" @@ -1469,3 +1470,185 @@ func TestSendWithTransientConnectionRetrySurfacesDispatchedFailureAfterBusy(t *t t.Fatalf("dispatched failure must surface as the original RPC error, got: %v", err) } } + +// busyFirstServerConnection starts a TCP stand-in for Unity that answers the first connection with +// a busy error and hands every later connection, once its request has been read, to +// handleDispatched. The returned connection points at the stand-in. +func busyFirstServerConnection(t *testing.T, handleDispatched func(conn net.Conn)) unityipc.Connection { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { + _ = listener.Close() + }) + + busy := `{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Unity is busy running 'execute-dynamic-code'.","data":{"type":"server_busy","runningToolName":"execute-dynamic-code","requestedToolName":"compile","message":"busy"}}}` + go func() { + first := true + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + sendBusy := first + first = false + go func(conn net.Conn, sendBusy bool) { + defer func() { + _ = conn.Close() + }() + if _, readErr := unityipc.Read(bufio.NewReader(conn)); readErr != nil { + return + } + if sendBusy { + _ = unityipc.Write(conn, []byte(busy)) + return + } + handleDispatched(conn) + }(conn, sendBusy) + } + }() + + return unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: "tcp", + Address: listener.Addr().String(), + }, + ProjectRoot: t.TempDir(), + } +} + +// Verifies a connection that drops after Unity accepted the retried request comes back as that +// disconnect, not as the busy answer from the attempt before it, so compile can go on to ask Unity +// for its compile status. +func TestSendWithTransientConnectionRetrySurfacesADroppedConnectionAfterBusy(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("TCP endpoint injection is only used by this non-Windows client test") + } + + deps := defaultConnectionRetryDeps() + deps.retryTimeout = 150 * time.Millisecond + deps.retryPoll = 5 * time.Millisecond + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + return nil, nil + } + connection := busyFirstServerConnection(t, func(conn net.Conn) { + accepted := `{"jsonrpc":"2.0","result":{"accepted":true},"uloop":{"phase":"accepted"},"id":1}` + _ = unityipc.Write(conn, []byte(accepted)) + }) + + outcome, err := sendWithTransientConnectionRetryWithDeps( + context.Background(), + connection, + "compile", + map[string]any{}, + nil, + 0, + deps) + if err == nil { + t.Fatal("expected the dropped connection to surface") + } + if isUnityServerBusyRPCError(err) { + t.Fatalf("a request that reached Unity must not be reported as the earlier busy answer, got: %v", err) + } + if !clierrors.IsTransportDisconnectError(err) { + t.Fatalf("err = %v, want a transport disconnect", err) + } + if !outcome.RequestDispatched || !outcome.RequestAccepted { + t.Fatalf("outcome = %+v, want a dispatched and accepted request", outcome) + } + if !shouldWaitForCompileStatus(err, outcome) { + t.Fatalf("compile must be able to wait for its status after err = %v, outcome = %+v", err, outcome) + } +} + +// Verifies a final response wait that times out after Unity accepted the retried request, which is +// how a compile longer than its response timeout ends, comes back as that timeout and not as the +// busy answer from the attempt before it, so compile can go on to ask Unity for its compile status. +func TestSendWithTransientConnectionRetrySurfacesAFinalResponseTimeoutAfterBusy(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("TCP endpoint injection is only used by this non-Windows client test") + } + + deps := defaultConnectionRetryDeps() + deps.retryTimeout = 150 * time.Millisecond + deps.retryPoll = 5 * time.Millisecond + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + return nil, nil + } + connection := busyFirstServerConnection(t, func(conn net.Conn) { + accepted := `{"jsonrpc":"2.0","result":{"accepted":true},"uloop":{"phase":"accepted"},"id":1}` + if writeErr := unityipc.Write(conn, []byte(accepted)); writeErr != nil { + return + } + // Staying silent until the client hangs up means the client's own deadline is always what + // ends the wait, never a close from this side. + _, _ = io.Copy(io.Discard, conn) + }) + + outcome, err := sendWithTransientConnectionRetryWithDeps( + context.Background(), + connection, + "compile", + map[string]any{}, + nil, + 50*time.Millisecond, + deps) + if err == nil { + t.Fatal("expected the final response timeout to surface") + } + if isUnityServerBusyRPCError(err) { + t.Fatalf("a request that reached Unity must not be reported as the earlier busy answer, got: %v", err) + } + if !clierrors.IsFinalResponseTimeoutError(err) { + t.Fatalf("err = %v, want a final response timeout", err) + } + if !outcome.RequestAccepted { + t.Fatalf("outcome = %+v, want an accepted request", outcome) + } + if !shouldWaitForCompileStatus(err, outcome) { + t.Fatalf("compile must be able to wait for its status after err = %v, outcome = %+v", err, outcome) + } +} + +// Verifies a retried request that Unity read but never acknowledged times out as an unanswered +// request, not as the busy answer from the attempt before it. +func TestSendWithTransientConnectionRetrySurfacesAnUnansweredRequestAfterBusy(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("TCP endpoint injection is only used by this non-Windows client test") + } + + deps := defaultConnectionRetryDeps() + deps.retryTimeout = 150 * time.Millisecond + deps.retryPoll = 5 * time.Millisecond + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + return nil, nil + } + connection := busyFirstServerConnection(t, func(conn net.Conn) { + // Staying silent until the client hangs up means the client's own accept deadline is always + // what ends the wait, never a close from this side. + _, _ = io.Copy(io.Discard, conn) + }) + + outcome, err := sendWithTransientConnectionRetryWithDeps( + context.Background(), + connection, + "compile", + map[string]any{}, + nil, + 0, + deps) + if err == nil { + t.Fatal("expected the unanswered request to surface") + } + if isUnityServerBusyRPCError(err) { + t.Fatalf("a request that reached Unity must not be reported as the earlier busy answer, got: %v", err) + } + if !clierrors.IsFinalResponseTimeoutError(err) { + t.Fatalf("err = %v, want a response 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/hot_reload_compile_fallback.go b/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback.go index efd80f35af..4a66fffef1 100644 --- a/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback.go +++ b/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "io" + "strconv" "strings" + "time" "github.com/hatayama/unity-cli-loop/common/clicore" clierrors "github.com/hatayama/unity-cli-loop/common/errors" @@ -24,6 +26,17 @@ const ( hotReloadRecommendedNextActionField = "RecommendedNextAction" hotReloadMessageField = "Message" hotReloadWarningsField = "Warnings" + hotReloadOutcomeField = "Outcome" + hotReloadAutoRefreshHeldField = "AutoRefreshHeld" + hotReloadAutoRefreshHoldMessageField = "AutoRefreshHoldMessage" + hotReloadTimingField = "Timing" + hotReloadFallbackCompileMsField = "FallbackCompileMs" +) + +// Raw JSON values, because the response fields are edited as encoded JSON. +const ( + hotReloadOutcomeReplacedByCompileJSON = `"ReplacedByCompile"` + hotReloadAutoRefreshReleasedJSON = "false" ) const ( @@ -81,7 +94,9 @@ func runHotReloadWithCompileFallback( return result.exitCode } + compileStarted := time.Now() compileResult := hotReloadFallbackCompile(ctx, connection, stderr) + compileElapsed := time.Since(compileStarted) if len(compileResult.result) == 0 { // The transport failure is already classified on stderr; the reload itself still happened. clicore.WriteJSON(stdout, result.result) @@ -89,6 +104,9 @@ func runHotReloadWithCompileFallback( } merged, err := injectHotReloadCompileFallback(result.result, compileResult.result) + if err == nil { + merged, err = addHotReloadFallbackCompileTiming(merged, compileElapsed) + } if err != nil { clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ ProjectRoot: connection.ProjectRoot, @@ -152,23 +170,82 @@ func injectHotReloadCompileFallback(raw json.RawMessage, compileRaw json.RawMess } // The reload's own next action says to run 'uloop compile', which this command just did. delete(fields, hotReloadRecommendedNextActionField) + // Before the compile sentence is appended, so the hold sentence is still at the end of Message. + if err := settleHotReloadStateAfterCompile(fields); err != nil { + return nil, err + } if err := appendHotReloadCompileSucceededMessage(fields); err != nil { return nil, err } return json.Marshal(fields) } +// Adds the fallback compile's wall time to Timing. The phases the Editor reported stay as they were, +// and a response from an older package, which sends no Timing, gets one holding only the compile. +func addHotReloadFallbackCompileTiming(raw []byte, elapsed time.Duration) ([]byte, error) { + fields := map[string]json.RawMessage{} + if err := json.Unmarshal(raw, &fields); err != nil { + return nil, err + } + timing := map[string]json.RawMessage{} + // Why only an object is decoded: JSON null would leave the map nil and any other value would + // fail the decode, while neither holds a phase worth keeping, so both count as absent. + if existing := fields[hotReloadTimingField]; len(existing) > 0 && existing[0] == '{' { + if err := json.Unmarshal(existing, &timing); err != nil { + return nil, err + } + } + timing[hotReloadFallbackCompileMsField] = json.RawMessage(strconv.FormatInt(elapsed.Milliseconds(), 10)) + encoded, err := json.Marshal(timing) + if err != nil { + return nil, err + } + fields[hotReloadTimingField] = encoded + return json.Marshal(fields) +} + +// A successful compile reloaded the domain: every edit is compiled in, the patches are gone, and +// the Auto Refresh hold is released, so the reload's own Outcome and hold sentence are stale. +// Outcome is written even for an older package that sent none, so every merged response says the +// compile replaced the reload. AutoRefreshHeld is only corrected, never added: a response without +// it comes from a package that never reported the hold. +func settleHotReloadStateAfterCompile(fields map[string]json.RawMessage) error { + fields[hotReloadOutcomeField] = json.RawMessage(hotReloadOutcomeReplacedByCompileJSON) + if _, present := fields[hotReloadAutoRefreshHeldField]; present { + fields[hotReloadAutoRefreshHeldField] = json.RawMessage(hotReloadAutoRefreshReleasedJSON) + } + return removeHotReloadHoldSentence(fields) +} + +// Removes " " from the end of Message, where the Editor appended it, so the +// CLI never needs its own copy of the sentence. Anything else is left alone: an older package sends +// no AutoRefreshHoldMessage, and a run that did not arm the hold omits it. +func removeHotReloadHoldSentence(fields map[string]json.RawMessage) error { + holdSentence, isString, err := readHotReloadStringField(fields, hotReloadAutoRefreshHoldMessageField) + if err != nil || !isString { + return err + } + message, isString, err := readHotReloadStringField(fields, hotReloadMessageField) + if err != nil || !isString { + return err + } + withoutHold, found := strings.CutSuffix(message, " "+holdSentence) + if !found { + return nil + } + trimmed, err := json.Marshal(withoutHold) + if err != nil { + return err + } + fields[hotReloadMessageField] = trimmed + return nil +} + // A Message that is missing or not a string is left alone: only an older or unexpected package // sends one, and inventing a Message would claim a reload summary the Editor never wrote. -// Why the first byte is checked: decoding JSON null into a string succeeds and leaves it empty, -// so the decode alone would turn a null Message into one that holds only the suffix. func appendHotReloadCompileSucceededMessage(fields map[string]json.RawMessage) error { - raw := fields[hotReloadMessageField] - if len(raw) == 0 || raw[0] != '"' { - return nil - } - message := "" - if err := json.Unmarshal(raw, &message); err != nil { + message, isString, err := readHotReloadStringField(fields, hotReloadMessageField) + if err != nil || !isString { return err } appended, err := json.Marshal(message + hotReloadCompileFallbackSucceededMessageSuffix) @@ -179,6 +256,22 @@ func appendHotReloadCompileSucceededMessage(fields map[string]json.RawMessage) e return nil } +// readHotReloadStringField decodes a response field that holds a JSON string, and reports false for +// a field that is missing or holds anything else. +// Why the first byte is checked: decoding JSON null into a string succeeds and leaves it empty, so +// the decode alone would treat a null field as an empty string and write a string back over it. +func readHotReloadStringField(fields map[string]json.RawMessage, name string) (string, bool, error) { + raw := fields[name] + if len(raw) == 0 || raw[0] != '"' { + return "", false, nil + } + value := "" + if err := json.Unmarshal(raw, &value); err != nil { + return "", false, err + } + return value, true, nil +} + // hotReloadUnappliedPointer names the response field that explains the unapplied edits, so the // note never sends the reader to an empty Warnings array. func hotReloadUnappliedPointer(fields map[string]json.RawMessage) string { diff --git a/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback_test.go b/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback_test.go index f0eab31bed..aa03b6a8ac 100644 --- a/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback_test.go +++ b/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback_test.go @@ -5,8 +5,10 @@ import ( "context" "encoding/json" "io" + "reflect" "strings" "testing" + "time" "github.com/hatayama/unity-cli-loop/common/unityipc" ) @@ -255,6 +257,18 @@ func runHotReloadWithFakeCompile( t *testing.T, hotReloadResponse string, compileResult compileExecutionResult, +) (string, string, int, int) { + t.Helper() + return runHotReloadWithDelayedFakeCompile(t, hotReloadResponse, compileResult, 0) +} + +// Same as runHotReloadWithFakeCompile, but the fake compile answers only after delay, so a test can +// tell a compile time that was measured from one that was never measured. +func runHotReloadWithDelayedFakeCompile( + t *testing.T, + hotReloadResponse string, + compileResult compileExecutionResult, + delay time.Duration, ) (string, string, int, int) { t.Helper() projectRoot := t.TempDir() @@ -267,6 +281,7 @@ func runHotReloadWithFakeCompile( compileCalls := 0 hotReloadFallbackCompile = func(context.Context, unityipc.Connection, io.Writer) compileExecutionResult { compileCalls++ + time.Sleep(delay) return compileResult } t.Cleanup(func() { @@ -370,3 +385,245 @@ func TestHotReloadCompileFallbackRejectsNonObjectResponses(t *testing.T) { t.Fatalf("expected the non-object error, got %v", err) } } + +// Verifies a successful fallback compile turns the reload's Outcome into ReplacedByCompile, reports +// the Auto Refresh hold released, and removes exactly the hold sentence the reload appended to +// Message, with its leading space, while AutoRefreshHoldMessage stays as the record of what was +// removed. +func TestInjectHotReloadCompileFallback_CompileSucceeded_SettlesFinalState(t *testing.T) { + cases := []struct { + name string + reload string + wantMessage string + }{ + { + name: "nothing applied", + reload: `{"Success":true,"Outcome":"NothingApplied","CompileFallback":"Requested","AutoRefreshHeld":true,"AutoRefreshHoldMessage":"HOLD SENTENCE","Message":"Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2. HOLD SENTENCE"}`, + wantMessage: "Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2.", + }, + { + name: "partially applied", + reload: `{"Success":true,"Outcome":"PartiallyApplied","CompileFallback":"Requested","AutoRefreshHeld":true,"AutoRefreshHoldMessage":"HOLD SENTENCE","Message":"Hot reload applied. PatchedTotal=1, ActivePatchTotal=1. Skipped: 1. HOLD SENTENCE"}`, + wantMessage: "Hot reload applied. PatchedTotal=1, ActivePatchTotal=1. Skipped: 1.", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + merged, err := injectHotReloadCompileFallback(json.RawMessage(tc.reload), json.RawMessage(`{"Success":true}`)) + if err != nil { + t.Fatalf("inject failed: %v", err) + } + fields := decodeSingleJSONObject(t, string(merged)) + assertJSONStringField(t, fields, "Outcome", "ReplacedByCompile") + if string(fields["AutoRefreshHeld"]) != "false" { + t.Fatalf("AutoRefreshHeld must be false once the compile released the hold: %s", merged) + } + assertJSONStringField(t, fields, "Message", tc.wantMessage+hotReloadCompileFallbackSucceededMessageSuffix) + assertJSONStringField(t, fields, "AutoRefreshHoldMessage", "HOLD SENTENCE") + }) + } +} + +// Verifies a failed fallback compile leaves Outcome, AutoRefreshHeld, AutoRefreshHoldMessage and +// Message as the reload wrote them. +func TestInjectHotReloadCompileFallback_CompileFailed_KeepsReloadState(t *testing.T) { + merged, err := injectHotReloadCompileFallback( + json.RawMessage(`{"Success":true,"Outcome":"NothingApplied","CompileFallback":"Requested","AutoRefreshHeld":true,"AutoRefreshHoldMessage":"HOLD SENTENCE","Message":"Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2. HOLD SENTENCE"}`), + json.RawMessage(`{"Success":false,"Errors":[{"Message":"CS0103"}]}`)) + if err != nil { + t.Fatalf("inject failed: %v", err) + } + fields := decodeSingleJSONObject(t, string(merged)) + assertJSONStringField(t, fields, "Outcome", "NothingApplied") + if string(fields["AutoRefreshHeld"]) != "true" { + t.Fatalf("AutoRefreshHeld must stay as the reload reported it: %s", merged) + } + assertJSONStringField(t, fields, "Message", "Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2. HOLD SENTENCE") + assertJSONStringField(t, fields, "AutoRefreshHoldMessage", "HOLD SENTENCE") +} + +// Verifies a successful fallback compile over an older package's response, which has neither +// Outcome nor AutoRefreshHoldMessage, still adds Outcome ReplacedByCompile, appends the compile +// sentence without removing anything from Message, and turns AutoRefreshHeld false only when the +// response has the field. +func TestInjectHotReloadCompileFallback_OlderPackageWithoutOutcome_StillSettles(t *testing.T) { + cases := []struct { + name string + reload string + wantMessage string + // Raw JSON of AutoRefreshHeld after the merge; empty when the field must stay absent. + wantAutoRefreshHeld string + }{ + { + name: "with AutoRefreshHeld", + reload: `{"Success":true,"CompileFallback":"Requested","AutoRefreshHeld":true,"Message":"Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2. HOLD SENTENCE"}`, + wantMessage: "Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2. HOLD SENTENCE", + wantAutoRefreshHeld: "false", + }, + { + name: "without AutoRefreshHeld", + reload: `{"Success":true,"CompileFallback":"Requested","Message":"Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2."}`, + wantMessage: "Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2.", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + merged, err := injectHotReloadCompileFallback(json.RawMessage(tc.reload), json.RawMessage(`{"Success":true}`)) + if err != nil { + t.Fatalf("inject failed: %v", err) + } + fields := decodeSingleJSONObject(t, string(merged)) + assertJSONStringField(t, fields, "Outcome", "ReplacedByCompile") + assertJSONStringField(t, fields, "Message", tc.wantMessage+hotReloadCompileFallbackSucceededMessageSuffix) + if string(fields["AutoRefreshHeld"]) != tc.wantAutoRefreshHeld { + t.Fatalf("AutoRefreshHeld mismatch: want %q, got %q", tc.wantAutoRefreshHeld, fields["AutoRefreshHeld"]) + } + }) + } +} + +// Verifies a fallback compile that succeeded after a reload with Failed rows replaces the Failed +// Outcome with ReplacedByCompile, together with Success. +func TestInjectHotReloadCompileFallback_CompileSucceededAfterFailedReload_ReplacesFailedOutcome(t *testing.T) { + merged, err := injectHotReloadCompileFallback( + json.RawMessage(`{"Success":false,"Outcome":"Failed","CompileFallback":"Requested","Message":"Hot reload finished with one or more Failed outcomes."}`), + json.RawMessage(`{"Success":true}`)) + if err != nil { + t.Fatalf("inject failed: %v", err) + } + fields := decodeSingleJSONObject(t, string(merged)) + if string(fields["Success"]) != "true" { + t.Fatalf("Success must become the compile's: %s", merged) + } + assertJSONStringField(t, fields, "Outcome", "ReplacedByCompile") +} + +// Verifies a successful fallback compile removes the hold sentence only from the end of Message, +// where the reload appended it, and leaves the same text elsewhere in Message alone. +func TestInjectHotReloadCompileFallback_CompileSucceeded_RemovesTheHoldSentenceOnlyAtTheEnd(t *testing.T) { + merged, err := injectHotReloadCompileFallback( + json.RawMessage(`{"Success":true,"Outcome":"NothingApplied","CompileFallback":"Requested","AutoRefreshHeld":true,"AutoRefreshHoldMessage":"HOLD SENTENCE","Message":"Skipped: 2. HOLD SENTENCE See Warnings."}`), + json.RawMessage(`{"Success":true}`)) + if err != nil { + t.Fatalf("inject failed: %v", err) + } + fields := decodeSingleJSONObject(t, string(merged)) + assertJSONStringField(t, fields, "Message", "Skipped: 2. HOLD SENTENCE See Warnings."+hotReloadCompileFallbackSucceededMessageSuffix) +} + +// Verifies the fallback compile's time joins the phases the Editor reported in Timing, which stay +// as they were. +func TestAddHotReloadFallbackCompileTimingKeepsTheEditorPhases(t *testing.T) { + fields := addHotReloadFallbackCompileTimingOf( + t, + `{"Success":true,"Timing":{"AnalysisMs":120,"ShimCompileMs":800,"PatchMs":3,"TotalMs":1000}}`) + + want := map[string]int64{"AnalysisMs": 120, "ShimCompileMs": 800, "PatchMs": 3, "TotalMs": 1000, "FallbackCompileMs": 1500} + if timing := decodeHotReloadTiming(t, fields); !reflect.DeepEqual(timing, want) { + t.Fatalf("Timing mismatch:\nwant %v\ngot %v", want, timing) + } +} + +// Verifies a response from an older package, which sends no Timing, gets one holding only the +// fallback compile's time. +func TestAddHotReloadFallbackCompileTimingCreatesTimingForAnOlderPackage(t *testing.T) { + fields := addHotReloadFallbackCompileTimingOf(t, `{"Success":true}`) + + want := map[string]int64{"FallbackCompileMs": 1500} + if timing := decodeHotReloadTiming(t, fields); !reflect.DeepEqual(timing, want) { + t.Fatalf("Timing mismatch:\nwant %v\ngot %v", want, timing) + } +} + +// Verifies a Timing of JSON null counts as absent and becomes an object holding the fallback +// compile's time. +func TestAddHotReloadFallbackCompileTimingTreatsNullTimingAsAbsent(t *testing.T) { + fields := addHotReloadFallbackCompileTimingOf(t, `{"Success":true,"Timing":null}`) + + want := map[string]int64{"FallbackCompileMs": 1500} + if timing := decodeHotReloadTiming(t, fields); !reflect.DeepEqual(timing, want) { + t.Fatalf("Timing mismatch:\nwant %v\ngot %v", want, timing) + } +} + +// Verifies adding the fallback compile's time leaves every other field of the merged response as +// it was. +func TestAddHotReloadFallbackCompileTimingKeepsTheOtherFields(t *testing.T) { + fields := addHotReloadFallbackCompileTimingOf(t, `{"Success":false,"CompileFallbackNote":"x"}`) + + if len(fields) != 3 || string(fields["Success"]) != "false" || string(fields["CompileFallbackNote"]) != `"x"` { + t.Fatalf("only Timing may be added to the merged response: %v", fields) + } +} + +// Verifies a successful fallback compile reports how long it ran in Timing.FallbackCompileMs, beside +// the phases the Editor reported. +func TestRunHotReloadRecordsFallbackCompileMsWhenTheCompileSucceeded(t *testing.T) { + stdout, stderr, _, code := runHotReloadWithDelayedFakeCompile( + t, + `{"Success":true,"CompileFallback":"Requested","Timing":{"AnalysisMs":120,"ShimCompileMs":800,"PatchMs":3,"TotalMs":1000}}`, + compileExecutionResult{result: json.RawMessage(`{"Success":true}`), exitCode: 0}, + 20*time.Millisecond, + ) + + if code != 0 { + t.Fatalf("exit code mismatch: code=%d stdout=%s stderr=%s", code, stdout, stderr) + } + timing := decodeHotReloadTiming(t, decodeSingleJSONObject(t, stdout)) + if timing["FallbackCompileMs"] < 20 || timing["AnalysisMs"] != 120 { + t.Fatalf("Timing must keep the Editor phases and measure the compile: %v", timing) + } +} + +// Verifies a failed fallback compile still reports how long it ran, while Success stays false. +func TestRunHotReloadRecordsFallbackCompileMsWhenTheCompileFailed(t *testing.T) { + stdout, stderr, _, code := runHotReloadWithDelayedFakeCompile( + t, + `{"Success":true,"CompileFallback":"Requested"}`, + compileExecutionResult{result: json.RawMessage(`{"Success":false,"Errors":[{"Message":"CS0103"}]}`), exitCode: 1}, + 20*time.Millisecond, + ) + + if code != 1 { + t.Fatalf("exit code mismatch: code=%d stdout=%s stderr=%s", code, stdout, stderr) + } + fields := decodeSingleJSONObject(t, stdout) + if string(fields["Success"]) != "false" { + t.Fatalf("Success must stay the failed compile's: %s", stdout) + } + if timing := decodeHotReloadTiming(t, fields); timing["FallbackCompileMs"] < 20 { + t.Fatalf("Timing must measure the failed compile too: %v", timing) + } +} + +// Adds a 1500 ms fallback compile to a merged response and decodes the result. +func addHotReloadFallbackCompileTimingOf(t *testing.T, merged string) map[string]json.RawMessage { + t.Helper() + withTiming, err := addHotReloadFallbackCompileTiming([]byte(merged), 1500*time.Millisecond) + if err != nil { + t.Fatalf("adding the fallback compile time failed: %v", err) + } + return decodeSingleJSONObject(t, string(withTiming)) +} + +// Decodes the Timing object of a response into its millisecond values; a missing Timing fails. +func decodeHotReloadTiming(t *testing.T, fields map[string]json.RawMessage) map[string]int64 { + t.Helper() + timing := map[string]int64{} + if err := json.Unmarshal(fields["Timing"], &timing); err != nil { + t.Fatalf("Timing must be an object of milliseconds: %v (raw %s)", err, fields["Timing"]) + } + return timing +} + +// Fails the test unless the field holds want as a JSON string. +func assertJSONStringField(t *testing.T, fields map[string]json.RawMessage, name string, want string) { + t.Helper() + got := "" + if err := json.Unmarshal(fields[name], &got); err != nil { + t.Fatalf("%s must be a JSON string: %v (raw %s)", name, err, fields[name]) + } + if got != want { + t.Fatalf("%s mismatch:\nwant %q\ngot %q", name, want, got) + } +} 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 0d74735fce..31195146c9 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_release_recovery.go +++ b/cli/project-runner/internal/projectrunner/pause_point_release_recovery.go @@ -189,20 +189,6 @@ func sendCompileWithBusyRetry( } } -type compileErrorCodeProbe struct { - ErrorCode string `json:"ErrorCode"` -} - -// Why ErrorCode only: the collision is a structured compile result, not a message string. -func isRetryablePausePointRecoveryCompileResult(raw []byte) bool { - var probe compileErrorCodeProbe - if json.Unmarshal(raw, &probe) != nil { - return false - } - return probe.ErrorCode == compileAlreadyInProgressErrorCode || - probe.ErrorCode == compileEditorUpdatingErrorCode -} - func runOneFreshCompileForPausePointRecoveryDefault( ctx context.Context, connection unityipc.Connection, @@ -256,7 +242,7 @@ func runFreshCompileWithBusyRetryForPausePointRecovery( if code == 0 { return 0 } - if !isRetryablePausePointRecoveryCompileResult(attemptOut.Bytes()) { + if !isCompileEditorBusyRejection(attemptOut.Bytes()) { _, _ = stdout.Write(attemptOut.Bytes()) return code } diff --git a/cli/project-runner/internal/projectrunner/run.go b/cli/project-runner/internal/projectrunner/run.go index caff9d7d91..f01f09d601 100644 --- a/cli/project-runner/internal/projectrunner/run.go +++ b/cli/project-runner/internal/projectrunner/run.go @@ -3,6 +3,7 @@ package projectrunner import ( "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -263,7 +264,7 @@ func runCompileWithReattachPolicy( return result } - return runFreshCompileWithDomainReloadWaitResultWithDeps(ctx, connection, params, stderr, compileWait) + return runFreshCompileRecoveringWithDeps(ctx, connection, params, stderr, compileWait) } func runFreshCompileWithDomainReloadWaitWithDeps( @@ -285,13 +286,31 @@ func runFreshCompileWithDomainReloadWaitResultWithDeps( stderr io.Writer, compileWait compileWaitDeps, ) compileExecutionResult { + result, _ := runFreshCompileAttempt(ctx, connection, params, stderr, compileWait, freshCompileAttemptOptions{}) + return result +} + +// runFreshCompileAttempt sends one compile request and waits for Unity to report its result. +func runFreshCompileAttempt( + ctx context.Context, + connection unityipc.Connection, + params map[string]any, + stderr io.Writer, + compileWait compileWaitDeps, + options freshCompileAttemptOptions, +) (compileExecutionResult, freshCompileAttemptOutcome) { waitTimeout, timeoutErr := compileWaitTimeoutFromParams(params) if timeoutErr != nil { clierrors.WriteClassifiedError(stderr, timeoutErr, clierrors.ErrorContext{ ProjectRoot: connection.ProjectRoot, Command: clicore.CompileCommandName, }) - return compileExecutionResult{exitCode: 1} + return compileExecutionResult{exitCode: 1}, freshCompileAttemptFinal + } + // Why only a resent attempt: the first one keeps the exact --timeout-seconds wait, which its log + // entry and its timeout message report. + if options.timeoutOverride > 0 { + waitTimeout = options.timeoutOverride } requestID, err := prepareCompileWaitParams(params) @@ -300,7 +319,7 @@ func runFreshCompileWithDomainReloadWaitResultWithDeps( ProjectRoot: connection.ProjectRoot, Command: clicore.CompileCommandName, }) - return compileExecutionResult{exitCode: 1} + return compileExecutionResult{exitCode: 1}, freshCompileAttemptFinal } logCliDebugModeResolved(connection, clicore.CompileCommandName) @@ -326,7 +345,7 @@ func runFreshCompileWithDomainReloadWaitResultWithDeps( ProjectRoot: connection.ProjectRoot, Command: clicore.CompileCommandName, }) - return compileExecutionResult{exitCode: 1} + return compileExecutionResult{exitCode: 1}, freshCompileAttemptFinal } spinner.Update("Waiting for domain reload to complete...") @@ -337,15 +356,23 @@ func runFreshCompileWithDomainReloadWaitResultWithDeps( requestID: requestID, forceRecompile: compileForceRecompileEnabled(params), timeout: waitTimeout, - pollInterval: compileWaitPollInterval, + pollInterval: freshWaitPollIntervalFor(compileWait), + resendBefore: options.resendBefore, + // Only a dispatched send reaches this wait, so a dropped connection here came after the + // request was sent. + serverRestartSeen: err != nil && clierrors.IsTransportDisconnectError(err), }, compileWait) + if errors.Is(waitErr, errCompileRequestMissing) { + spinner.Stop() + return compileExecutionResult{}, freshCompileAttemptRequestMissing + } if waitErr != nil { spinner.Stop() clierrors.WriteClassifiedError(stderr, waitErr, clierrors.ErrorContext{ ProjectRoot: connection.ProjectRoot, Command: clicore.CompileCommandName, }) - return compileExecutionResult{exitCode: 1} + return compileExecutionResult{exitCode: 1}, freshCompileAttemptFinal } if !completed { spinner.Stop() @@ -357,9 +384,13 @@ func runFreshCompileWithDomainReloadWaitResultWithDeps( time.Since(waitStartedAt), compilePendingRecordLifetime-waitTimeout, )) - return compileExecutionResult{exitCode: 1} + return compileExecutionResult{exitCode: 1}, freshCompileAttemptFinal + } + if canResendCompile(options.resendBefore) && isCompileEditorBusyRejection(result) { + spinner.Stop() + return compileExecutionResult{}, freshCompileAttemptEditorBusy } - return completeCompileResult(ctx, connection, result, stderr, spinner, startedAt, outcome) + return completeCompileResult(ctx, connection, result, stderr, spinner, startedAt, outcome), freshCompileAttemptFinal } func writePostCompileWarmupWarning(stderr io.Writer, err error) { diff --git a/docs/hot-reload.md b/docs/hot-reload.md index b47be33ff8..5601ae33ba 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -47,6 +47,21 @@ Harmony transpiler transplant (5) patch the original method with a transpiler t DynamicMethod replacement ``` +The worker's compilations hold only the edited sources (plus the changed siblings for the const +checks), so each of them also gets one synthesized tree, `WorkerGlobalUsingBindingTree`, that holds +nothing but the `global using` directives collected from the assembly's other sources. Without it, +a type a sibling file imports through a `global using` would not bind, and an existing method whose +signature names that type would look like an added method. + +Methods and property getters of a `partial` type are patched like any other. When an edited file +declares a partial type, the worker reads the assembly's other sources, keeps only the declarations +of that type's other parts, and adds them to the transform compilation as binding-only trees: +nothing is transformed or reported from them. A part counts only when the Editor's changed-file scan +compared it with the last compile's source snapshot and found it unchanged, so the type's edited +methods are skipped when a part changed and was not passed (the reason names that file) or when the +scan was incomplete (no snapshot, or more than 50 changed files); a body that names a member no +readable part declares, typically a source generator's output, is skipped on its own. + Skip reasons and introduced-type diagnostics leave the worker as a reason `code` plus its `args` (and an optional free-form detail), never as a sentence. Only the Editor's `HotReloadWorkerReasonText` turns those into the English a caller reads, so a wording change @@ -58,8 +73,16 @@ Harmony ID: `io.github.hatayama.uloop.hot-reload` (distinct from the pause point Caches: `Library/UloopHotReload/PublicizedRefs/fmt2/-.dll`, `Library/UloopHotReload/Worker//`, and `Library/UloopHotReload/SourceSnapshot/-/`. +Cecil looks up the assemblies a publicized copy refers to in the directories of the group +assembly's compile references first, then in those of every assembly it references transitively. When a verified snapshot marks a currently patched method as unchanged, the orchestrator reverts that patch to the compiled IL instead of re-emitting a shim. +The outside-method-body warning compares against the same snapshot, the source of the last +compile, so a declaration edit made since then keeps the warning on every reload until +`uloop compile`; comment-only differences do not count. + +The response's `Timing` object breaks the run down per phase (worker, shim compile, patch) +and, after a fallback compile, adds the compile's time. ## Spike Findings @@ -403,6 +426,10 @@ Wire details: - The patch ledger and loaded shim assemblies are static state; both are cleared by domain reload by design (no persistence, no auto-reapply). Shim assemblies cannot be unloaded and accumulate until the next domain reload; that is accepted. +- `uloop run-tests` compiles first by default, so that compile drops every live patch; + `--skip-compile` runs the tests against the live patches instead. What each path was observed to + do is in "Running tests while patches are live" of + `Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/mechanism-and-lifecycle.md`. - Mvid guard before patching: if the on-disk `Library/ScriptAssemblies/.dll` Mvid differs from the loaded module's `ModuleVersionId`, the assembly has already been rebuilt and reloaded — hot reload is refused with a pointer to `uloop compile`. @@ -428,7 +455,8 @@ Wire details: already hold active patches are re-applied so they bind to the newest shim. Shim compile errors caused by references to members that are still missing are reported with that hint, and changed `const` values (including enum members) are compared against the compiled target assembly and reported as - a response warning; other outside-body edits stay silent. + a response warning; other outside-body edits are reported as a response warning when a + verified source baseline is available, and stay silent without one. - A Unity message added to an existing `MonoBehaviour` is delivered by a generated proxy component that hot reload attaches to each live instance while Play Mode runs, because Unity's own message discovery only sees the compiled class. `Start`, `Update`,