diff --git a/.agents/skills/uloop-hot-reload/references/output.md b/.agents/skills/uloop-hot-reload/references/output.md index 586599bc4e..249f1eb1b8 100644 --- a/.agents/skills/uloop-hot-reload/references/output.md +++ b/.agents/skills/uloop-hot-reload/references/output.md @@ -29,7 +29,7 @@ Returns JSON with: - `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`. +- `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. When it waited for the Editor — for another uloop command to finish before the first apply, or for the Editor to settle, whether or not a second apply followed — it adds `EditorReadyWaitMs`, the wall time of all such waits in this command; the Editor's phases are then the last apply's. 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) @@ -38,7 +38,10 @@ Returns JSON with: - `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`. 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. +- `RecommendedNextAction` (string): Present in three cases. (1) Any method or introduced-type outcome is `Failed`. A failure that needs a change in the source keeps the fix advice: a partial apply (some methods patched or added, or some types left active) says to fix and rerun, run `uloop compile`, or `uloop hot-reload --revert-all`; a failure with nothing applied says to fix and rerun or compile. When every failure came from the Editor compiling or importing while the reload ran, it says there is nothing to fix: wait until `uloop status` no longer reports that, then rerun, or run `uloop compile` (after a partial apply, `uloop hot-reload --revert-all` is offered as well); beside failures that need a fix, the fix advice comes first and adds that those rows need only a rerun. A missing compiled assembly adds that there is nothing to fix in the source and that `uloop compile` is the step; in a Multiplayer Play Mode Virtual Player, that compile has to run against the main Editor's project, because a patch applied in the main Editor does not reach the player. (2) Every method of the requested files was `Skipped`, which still answers `Success`: it points first at the fix each Skipped row's `Methods[].Reason` names and offers `uloop compile` as the alternative. (3) `CompileFallback` is `HeldForPlayMode` or `BlockedByPlayModeSetting`, whatever the outcomes: the reason no compile ran is appended after any advice from (1) or (2), and it opens by saying to do any fix a `Reason` names that needs no compile before compiling. Omitted otherwise. - `CompileFallback` (string, always present): whether the CLI should run a compile after this run — `NotNeeded`, `Requested`, `HeldForPlayMode` (edits stayed unapplied but the Editor is in Play Mode and `--compile-on-skip` is `auto`), `BlockedByPlayModeSetting` (`--compile-on-skip on` during Play Mode while Unity's "Script Changes While Playing" is "Recompile After Finished Playing", which refuses the compile; `RecommendedNextAction` says to stop Play Mode first), or `Disabled` (`--compile-on-skip off`). `--status`, `--revert-all` and validation failures answer `NotNeeded`. `Skipped` rows of a sibling pulled in to re-bind its active patches do not count as unapplied edits: they are not this run's edits, and their earlier patches stay active. Its `Failed` rows do count, because a failed reload reverts those patches. A sibling retried after an earlier Skip, or brought in as a companion, still counts. +- `RetryAfterEditorReady` (bool, apply runs only): `true` when every failure of the run is the Editor compiling or importing, whether the request arrived during the compile or the compile started before the reload could be applied. The CLI then waits for the Editor to settle (up to 10 minutes) and applies the same files again in the same command, so the response you read is the second apply's. `false` on every other failure, and on `--status` and `--revert-all`. +- `SelectedFiles` (string[], apply runs only): the project-relative asset paths of the scripts the run selected, whether given as `--files` or chosen as the changed files. The retry sends exactly these, as an explicit list, so a file the default selection would leave out is not left out on the second apply. Empty on `--status` and `--revert-all`. - `Compile` (object, present only when the CLI ran the fallback compile): the full `uloop compile` response; the top-level `Success` is then the compile's, and the command's exit code is the compile's. A successful compile drops `RecommendedNextAction`, sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, removes the hold sentence from `Message`, and ends `Message` with a sentence saying the compile succeeded; a failed one leaves `Outcome`, `AutoRefreshHeld`, and `Message` as the reload reported them, and sets `RecommendedNextAction` to the compile's own `NextActions` when it reports any, and otherwise to fixing `Compile.Errors`. - `CompileFallbackNote` (string, present only with `Compile`): why the compile ran and how it ended. When it succeeded, `Outcome` is `ReplacedByCompile`, `AutoRefreshHeld` is `false`, and `Message` no longer carries the hold sentence. +- `EditorReadyRetryNote` (string, added by the CLI): present when the command waited for the Editor before sending: because another uloop command held the Editor when the request arrived (the note names that command; `--status` and `--revert-all` wait the same way and carry the note, without `Timing`), or because `RetryAfterEditorReady` was `true`. It says what the command waited for and that every other field describes the answer that followed; or that the Editor did not settle within the wait, so the fields are the earlier apply's and no compile ran. When the command waited more than once, the note carries one sentence per wait in order. A second apply that is refused again is not retried a third time: its `CompileFallback` decides the compile as usual. 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 be4772af36..25e9f5d6b0 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -431,6 +431,8 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | Resolved assembly name is missing from CompilationPipeline | Per-file entry with `Method` = `(file)`; Unity may have mapped a not-yet-imported `.asmdef` onto a predefined assembly. Run `uloop compile` first | | Script is not in the last compiled assembly's source list and its assembly membership cannot be confirmed | Per-file entry with `Method` = `(file)`; a new file passed with `--files` is hot-reloadable when its membership in an existing, unchanged compiled assembly is confirmed (`.asmdef` / `.asmref` boundaries are checked when present; a predefined assembly with none also passes), but fails when the Editor is not ready or an `.asmdef` / `.asmref` on its path was added, deleted, or changed since the last import — run `uloop compile` first | | The Editor is a Multiplayer Play Mode Virtual Player | Per-file entry with `Method` = `(file)`; a Virtual Player has no compiled assemblies under its own project root, so hot reload cannot patch it yet. The edit reaches that player only through a compile: the CLI's compile fallback brings it in when `--compile-on-skip` lets the compile run, and `auto` holds it while that player is in Play Mode (`CompileFallback` is `HeldForPlayMode`). A patch applied to the main Editor does not reach Virtual Players (each is a separate Editor process) | +| The Editor is compiling or importing when the request arrives, or starts to before the reload is applied | Per-file entry with `Method` = `(file)`; nothing in the source needs a change. The response sets `RetryAfterEditorReady`, and the CLI waits for the Editor to settle (up to 10 minutes) and applies the same request again once; if Unity's own compile already took the edit in, the second apply reports `NothingToApply`. A second refusal falls through to `--compile-on-skip`. | +| Another uloop command still holds the Editor when the request arrives (for example a `uloop compile` sent a moment earlier) | The request is not run; the CLI does not retry against the busy Editor and does not bring it to the front. It waits for that command to finish and the Editor to be ready (up to 10 minutes), then sends the same request once. `EditorReadyRetryNote` names the command it waited for and `Timing.EditorReadyWaitMs` is the wait. A busy answer to that one request is reported as `UNITY_SERVER_BUSY` without another wait. While a cancelled `execute-dynamic-code` request still holds the Editor, the wait sends the request again every 5 seconds, because the Editor takes such a request's slot back only when another request arrives. When the command that ran was a compile that already took the edit in, give `--files`: with no files the Editor selects changed files anew, finds none, and fails validation instead of reporting `NothingToApply` | | Loaded assembly differs from the one on disk (pending compile) | Run `uloop compile` first, then retry | | 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-pause-point/SKILL.md b/.agents/skills/uloop-pause-point/SKILL.md index 2379953977..67af9e67d0 100644 --- a/.agents/skills/uloop-pause-point/SKILL.md +++ b/.agents/skills/uloop-pause-point/SKILL.md @@ -33,7 +33,7 @@ Enable a pause point so Unity pauses when that code path is reached, either by a | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `--id` | string | - | Named pause point id passed to UloopPausePoint.Pause. Mutually exclusive with File/Line | -| `--file` | string | - | Project-relative source file path to patch a pause point into. Requires Line; mutually exclusive with Id | +| `--file` | string | - | Source file to patch a pause point into: `Assets/...`, `Packages//...`, a package folder path, or an absolute path. Requires Line; mutually exclusive with Id | | `--line` | integer | - | 1-based source line to resolve within File. Requires File; mutually exclusive with Id | | `--timeout-seconds` | integer | `30` | Seconds before the enable request expires and stops pausing late hits | | `--mode` | enum | `single-shot` | Capture mode: single-shot pauses once, continuous pauses on every hit, trace records hits without pausing | @@ -95,7 +95,7 @@ On a wait timeout, `PAUSE_POINT_EXPIRED`, or an enable failure, read `Error.Deta - Patches drop on every compile or domain reload (the compile / Play-entry responses warn). `--persist` re-arms automatically (`references/persist.md`). - Physics message methods, their helpers, and pre-bound delegates can miss hits on pre-existing GameObjects; enable warns where detectable. - An `--id` marker waits on your `UloopPausePoint.Pause(id)` call (`using io.github.hatayama.UnityCliLoop.Runtime;`); its hits record no `CapturedVariables`. -- For scripts under `Packages/`, pass the package-id path form (`Packages//...`); physical checkout paths do not resolve. +- Every `--file` form resolves to the asset path (`Packages//...` for a package script), which the marker `Id` uses; status, await, and clear find it by any form. ## Reference Guides @@ -109,4 +109,4 @@ Read the one whose trigger matches: - `references/condition-triggered-pause.md` — runtime-condition pauses. - `references/fast-progressing-games.md` — freezing self-progressing games, `--resume-play`. - `references/persist.md` — what `--persist` restores, timeout restart, first-frame gap, the re-arm report. -- `references/troubleshooting.md` — timeouts, missed hits, hot reload, Debug switch, failure codes. +- `references/troubleshooting.md` — timeouts, missed hits, hot reload, Debug switch, failure codes, path forms. diff --git a/.agents/skills/uloop-pause-point/references/troubleshooting.md b/.agents/skills/uloop-pause-point/references/troubleshooting.md index 38f4136b25..dbe447522b 100644 --- a/.agents/skills/uloop-pause-point/references/troubleshooting.md +++ b/.agents/skills/uloop-pause-point/references/troubleshooting.md @@ -84,6 +84,10 @@ If enable fails with a "No sequence point found" error (worded "No compiled stat A "No method named '' ..." failure means no method matching `--method` has a statement on or after `--line`. The match is case-sensitive, on the simple name or `Type.Method`, and a name that matches no method in the file fails on every line and after every compile, so check `--method` (or drop it) before moving `--line`. +## File Path Forms + +`--file` takes the asset path Unity shows (`Assets/...` or `Packages//...`), the path under a package's folder (`Packages//...` for an embedded package), or an absolute path. Every form resolves to the asset path, and a file:line marker's `Id` is `:`. `pause-point-status`, `await-pause-point`, and `clear-pause-point` resolve `--file` (or an `--id` of the form `:`) the same way, so every form finds the same marker. A named marker's `Id` is looked up as given. + ## Debug-switch trade-offs The warning that the Debug switch happened means the pause point is already armed: do not diff --git a/.claude/skills/uloop-hot-reload/references/output.md b/.claude/skills/uloop-hot-reload/references/output.md index 586599bc4e..249f1eb1b8 100644 --- a/.claude/skills/uloop-hot-reload/references/output.md +++ b/.claude/skills/uloop-hot-reload/references/output.md @@ -29,7 +29,7 @@ Returns JSON with: - `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`. +- `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. When it waited for the Editor — for another uloop command to finish before the first apply, or for the Editor to settle, whether or not a second apply followed — it adds `EditorReadyWaitMs`, the wall time of all such waits in this command; the Editor's phases are then the last apply's. 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) @@ -38,7 +38,10 @@ Returns JSON with: - `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`. 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. +- `RecommendedNextAction` (string): Present in three cases. (1) Any method or introduced-type outcome is `Failed`. A failure that needs a change in the source keeps the fix advice: a partial apply (some methods patched or added, or some types left active) says to fix and rerun, run `uloop compile`, or `uloop hot-reload --revert-all`; a failure with nothing applied says to fix and rerun or compile. When every failure came from the Editor compiling or importing while the reload ran, it says there is nothing to fix: wait until `uloop status` no longer reports that, then rerun, or run `uloop compile` (after a partial apply, `uloop hot-reload --revert-all` is offered as well); beside failures that need a fix, the fix advice comes first and adds that those rows need only a rerun. A missing compiled assembly adds that there is nothing to fix in the source and that `uloop compile` is the step; in a Multiplayer Play Mode Virtual Player, that compile has to run against the main Editor's project, because a patch applied in the main Editor does not reach the player. (2) Every method of the requested files was `Skipped`, which still answers `Success`: it points first at the fix each Skipped row's `Methods[].Reason` names and offers `uloop compile` as the alternative. (3) `CompileFallback` is `HeldForPlayMode` or `BlockedByPlayModeSetting`, whatever the outcomes: the reason no compile ran is appended after any advice from (1) or (2), and it opens by saying to do any fix a `Reason` names that needs no compile before compiling. Omitted otherwise. - `CompileFallback` (string, always present): whether the CLI should run a compile after this run — `NotNeeded`, `Requested`, `HeldForPlayMode` (edits stayed unapplied but the Editor is in Play Mode and `--compile-on-skip` is `auto`), `BlockedByPlayModeSetting` (`--compile-on-skip on` during Play Mode while Unity's "Script Changes While Playing" is "Recompile After Finished Playing", which refuses the compile; `RecommendedNextAction` says to stop Play Mode first), or `Disabled` (`--compile-on-skip off`). `--status`, `--revert-all` and validation failures answer `NotNeeded`. `Skipped` rows of a sibling pulled in to re-bind its active patches do not count as unapplied edits: they are not this run's edits, and their earlier patches stay active. Its `Failed` rows do count, because a failed reload reverts those patches. A sibling retried after an earlier Skip, or brought in as a companion, still counts. +- `RetryAfterEditorReady` (bool, apply runs only): `true` when every failure of the run is the Editor compiling or importing, whether the request arrived during the compile or the compile started before the reload could be applied. The CLI then waits for the Editor to settle (up to 10 minutes) and applies the same files again in the same command, so the response you read is the second apply's. `false` on every other failure, and on `--status` and `--revert-all`. +- `SelectedFiles` (string[], apply runs only): the project-relative asset paths of the scripts the run selected, whether given as `--files` or chosen as the changed files. The retry sends exactly these, as an explicit list, so a file the default selection would leave out is not left out on the second apply. Empty on `--status` and `--revert-all`. - `Compile` (object, present only when the CLI ran the fallback compile): the full `uloop compile` response; the top-level `Success` is then the compile's, and the command's exit code is the compile's. A successful compile drops `RecommendedNextAction`, sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, removes the hold sentence from `Message`, and ends `Message` with a sentence saying the compile succeeded; a failed one leaves `Outcome`, `AutoRefreshHeld`, and `Message` as the reload reported them, and sets `RecommendedNextAction` to the compile's own `NextActions` when it reports any, and otherwise to fixing `Compile.Errors`. - `CompileFallbackNote` (string, present only with `Compile`): why the compile ran and how it ended. When it succeeded, `Outcome` is `ReplacedByCompile`, `AutoRefreshHeld` is `false`, and `Message` no longer carries the hold sentence. +- `EditorReadyRetryNote` (string, added by the CLI): present when the command waited for the Editor before sending: because another uloop command held the Editor when the request arrived (the note names that command; `--status` and `--revert-all` wait the same way and carry the note, without `Timing`), or because `RetryAfterEditorReady` was `true`. It says what the command waited for and that every other field describes the answer that followed; or that the Editor did not settle within the wait, so the fields are the earlier apply's and no compile ran. When the command waited more than once, the note carries one sentence per wait in order. A second apply that is refused again is not retried a third time: its `CompileFallback` decides the compile as usual. 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 be4772af36..25e9f5d6b0 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -431,6 +431,8 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | Resolved assembly name is missing from CompilationPipeline | Per-file entry with `Method` = `(file)`; Unity may have mapped a not-yet-imported `.asmdef` onto a predefined assembly. Run `uloop compile` first | | Script is not in the last compiled assembly's source list and its assembly membership cannot be confirmed | Per-file entry with `Method` = `(file)`; a new file passed with `--files` is hot-reloadable when its membership in an existing, unchanged compiled assembly is confirmed (`.asmdef` / `.asmref` boundaries are checked when present; a predefined assembly with none also passes), but fails when the Editor is not ready or an `.asmdef` / `.asmref` on its path was added, deleted, or changed since the last import — run `uloop compile` first | | The Editor is a Multiplayer Play Mode Virtual Player | Per-file entry with `Method` = `(file)`; a Virtual Player has no compiled assemblies under its own project root, so hot reload cannot patch it yet. The edit reaches that player only through a compile: the CLI's compile fallback brings it in when `--compile-on-skip` lets the compile run, and `auto` holds it while that player is in Play Mode (`CompileFallback` is `HeldForPlayMode`). A patch applied to the main Editor does not reach Virtual Players (each is a separate Editor process) | +| The Editor is compiling or importing when the request arrives, or starts to before the reload is applied | Per-file entry with `Method` = `(file)`; nothing in the source needs a change. The response sets `RetryAfterEditorReady`, and the CLI waits for the Editor to settle (up to 10 minutes) and applies the same request again once; if Unity's own compile already took the edit in, the second apply reports `NothingToApply`. A second refusal falls through to `--compile-on-skip`. | +| Another uloop command still holds the Editor when the request arrives (for example a `uloop compile` sent a moment earlier) | The request is not run; the CLI does not retry against the busy Editor and does not bring it to the front. It waits for that command to finish and the Editor to be ready (up to 10 minutes), then sends the same request once. `EditorReadyRetryNote` names the command it waited for and `Timing.EditorReadyWaitMs` is the wait. A busy answer to that one request is reported as `UNITY_SERVER_BUSY` without another wait. While a cancelled `execute-dynamic-code` request still holds the Editor, the wait sends the request again every 5 seconds, because the Editor takes such a request's slot back only when another request arrives. When the command that ran was a compile that already took the edit in, give `--files`: with no files the Editor selects changed files anew, finds none, and fails validation instead of reporting `NothingToApply` | | Loaded assembly differs from the one on disk (pending compile) | Run `uloop compile` first, then retry | | 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-pause-point/SKILL.md b/.claude/skills/uloop-pause-point/SKILL.md index 2379953977..67af9e67d0 100644 --- a/.claude/skills/uloop-pause-point/SKILL.md +++ b/.claude/skills/uloop-pause-point/SKILL.md @@ -33,7 +33,7 @@ Enable a pause point so Unity pauses when that code path is reached, either by a | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `--id` | string | - | Named pause point id passed to UloopPausePoint.Pause. Mutually exclusive with File/Line | -| `--file` | string | - | Project-relative source file path to patch a pause point into. Requires Line; mutually exclusive with Id | +| `--file` | string | - | Source file to patch a pause point into: `Assets/...`, `Packages//...`, a package folder path, or an absolute path. Requires Line; mutually exclusive with Id | | `--line` | integer | - | 1-based source line to resolve within File. Requires File; mutually exclusive with Id | | `--timeout-seconds` | integer | `30` | Seconds before the enable request expires and stops pausing late hits | | `--mode` | enum | `single-shot` | Capture mode: single-shot pauses once, continuous pauses on every hit, trace records hits without pausing | @@ -95,7 +95,7 @@ On a wait timeout, `PAUSE_POINT_EXPIRED`, or an enable failure, read `Error.Deta - Patches drop on every compile or domain reload (the compile / Play-entry responses warn). `--persist` re-arms automatically (`references/persist.md`). - Physics message methods, their helpers, and pre-bound delegates can miss hits on pre-existing GameObjects; enable warns where detectable. - An `--id` marker waits on your `UloopPausePoint.Pause(id)` call (`using io.github.hatayama.UnityCliLoop.Runtime;`); its hits record no `CapturedVariables`. -- For scripts under `Packages/`, pass the package-id path form (`Packages//...`); physical checkout paths do not resolve. +- Every `--file` form resolves to the asset path (`Packages//...` for a package script), which the marker `Id` uses; status, await, and clear find it by any form. ## Reference Guides @@ -109,4 +109,4 @@ Read the one whose trigger matches: - `references/condition-triggered-pause.md` — runtime-condition pauses. - `references/fast-progressing-games.md` — freezing self-progressing games, `--resume-play`. - `references/persist.md` — what `--persist` restores, timeout restart, first-frame gap, the re-arm report. -- `references/troubleshooting.md` — timeouts, missed hits, hot reload, Debug switch, failure codes. +- `references/troubleshooting.md` — timeouts, missed hits, hot reload, Debug switch, failure codes, path forms. diff --git a/.claude/skills/uloop-pause-point/references/troubleshooting.md b/.claude/skills/uloop-pause-point/references/troubleshooting.md index 38f4136b25..dbe447522b 100644 --- a/.claude/skills/uloop-pause-point/references/troubleshooting.md +++ b/.claude/skills/uloop-pause-point/references/troubleshooting.md @@ -84,6 +84,10 @@ If enable fails with a "No sequence point found" error (worded "No compiled stat A "No method named '' ..." failure means no method matching `--method` has a statement on or after `--line`. The match is case-sensitive, on the simple name or `Type.Method`, and a name that matches no method in the file fails on every line and after every compile, so check `--method` (or drop it) before moving `--line`. +## File Path Forms + +`--file` takes the asset path Unity shows (`Assets/...` or `Packages//...`), the path under a package's folder (`Packages//...` for an embedded package), or an absolute path. Every form resolves to the asset path, and a file:line marker's `Id` is `:`. `pause-point-status`, `await-pause-point`, and `clear-pause-point` resolve `--file` (or an `--id` of the form `:`) the same way, so every form finds the same marker. A named marker's `Id` is looked up as given. + ## Debug-switch trade-offs The warning that the Debug switch happened means the pause point is already armed: do not diff --git a/AGENTS.md b/AGENTS.md index 6330f360ec..c70a3af4bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -255,7 +255,8 @@ first thing to look for behind BUSY. Details: `docs/vibe-logs.md`. Unity EditMode tests can freeze the Editor. `uloop` is single-flight per Editor: a command sent while another is still running is rejected with a BUSY error after a bounded retry, so two -`uloop run-tests` runs never overlap in one Editor. When you see BUSY, wait for the running +`uloop run-tests` runs never overlap in one Editor. `uloop hot-reload` is the one exception: it +waits for the running command to finish (up to 10 minutes) and then sends once. When you see BUSY, wait for the running command to finish and run yours once — do not retry in a loop. Separate Unity projects run in separate Editors and do not block each other. Before adding or modifying Unity EditMode tests (especially anything touching async execution, cancellation, threads, or dynamic-code diff --git a/Assets/Tests/Editor/HotReload/FixedPackageRootCapture.cs b/Assets/Tests/Editor/HotReload/FixedPackageRootCapture.cs new file mode 100644 index 0000000000..6dc8a00f75 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/FixedPackageRootCapture.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// A package root capture that always holds the roots it was built with, for tests that build + /// a collaborator reading captured roots without asking the Package Manager. + /// + internal sealed class FixedPackageRootCapture : IHotReloadPackageRootCapture + { + private readonly IReadOnlyList _roots; + + internal FixedPackageRootCapture(IReadOnlyList roots) + { + _roots = roots; + } + + public void CaptureCurrent() + { + } + + public IReadOnlyList Current => _roots; + } +} diff --git a/Assets/Tests/Editor/HotReload/FixedPackageRootCapture.cs.meta b/Assets/Tests/Editor/HotReload/FixedPackageRootCapture.cs.meta new file mode 100644 index 0000000000..ee52974bf9 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/FixedPackageRootCapture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 36ade81182dd541f1a3ef529ca8377eb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadActiveSiblingRebindPlannerTests.cs b/Assets/Tests/Editor/HotReload/HotReloadActiveSiblingRebindPlannerTests.cs index a805db9da5..2d283668f3 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadActiveSiblingRebindPlannerTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadActiveSiblingRebindPlannerTests.cs @@ -13,7 +13,8 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload /// /// Covers which files of an assembly a reload pulls back in: active files that rebind against /// the new shim (in particular a declaration file the last compile never listed), files left - /// Skipped or Failed, and companion files. + /// Skipped or Failed, and companion files; and which of them it reports as changed or as back at + /// their compiled source instead. /// public class HotReloadActiveSiblingRebindPlannerTests { @@ -26,6 +27,8 @@ public class HotReloadActiveSiblingRebindPlannerTests private HotReloadDomainTestScope _scope; private HotReloadDomainTestAccess _access; private Dictionary _workerSourceByPath; + // Stands in for the compile snapshot: tests that revert a file make it answer true. + private Func _matchesVerifiedBaseline; private string _temporaryDirectory; [SetUp] @@ -34,6 +37,7 @@ public void SetUp() _scope = new HotReloadDomainTestScope(); _access = new HotReloadDomainTestAccess(); _workerSourceByPath = new Dictionary(StringComparer.Ordinal); + _matchesVerifiedBaseline = (path, workerSourcePath) => false; _temporaryDirectory = Path.Combine(Path.GetTempPath(), "uloop-sibling-planner-" + Guid.NewGuid()); Directory.CreateDirectory(_temporaryDirectory); } @@ -198,6 +202,127 @@ public void Plan_Companion_IsIncludedWhileUnchangedAndReportedOnceChanged() Assert.That(changed.ChangedCompanionPaths, Is.EqualTo(new[] { CompiledSiblingPath })); } + /// + /// What: a file left Skipped or Failed whose source went back to the compile snapshot is + /// listed as reverted, after its path and the source the worker reads were handed to the + /// snapshot check, and is neither retried nor reported as changed. + /// + [Test] + public void Plan_NotFullyAppliedFileBackAtItsCompiledSource_IsForgottenInsteadOfReportedAsChanged() + { + RecordAppliedSourceOnly(CompiledSiblingPath, isFullyApplied: false); + string workerSourcePath = _workerSourceByPath[CompiledSiblingPath]; + File.WriteAllText(workerSourcePath, "// back at its compiled source\n"); + _matchesVerifiedBaseline = (path, source) => + string.Equals(path, CompiledSiblingPath, StringComparison.Ordinal) + && string.Equals(source, workerSourcePath, StringComparison.Ordinal); + + HotReloadActiveSiblingRebindPlan plan = Plan(new[] { CompiledSiblingPath }); + + Assert.That(plan.RevertedSinceSkipPaths, Is.EqualTo(new[] { CompiledSiblingPath })); + Assert.That(plan.ChangedSinceSkipPaths, Is.Empty); + Assert.That(PathsOf(plan), Is.Empty); + } + + /// + /// What: a file left Skipped or Failed whose source holds neither its recorded bytes nor the + /// compile snapshot is still reported as changed since the skip. + /// + [Test] + public void Plan_NotFullyAppliedFileChangedToSomethingElse_IsStillReportedAsChanged() + { + RecordAppliedSourceOnly(CompiledSiblingPath, isFullyApplied: false); + File.WriteAllText(_workerSourceByPath[CompiledSiblingPath], "// edited again after the skip\n"); + + HotReloadActiveSiblingRebindPlan plan = Plan(new[] { CompiledSiblingPath }); + + Assert.That(plan.ChangedSinceSkipPaths, Is.EqualTo(new[] { CompiledSiblingPath })); + Assert.That(plan.RevertedSinceSkipPaths, Is.Empty); + Assert.That(PathsOf(plan), Is.Empty); + } + + /// + /// What: a file that holds active changes and was also left Skipped or Failed is reported as + /// changed since its changes were applied when its source goes back to the compile snapshot, + /// because those changes still run. + /// + [Test] + public void Plan_NotFullyAppliedFileWithActiveChangesBackAtItsCompiledSource_IsReportedUnderActiveChanges() + { + ArrangeAppliedFile(CompiledSiblingPath, isFullyApplied: false); + File.WriteAllText(_workerSourceByPath[CompiledSiblingPath], "// back at its compiled source\n"); + _matchesVerifiedBaseline = (path, source) => true; + + HotReloadActiveSiblingRebindPlan plan = Plan(new[] { CompiledSiblingPath }); + + Assert.That(plan.ChangedSinceApplyPaths, Is.EqualTo(new[] { CompiledSiblingPath })); + Assert.That(plan.RevertedSinceSkipPaths, Is.Empty); + Assert.That(plan.ChangedSinceSkipPaths, Is.Empty); + } + + /// + /// What: a file left Skipped or Failed whose source still hashes to its record is retried + /// without the compile snapshot being read. + /// + [Test] + public void Plan_NotFullyAppliedFileBackAtItsCompiledSource_DoesNotAskTheBaselineWhenTheRecordMatches() + { + RecordAppliedSourceOnly(CompiledSiblingPath, isFullyApplied: false); + _matchesVerifiedBaseline = (path, source) => + { + Assert.Fail("The snapshot must not be read for a file whose source matches its record."); + return false; + }; + + HotReloadActiveSiblingRebindPlan plan = Plan(new[] { CompiledSiblingPath }); + + Assert.That(PathsOf(plan), Is.EqualTo(new[] { CompiledSiblingPath })); + Assert.That(plan.RevertedSinceSkipPaths, Is.Empty); + } + + /// + /// What: a companion whose explicit reload later Failed, and whose source then went back to + /// the compile snapshot, is listed as reverted rather than reported as changed since the skip + /// or as a changed companion. + /// + [Test] + public void Plan_NotFullyAppliedCompanionBackAtItsCompiledSource_IsForgottenInsteadOfReportedAsAChangedCompanion() + { + RecordAppliedSourceOnly(CompiledSiblingPath, isFullyApplied: false); + _access.Domain.CompanionSources.Record(CompiledSiblingPath, "edited-companion-hash"); + File.WriteAllText(_workerSourceByPath[CompiledSiblingPath], "// back at its compiled source\n"); + _matchesVerifiedBaseline = (path, source) => true; + + HotReloadActiveSiblingRebindPlan plan = Plan(new[] { CompiledSiblingPath }); + + Assert.That(plan.RevertedSinceSkipPaths, Is.EqualTo(new[] { CompiledSiblingPath })); + Assert.That(plan.ChangedSinceSkipPaths, Is.Empty); + Assert.That(plan.ChangedCompanionPaths, Is.Empty); + Assert.That(PathsOf(plan), Is.Empty); + } + + /// + /// What: a companion recorded while edited whose source went back to the compile snapshot is + /// still reported as a changed companion, because only a file left Skipped or Failed is + /// forgotten that way. + /// + [Test] + public void Plan_CompanionBackAtItsCompiledSource_IsStillReportedAsAChangedCompanion() + { + string workerSourcePath = WriteWorkerSource(CompiledSiblingPath); + _access.Domain.CompanionSources.Record( + CompiledSiblingPath, + new HotReloadSourceContentHasher().ComputeContentHash(File.ReadAllBytes(workerSourcePath))); + File.WriteAllText(workerSourcePath, "// back at its compiled source\n"); + _matchesVerifiedBaseline = (path, source) => true; + + HotReloadActiveSiblingRebindPlan plan = Plan(new[] { CompiledSiblingPath }); + + Assert.That(plan.ChangedCompanionPaths, Is.EqualTo(new[] { CompiledSiblingPath })); + Assert.That(plan.RevertedSinceSkipPaths, Is.Empty); + Assert.That(PathsOf(plan), Is.Empty); + } + private HotReloadActiveSiblingRebindPlan Plan(string[] assemblySourceFiles) { return HotReloadActiveSiblingRebindPlanner.Plan( @@ -207,7 +332,8 @@ private HotReloadActiveSiblingRebindPlan Plan(string[] assemblySourceFiles) Array.Empty(), path => _workerSourceByPath.TryGetValue(path, out string workerSourcePath) ? workerSourcePath - : null); + : null, + _matchesVerifiedBaseline); } private static string[] PathsOf(HotReloadActiveSiblingRebindPlan plan) @@ -223,14 +349,14 @@ private static string[] PathsOf(HotReloadActiveSiblingRebindPlan plan) // An applied file with an added member of its own: what makes the domain name it as an // active sibling in the first place. - private void ArrangeAppliedFile(string projectRelativePath) + private void ArrangeAppliedFile(string projectRelativePath, bool isFullyApplied = true) { _access.RegisterAddedMember( projectRelativePath, "PlannerHost.Added" + Path.GetFileNameWithoutExtension(projectRelativePath) + "()", GetAddedTarget(), projectRelativePath); - RecordAppliedSourceOnly(projectRelativePath); + RecordAppliedSourceOnly(projectRelativePath, isFullyApplied); } private void RecordAppliedSourceOnly(string projectRelativePath, bool isFullyApplied = true) diff --git a/Assets/Tests/Editor/HotReload/HotReloadBusyEditorResponseE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadBusyEditorResponseE2ETests.cs new file mode 100644 index 0000000000..bef80971f8 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadBusyEditorResponseE2ETests.cs @@ -0,0 +1,221 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers the next step a response recommends when the Editor's state stops a run, at the + /// commit boundary or when the request arrives: a state that ends on its own asks for a wait + /// and a retry, and one that leaves errors behind asks for a fix. + /// + public class HotReloadBusyEditorResponseE2ETests + { + private const string CallerFileName = "HotReloadCrossFileAddedMemberCaller.cs"; + + private const string CallerBodyAnchor = "return host.Value();"; + + private const string CallerProjectRelativePath = "Assets/Tests/Editor/HotReload/" + CallerFileName; + + private static readonly HotReloadEditorStateSnapshot IdleEditor = + new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: false, scriptCompilationFailed: false); + + private static readonly HotReloadEditorStateSnapshot CompilingEditor = + new HotReloadEditorStateSnapshot(isCompiling: true, isUpdating: false, scriptCompilationFailed: false); + + private HotReloadDomainTestScope _scope; + + [SetUp] + public void SetUp() + { + _scope = new HotReloadDomainTestScope(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + } + + [TearDown] + public void TearDown() + { + _scope.Dispose(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + } + + /// + /// What: a compile the Editor starts after the request arrived refuses the run at the commit + /// boundary, and the response asks for a wait and a retry rather than for a fix. + /// + [Test] + public async Task Run_WhenTheEditorIsCompilingAtCommit_RecommendsWaitingNotFixing() + { + // The first capture is the early check while the file is resolved; the second is the + // commit boundary, so only the boundary sees the compile. + int captures = 0; + HotReloadResponse response = await RunEditingTheCallerBodyAsync( + () => ++captures == 1 ? IdleEditor : CompilingEditor); + + AssertRefusedAtTheCommitBoundary(response); + Assert.That( + response.RecommendedNextAction, + Is.EqualTo(HotReloadConstants.EditorNotReadyRecommendedNextAction)); + Assert.That(response.RecommendedNextAction, Does.Not.Contain("Fix the failed declarations")); + Assert.That(response.RetryAfterEditorReady, Is.True); + } + + /// + /// What: a failed last compile refuses the run at the same boundary, but its errors stay + /// until the reader fixes them, so the response keeps the fix advice. + /// + [Test] + public async Task Run_WhenTheLastCompileFailedAtCommit_KeepsTheFixAdvice() + { + HotReloadEditorStateSnapshot compileFailed = + new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: false, scriptCompilationFailed: true); + HotReloadResponse response = await RunEditingTheCallerBodyAsync(() => compileFailed); + + AssertRefusedAtTheCommitBoundary(response); + Assert.That( + response.RecommendedNextAction, + Is.EqualTo(HotReloadConstants.FailedWithNoApplyRecommendedNextAction)); + Assert.That(response.RetryAfterEditorReady, Is.False); + } + + /// + /// What: a compile already running when the request arrives refuses the file before the + /// transform, and the response asks for a retry once the Editor settles. + /// + [Test] + public async Task Run_WhenTheEditorIsCompilingWhenTheRequestArrives_RefusesBeforeTheTransformAndAsksForARetry() + { + HotReloadResponse response = await RunEditingTheCallerBodyAsync(() => CompilingEditor); + + AssertRefusedBeforeTheTransform(response, HotReloadConstants.EditorCompilingBeforeTransformReason); + } + + /// + /// What: an asset import already running when the request arrives refuses the file before + /// the transform, and the response asks for a retry once the Editor settles. + /// + [Test] + public async Task Run_WhenTheEditorIsImportingWhenTheRequestArrives_RefusesBeforeTheTransformAndAsksForARetry() + { + HotReloadEditorStateSnapshot importing = + new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: true, scriptCompilationFailed: false); + HotReloadResponse response = await RunEditingTheCallerBodyAsync(() => importing); + + AssertRefusedBeforeTheTransform(response, HotReloadConstants.EditorImportingBeforeTransformReason); + } + + /// + /// What: a file whose patches are already active and whose source is unchanged still reports + /// AlreadyActive while the Editor compiles, because it has nothing to apply. + /// + [Test] + public async Task Run_WhenAnUnchangedAppliedFileArrivesWhileTheEditorIsCompiling_StaysAlreadyActive() + { + string editedPath = WriteEditedCaller(); + HotReloadStubEditorStateSnapshotCapture capture = + new HotReloadStubEditorStateSnapshotCapture(() => IdleEditor); + + // One scope for both runs: the second run has to see the applied-source record the first + // run left in the same domain. + using (HotReloadServicesTestScope.BeginWithEditorState(capture)) + { + HotReloadResponse applied = await RunAndBuildAsync(editedPath); + Assert.That(applied.Outcome, Is.EqualTo("Applied"), "Precondition: the first run applies. " + applied.Message); + Assert.That(applied.PatchedTotal, Is.GreaterThan(0), "Precondition: the first run patches. " + applied.Message); + + capture.Capture = () => CompilingEditor; + HotReloadResponse unchanged = await RunAndBuildAsync(editedPath); + + Assert.That(unchanged.Methods.Where(row => row.Kind == "Failed"), Is.Empty, unchanged.Message); + Assert.That(unchanged.Success, Is.True, unchanged.Message); + Assert.That(unchanged.Outcome, Is.EqualTo("Applied"), unchanged.Message); + Assert.That(unchanged.AlreadyActiveTotal, Is.GreaterThan(0), unchanged.Message); + Assert.That(unchanged.PatchedTotal, Is.EqualTo(0), unchanged.Message); + Assert.That(unchanged.RetryAfterEditorReady, Is.False); + } + } + + // Why a fresh services graph per call: these runs are refused or read only one state + // sequence, so nothing has to carry over from one run to the next. + private static async Task RunEditingTheCallerBodyAsync( + Func editorState) + { + string editedPath = WriteEditedCaller(); + + using (HotReloadCompositionRoot.BeginReplacement(HotReloadCompositionRoot.CreateProductionServices())) + using (HotReloadServicesTestScope.BeginWithEditorState( + new HotReloadStubEditorStateSnapshotCapture(editorState))) + { + return await RunAndBuildAsync(editedPath); + } + } + + private static string WriteEditedCaller() + { + string callerSource = File.ReadAllText(FixturePath(CallerFileName)); + Assert.That(callerSource, Does.Contain(CallerBodyAnchor), "Precondition: caller body anchor must exist."); + return HotReloadTestSourceWriter.WriteEditedSource( + "BusyEditorResponseCaller.cs", + callerSource.Replace(CallerBodyAnchor, "return host.Value() + 3;", StringComparison.Ordinal)); + } + + // Runs the caller with the edited copy on the installed services and builds the response + // the tool would, with the caller's absolute path as the selected file. + private static async Task RunAndBuildAsync(string editedPath) + { + string[] files = { FixturePath(CallerFileName) }; + HotReloadOrchestratorResult result = await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + files, + editedPath, + CancellationToken.None); + return HotReloadApplyResponseBuilder.Build( + HotReloadCompositionRoot.Services, + result, + null, + Array.Empty(), + Array.Empty(), + isPlaying: false, + isPaused: false, + selectedFiles: files); + } + + private static void AssertRefusedBeforeTheTransform(HotReloadResponse response, string expectedReason) + { + Assert.That(response.PatchedTotal, Is.EqualTo(0), "A refused run must patch nothing. " + response.Message); + HotReloadMethodResult[] failedRows = response.Methods.Where(row => row.Kind == "Failed").ToArray(); + Assert.That(failedRows, Has.Length.EqualTo(1), response.Message); + Assert.That(failedRows[0].Reason, Is.EqualTo(expectedReason)); + // Never reaching the boundary is what shows the transform was skipped. + Assert.That(failedRows[0].Reason, Does.Not.Contain("became busy")); + Assert.That(response.RetryAfterEditorReady, Is.True); + Assert.That( + response.RecommendedNextAction, + Is.EqualTo(HotReloadConstants.EditorNotReadyRecommendedNextAction)); + Assert.That(response.SelectedFiles, Is.EqualTo(new[] { CallerProjectRelativePath })); + } + + private static void AssertRefusedAtTheCommitBoundary(HotReloadResponse response) + { + Assert.That(response.PatchedTotal, Is.EqualTo(0), "A refused run must patch nothing. " + response.Message); + HotReloadMethodResult failedRow = response.Methods.FirstOrDefault(row => row.Kind == "Failed"); + Assert.That(failedRow, Is.Not.Null, "A refused run must report a failed row. " + response.Message); + Assert.That(failedRow.Reason, Does.Contain("became busy")); + } + + 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; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadBusyEditorResponseE2ETests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadBusyEditorResponseE2ETests.cs.meta new file mode 100644 index 0000000000..9a45ce75ec --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadBusyEditorResponseE2ETests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0a704812d5a0a4e27825b02ef7184262 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2EFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2EFixture.cs new file mode 100644 index 0000000000..a97fbc85b8 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2EFixture.cs @@ -0,0 +1,17 @@ +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Compiled host whose one method the call-site cache E2E edits in two runs. Kept apart from + /// the other fixtures so their patches cannot change what these runs patch and scan. + /// + public class HotReloadCallSiteCacheE2EFixture + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int Read() + { + return 1; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2EFixture.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2EFixture.cs.meta new file mode 100644 index 0000000000..e972c430d1 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2EFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1b89b4b03540240c8bf611e9c1e8384c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2ETests.cs new file mode 100644 index 0000000000..7220607ed2 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2ETests.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +using UnityEditor.Compilation; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// End-to-end EditMode coverage for the compiled-assembly cache behind the caller scan of a + /// hot reload run: a run reads each dll it scans at most once, and the next run reads none + /// of them again. + /// + public class HotReloadCallSiteCacheE2ETests + { + private const string FixtureFileName = "HotReloadCallSiteCacheE2EFixture.cs"; + private const string FixtureProjectRelativePath = "Assets/Tests/Editor/HotReload/" + FixtureFileName; + private const string ReadBody = "return 1;"; + + private HotReloadDomainTestScope _scope; + + [SetUp] + public void SetUp() + { + _scope = new HotReloadDomainTestScope(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + } + + [TearDown] + public void TearDown() + { + _scope.Dispose(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + VibeLogger.ClearMemoryLogs(); + } + + /// + /// What: the first run reads each dll its caller scan covers at most once, and a second run + /// on the same file reads none of them again, because the scanned dlls stay cached. + /// + [Test] + public async Task Run_SecondRunOnTheSameFile_ReadsNoCompiledAssemblyAgain() + { + string fixturePath = FixturePath(); + string source = File.ReadAllText(fixturePath); + Assert.That(source, Does.Contain(ReadBody), "Precondition: the Read body anchor must exist."); + int scannedAssemblyCount = CountScannedAssemblies(); + // Why clear: entries cached by earlier tests would let the first run read nothing, + // which could not tell a cached dll from a scan that never ran. + HotReloadCompiledCallSiteCache.Shared.Clear(); + + int loadsBeforeFirst = HotReloadCompiledCallSiteCache.Shared.LoadCount; + HotReloadOrchestratorResult first = await RunWithReadReturningAsync(fixturePath, source, 2); + int loadsAfterFirst = HotReloadCompiledCallSiteCache.Shared.LoadCount; + Assert.That( + new HotReloadCallSiteCacheE2EFixture().Read(), + Is.EqualTo(2), + "Precondition: the first run must patch Read, so its caller scan runs.\n" + FormatOutcomes(first)); + + HotReloadOrchestratorResult second = await RunWithReadReturningAsync(fixturePath, source, 3); + int loadsAfterSecond = HotReloadCompiledCallSiteCache.Shared.LoadCount; + Assert.That( + new HotReloadCallSiteCacheE2EFixture().Read(), + Is.EqualTo(3), + "Precondition: the second run must patch Read, so its caller scan runs.\n" + FormatOutcomes(second)); + + Assert.That( + loadsAfterFirst - loadsBeforeFirst, + Is.InRange(1, scannedAssemblyCount), + "The first run reads each dll it scans at most once."); + Assert.That(loadsAfterSecond - loadsAfterFirst, Is.EqualTo(0), "The second run reads no dll again."); + } + + private static async Task RunWithReadReturningAsync( + string fixturePath, + string source, + int value) + { + string editedPath = HotReloadTestSourceWriter.WriteEditedSource( + "CallSiteCacheE2E.cs", + source.Replace(ReadBody, "return " + value + ";")); + HotReloadOrchestratorResult result = null; + try + { + result = await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { fixturePath }, + editedPath, + CancellationToken.None); + } + catch (OperationCanceledException exception) + { + // Why fail here: the test framework records an async test that ends canceled as + // passed, which would hide a run that never finished. + Assert.Fail("The run was canceled: " + exception.Message); + } + + return result; + } + + // The caller scan covers the fixture's assembly and every assembly that references its dll. + private static int CountScannedAssemblies() + { + string targetAssemblyName = Path.GetFileNameWithoutExtension( + CompilationPipeline.GetAssemblyNameFromScriptPath(FixtureProjectRelativePath)); + string targetDllFileName = targetAssemblyName + ".dll"; + int count = 0; + foreach (UnityCompilationAssembly assembly in CompilationPipeline.GetAssemblies()) + { + if (assembly.name == targetAssemblyName + || (assembly.allReferences ?? Array.Empty()).Any( + reference => Path.GetFileName(reference) == targetDllFileName)) + { + count++; + } + } + + Assert.That(count, Is.GreaterThan(0), "The fixture's assembly must be in the compilation pipeline."); + return count; + } + + private static string FixturePath() + { + string path = Path.GetFullPath( + Path.Combine(Application.dataPath, "Tests", "Editor", "HotReload", FixtureFileName)); + Assert.That(File.Exists(path), Is.True, "Fixture missing: " + path); + return path; + } + + 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); + } + + lines.AddRange(result.Warnings ?? new List()); + return string.Join("\n", lines); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2ETests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2ETests.cs.meta new file mode 100644 index 0000000000..0b583e3967 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCallSiteCacheE2ETests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2b7b054da922b44ea8bcf49a0a4d3930 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerFixture.cs index 5d9128d040..8a2f8dfaad 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerFixture.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerFixture.cs @@ -31,8 +31,8 @@ public static int CalledFromNestedType() } /// - /// A nested caller, so a scan can observe a caller type name that only a metadata - /// separator spells correctly. + /// A nested type that both calls and is called, so a scan can observe caller and target + /// type names that only a metadata separator spells correctly. /// public static class NestedCallerHost { @@ -40,6 +40,16 @@ public static int NestedCaller() { return CalledFromNestedType(); } + + public static int CalledFromOuterType() + { + return 10; + } + } + + public static int CallNestedTarget() + { + return NestedCallerHost.CalledFromOuterType(); } public static int CalledOnlyViaDelegate() @@ -86,6 +96,20 @@ public static Func CaptureGenericMethodTarget() return captured; } + /// + /// A parameter typed by a type parameter, which a scan matches against any parameter type + /// name, so one call site can match two targets that differ only in that name. + /// + public static int GenericParameterTarget(T value) + { + return 8; + } + + public static int CallGenericParameterTarget() + { + return GenericParameterTarget(1); + } + public static int SelfRecursive(int remaining) { if (remaining <= 0) diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerTests.cs index 5cd69dcab0..da0a6472f8 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCallSiteScannerTests.cs @@ -370,28 +370,150 @@ public void FindCallSites_NestedCaller_KeepsTheMetadataSeparator() Is.EqualTo(NestedCallerHostTypeMetadataName + "::NestedCaller()")); } + /// + /// What: a method of a nested type, named in the metadata spelling, is found through the + /// call-site index and reports its caller in the outer type. + /// + [Test] + public void FindCallSites_NestedTarget_ReportsTheCaller() + { + List hits = FindHits( + NestedCallerHostTypeMetadataName, + nameof(HotReloadCallSiteScannerFixture.NestedCallerHost.CalledFromOuterType), + Array.Empty(), + 0); + + Assert.That(hits.Count, Is.EqualTo(1)); + Assert.That( + hits[0].TargetMethodKey, + Is.EqualTo(NestedCallerHostTypeMetadataName + "::CalledFromOuterType()")); + Assert.That( + hits[0].CallerMethodKey, + Is.EqualTo(FixtureTypeMetadataName + "::CallNestedTarget()")); + } + + /// + /// What: a scan compares only the call sites the index files under its target's type and + /// method name, not every call site of the scanned assemblies. + /// + [Test] + public void FindCallSites_ExaminesOnlyTheCallSitesFiledUnderTheTargets() + { + const string targetMethodName = nameof(HotReloadCallSiteScannerFixture.CalledFromOrdinaryMethod); + HotReloadCallSiteScanner.HotReloadCallSiteScanResult result = Scan( + FixtureTypeMetadataName, + targetMethodName, + Array.Empty(), + 0); + + HotReloadCompiledCallSiteCache.Entry compiled = + HotReloadCompiledCallSiteCache.Shared.GetOrLoad(GetTestAssemblyDllPath()); + int filedUnderTarget = compiled.LookupCallSiteIndices(FixtureTypeMetadataName, targetMethodName).Count; + + Assert.That(result.Hits.Count, Is.EqualTo(1)); + // Why the test assembly's bucket alone: the other scanned assembly never calls this + // fixture method, so its bucket under the same key is empty. + Assert.That(result.ExaminedCallSiteCount, Is.EqualTo(filedUnderTarget)); + Assert.That( + result.ExaminedCallSiteCount, + Is.LessThan(compiled.CallSites.Count), + "A scan must not walk every call site of the assembly."); + } + + /// + /// What: a call site whose type-parameter argument matches two targets is reported once, + /// for whichever of them comes first in the given order, so one compiled call is never + /// counted twice. + /// + [Test] + public void FindCallSites_TwoTargetsMatchingOneCallSite_ReportsOneHitForTheFirstTarget() + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string rawAssemblyName = CompilationPipeline.GetAssemblyNameFromScriptPath( + TestScriptProjectRelativePath); + string assemblyName = Path.GetFileNameWithoutExtension(rawAssemblyName); + HotReloadCallSiteScanner.CompiledMethodIdentity typeParameterTarget = + new HotReloadCallSiteScanner.CompiledMethodIdentity( + assemblyName, + new HotReloadMetadataTypeName(FixtureTypeMetadataName), + nameof(HotReloadCallSiteScannerFixture.GenericParameterTarget), + new[] { "T" }, + 1); + HotReloadCallSiteScanner.CompiledMethodIdentity int32Target = + new HotReloadCallSiteScanner.CompiledMethodIdentity( + assemblyName, + new HotReloadMetadataTypeName(FixtureTypeMetadataName), + nameof(HotReloadCallSiteScannerFixture.GenericParameterTarget), + new[] { "System.Int32" }, + 1); + + List hits = HotReloadCallSiteScanner.FindCallSites( + projectRoot, + new[] { typeParameterTarget, int32Target }).Hits; + List reversedHits = HotReloadCallSiteScanner.FindCallSites( + projectRoot, + new[] { int32Target, typeParameterTarget }).Hits; + + Assert.That(hits.Count, Is.EqualTo(1)); + Assert.That( + hits[0].TargetMethodKey, + Is.EqualTo(FixtureTypeMetadataName + "::GenericParameterTarget`1(T)")); + Assert.That( + hits[0].CallerMethodKey, + Is.EqualTo(FixtureTypeMetadataName + "::CallGenericParameterTarget()")); + Assert.That(reversedHits.Count, Is.EqualTo(1)); + Assert.That( + reversedHits[0].TargetMethodKey, + Is.EqualTo(FixtureTypeMetadataName + "::GenericParameterTarget`1(System.Int32)")); + } + private static List FindHits( string typeMetadataName, string methodName, string[] parameterTypeFullNames, int genericArity) { - string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - string rawAssemblyName = CompilationPipeline.GetAssemblyNameFromScriptPath( - TestScriptProjectRelativePath); - string assemblyName = Path.GetFileNameWithoutExtension(rawAssemblyName); + return Scan(typeMetadataName, methodName, parameterTypeFullNames, genericArity).Hits; + } + private static HotReloadCallSiteScanner.HotReloadCallSiteScanResult Scan( + string typeMetadataName, + string methodName, + string[] parameterTypeFullNames, + int genericArity) + { HotReloadCallSiteScanner.CompiledMethodIdentity target = new HotReloadCallSiteScanner.CompiledMethodIdentity( - assemblyName, + GetTestAssemblyName(), new HotReloadMetadataTypeName(typeMetadataName), methodName, parameterTypeFullNames, genericArity); return HotReloadCallSiteScanner.FindCallSites( - projectRoot, - new[] { target }).Hits; + GetProjectRoot(), + new[] { target }); + } + + // The same path FindCallSites reads, so the shared cache returns the entry the scan used. + private static string GetTestAssemblyDllPath() + { + return Path.Combine( + GetProjectRoot(), + HotReloadConstants.ScriptAssembliesRelativeDirectory, + GetTestAssemblyName() + HotReloadConstants.CompiledAssemblyExtension); + } + + private static string GetProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + + private static string GetTestAssemblyName() + { + string rawAssemblyName = CompilationPipeline.GetAssemblyNameFromScriptPath( + TestScriptProjectRelativePath); + return Path.GetFileNameWithoutExtension(rawAssemblyName); } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs index ae81a69f5f..b475459c71 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs @@ -12,7 +12,8 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// - /// EditMode coverage for snapshot-vs-disk sibling change detection used by const-drift scanning. + /// EditMode coverage for snapshot-vs-disk sibling change detection used by const-drift scanning, + /// and for the check that one source still holds its snapshot bytes. /// public class HotReloadChangedSiblingSourceDetectorTests { @@ -330,6 +331,179 @@ public void DetectFromSnapshotDirectory_WhenEverySiblingWasCompared_IsComplete() } } + /// + /// What: a source holding the bytes of its snapshot matches it, read from the path given + /// rather than from the project file. + /// + [Test] + public void SourceMatchesSnapshotDirectory_SameBytes_IsTrue() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string revertedRelative = "Assets/Reverted.cs"; + string workerCopyRelative = "Temp/WorkerCopy/Reverted.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", revertedRelative, "compiled-bytes"); + WriteProjectFile(projectRoot, workerCopyRelative, "compiled-bytes"); + + bool matches = HotReloadChangedSiblingSourceDetector.SourceMatchesSnapshotDirectory( + projectRoot, + "Asm-mvid", + revertedRelative, + AbsoluteProjectPath(projectRoot, workerCopyRelative)); + + Assert.That(matches, Is.True); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a source whose bytes differ from its snapshot does not match, even while the project + /// file at the same path still holds the snapshot bytes, because the given source is what the + /// worker reads. + /// + [Test] + public void SourceMatchesSnapshotDirectory_DifferentBytes_IsFalse() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string revertedRelative = "Assets/Reverted.cs"; + string workerCopyRelative = "Temp/WorkerCopy/Reverted.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", revertedRelative, "compiled-bytes"); + WriteProjectFile(projectRoot, revertedRelative, "compiled-bytes"); + WriteProjectFile(projectRoot, workerCopyRelative, "edited-bytes"); + + bool matches = HotReloadChangedSiblingSourceDetector.SourceMatchesSnapshotDirectory( + projectRoot, + "Asm-mvid", + revertedRelative, + AbsoluteProjectPath(projectRoot, workerCopyRelative)); + + Assert.That(matches, Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a file the snapshot directory holds no copy of does not match, so a missing + /// snapshot is never read as "back at its compiled source". + /// + [Test] + public void SourceMatchesSnapshotDirectory_NoSnapshotFile_IsFalse() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string revertedRelative = "Assets/Reverted.cs"; + string workerCopyRelative = "Temp/WorkerCopy/Reverted.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", "Assets/Other.cs", "compiled-bytes"); + WriteProjectFile(projectRoot, workerCopyRelative, "compiled-bytes"); + + bool matches = HotReloadChangedSiblingSourceDetector.SourceMatchesSnapshotDirectory( + projectRoot, + "Asm-mvid", + revertedRelative, + AbsoluteProjectPath(projectRoot, workerCopyRelative)); + + Assert.That(matches, Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a source path with no file behind it does not match, instead of throwing. + /// + [Test] + public void SourceMatchesSnapshotDirectory_NoSourceFile_IsFalse() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string revertedRelative = "Assets/Reverted.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", revertedRelative, "compiled-bytes"); + + bool matches = HotReloadChangedSiblingSourceDetector.SourceMatchesSnapshotDirectory( + projectRoot, + "Asm-mvid", + revertedRelative, + AbsoluteProjectPath(projectRoot, "Temp/WorkerCopy/Reverted.cs")); + + Assert.That(matches, Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: without the compiled DLL the snapshot directory cannot be named, so the source does + /// not match. + /// + [Test] + public void SourceMatchesSnapshot_NoDll_IsFalse() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string workerCopyRelative = "Temp/WorkerCopy/Reverted.cs"; + WriteProjectFile(projectRoot, workerCopyRelative, "compiled-bytes"); + + bool matches = HotReloadChangedSiblingSourceDetector.SourceMatchesSnapshot( + projectRoot, + "Asm", + Path.Combine(projectRoot, "missing.dll"), + "Assets/Reverted.cs", + AbsoluteProjectPath(projectRoot, workerCopyRelative)); + + Assert.That(matches, Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a DLL without its PDB has no snapshot to compare with, so the source does not match + /// and the DLL is not read. + /// + [Test] + public void SourceMatchesSnapshot_NoPdb_IsFalse() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string workerCopyRelative = "Temp/WorkerCopy/Reverted.cs"; + string dllPath = Path.Combine(projectRoot, "Fixture.dll"); + File.WriteAllBytes(dllPath, Array.Empty()); + WriteProjectFile(projectRoot, workerCopyRelative, "compiled-bytes"); + + bool matches = HotReloadChangedSiblingSourceDetector.SourceMatchesSnapshot( + projectRoot, + "Asm", + dllPath, + "Assets/Reverted.cs", + AbsoluteProjectPath(projectRoot, workerCopyRelative)); + + Assert.That(matches, Is.False); + } + 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/HotReloadCompiledCallSiteCacheTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledCallSiteCacheTests.cs index 97db767975..97a84f6ec7 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompiledCallSiteCacheTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledCallSiteCacheTests.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using Mono.Cecil; @@ -13,13 +15,18 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// - /// Invalidation and capacity contract of the compiled call-site cache. Each test works on - /// copies of this test assembly's dll in a private temp directory so that mutating the file - /// never touches ScriptAssemblies. + /// Invalidation, capacity, run hold, and call-site index contract of the compiled call-site + /// cache. Each test works on copies of this test assembly's dll in a private temp directory so + /// that mutating the file never touches ScriptAssemblies. /// public class HotReloadCompiledCallSiteCacheTests { private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string ScannerFixtureTypeMetadataName = + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadCallSiteScannerFixture"; + private const string NestedCallerHostTypeMetadataName = ScannerFixtureTypeMetadataName + "/NestedCallerHost"; + private const string GenericHostTypeMetadataName = + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.GenericHost`1"; // Why this assembly: it is always compiled alongside the test assembly and is smaller, so // it can be zero-padded to the test assembly's length for the module identity test. private const string OtherAssemblyName = "UnityCLILoop.Tests.Editor.HotReload.CallSiteCrossAssembly"; @@ -322,6 +329,259 @@ public void Constructor_NonPositiveCapacity_Throws() Assert.Throws(() => new HotReloadCompiledCallSiteCache(0)); } + /// + /// What: inside a hold each dll is read once even when a run touches more dlls than the + /// capacity, so a run never re-reads a dll it already read. + /// + [Test] + public void GetOrLoad_InsideHold_ReadsEachDllOnce_EvenBeyondCapacity() + { + string pathA = CopyTestAssembly("a.dll"); + string pathB = CopyTestAssembly("b.dll"); + string pathC = CopyTestAssembly("c.dll"); + + using (_cache.HoldEntriesForRun()) + { + HotReloadCompiledCallSiteCache.Entry firstA = _cache.GetOrLoad(pathA); + HotReloadCompiledCallSiteCache.Entry firstB = _cache.GetOrLoad(pathB); + HotReloadCompiledCallSiteCache.Entry firstC = _cache.GetOrLoad(pathC); + HotReloadCompiledCallSiteCache.Entry secondA = _cache.GetOrLoad(pathA); + HotReloadCompiledCallSiteCache.Entry secondB = _cache.GetOrLoad(pathB); + HotReloadCompiledCallSiteCache.Entry secondC = _cache.GetOrLoad(pathC); + + Assert.That(_cache.LoadCount, Is.EqualTo(3)); + Assert.That(_cache.Count, Is.EqualTo(3)); + Assert.That(secondA, Is.SameAs(firstA)); + Assert.That(secondB, Is.SameAs(firstB)); + Assert.That(secondC, Is.SameAs(firstC)); + } + } + + /// + /// What: ending the hold evicts the least recently used entries down to the capacity, so + /// the dlls used last stay cached for the next run and the evicted one is read again. + /// + [Test] + public void HoldEntriesForRun_Dispose_EvictsDownToCapacity() + { + string pathA = CopyTestAssembly("a.dll"); + string pathB = CopyTestAssembly("b.dll"); + string pathC = CopyTestAssembly("c.dll"); + using (_cache.HoldEntriesForRun()) + { + _cache.GetOrLoad(pathA); + _cache.GetOrLoad(pathB); + _cache.GetOrLoad(pathC); + _cache.GetOrLoad(pathA); + _cache.GetOrLoad(pathB); + _cache.GetOrLoad(pathC); + } + + Assert.That(_cache.Count, Is.EqualTo(2)); + // Why B and C before A: outside a hold a miss evicts one entry before it adds, so + // reading A first would evict B and hide whether B survived the end of the hold. + _cache.GetOrLoad(pathB); + _cache.GetOrLoad(pathC); + Assert.That(_cache.LoadCount, Is.EqualTo(3), "B and C were used last and stayed cached."); + _cache.GetOrLoad(pathA); + Assert.That(_cache.LoadCount, Is.EqualTo(4), "A was used least recently and was evicted when the hold ended."); + } + + /// + /// What: ending an inner hold keeps every entry while the outer hold is open; only the end + /// of the outer hold evicts down to the capacity. + /// + [Test] + public void HoldEntriesForRun_Nested_KeepsEntriesUntilTheOuterHoldEnds() + { + string pathA = CopyTestAssembly("a.dll"); + string pathB = CopyTestAssembly("b.dll"); + string pathC = CopyTestAssembly("c.dll"); + + using (_cache.HoldEntriesForRun()) + { + using (_cache.HoldEntriesForRun()) + { + _cache.GetOrLoad(pathA); + _cache.GetOrLoad(pathB); + _cache.GetOrLoad(pathC); + } + + Assert.That(_cache.Count, Is.EqualTo(3), "The outer hold is still open."); + } + + Assert.That(_cache.Count, Is.EqualTo(2)); + } + + /// + /// What: disposing a hold twice releases it once, so a repeated Dispose cannot end the + /// outer hold early and let a run's entries be evicted. + /// + [Test] + public void HoldEntriesForRun_DisposeTwice_IsIgnored() + { + string pathA = CopyTestAssembly("a.dll"); + string pathB = CopyTestAssembly("b.dll"); + string pathC = CopyTestAssembly("c.dll"); + + using (_cache.HoldEntriesForRun()) + { + IDisposable inner = _cache.HoldEntriesForRun(); + inner.Dispose(); + inner.Dispose(); + + _cache.GetOrLoad(pathA); + _cache.GetOrLoad(pathB); + _cache.GetOrLoad(pathC); + Assert.That(_cache.Count, Is.EqualTo(3), "The outer hold is still open."); + } + + Assert.That(_cache.Count, Is.EqualTo(2)); + } + + /// + /// What: without a hold a miss still evicts the least recently used entry before it adds, + /// so lookups outside a run keep the cache within its capacity. + /// + [Test] + public void GetOrLoad_WithoutHold_StillEvictsBeforeAdding() + { + _cache.GetOrLoad(CopyTestAssembly("a.dll")); + _cache.GetOrLoad(CopyTestAssembly("b.dll")); + _cache.GetOrLoad(CopyTestAssembly("c.dll")); + + Assert.That(_cache.Count, Is.EqualTo(2)); + Assert.That(_cache.LoadCount, Is.EqualTo(3)); + } + + /// + /// What: inside a hold a dll whose fingerprint changed is read again in place without + /// evicting another entry, and the other entries are still served without a read. + /// + [Test] + public void GetOrLoad_InsideHold_StaleFingerprint_ReloadsWithoutEvicting() + { + string pathA = CopyTestAssembly("a.dll"); + string pathB = CopyTestAssembly("b.dll"); + string pathC = CopyTestAssembly("c.dll"); + + using (_cache.HoldEntriesForRun()) + { + _cache.GetOrLoad(pathA); + _cache.GetOrLoad(pathB); + _cache.GetOrLoad(pathC); + + File.SetLastWriteTimeUtc(pathA, File.GetLastWriteTimeUtc(pathA).AddSeconds(5)); + _cache.GetOrLoad(pathA); + Assert.That(_cache.LoadCount, Is.EqualTo(4)); + Assert.That(_cache.Count, Is.EqualTo(3)); + + _cache.GetOrLoad(pathB); + _cache.GetOrLoad(pathC); + Assert.That(_cache.LoadCount, Is.EqualTo(4), "Reading A again must not evict B or C."); + } + } + + /// + /// What: once a dll is indexed, its cached view no longer holds the methods' instruction + /// lists, so an entry keeps only the dll bytes and the metadata. + /// + [Test] + public void GetOrLoad_ReleasesMethodBodiesAfterIndexing() + { + // Why a private field: Cecil gives no public way to observe a released body. RVA and + // HasBody do not change, and reading Body reads it again from the image, so this peeks + // at the field name of the Cecil version the package pins (1.11.6). + FieldInfo bodyField = typeof(MethodDefinition).GetField("body", BindingFlags.Instance | BindingFlags.NonPublic); + if (bodyField == null) + { + Assert.Fail("MethodDefinition has no private 'body' field: the Cecil version changed, so update this probe."); + } + + AssemblyDefinition captured = null; + HotReloadCompiledCallSiteCache.LoadProbes probes = new HotReloadCompiledCallSiteCache.LoadProbes + { + AfterAssemblyRead = assembly => captured = assembly + }; + HotReloadCompiledCallSiteCache cache = new HotReloadCompiledCallSiteCache(2, probes); + try + { + HotReloadCompiledCallSiteCache.Entry entry = cache.GetOrLoad(CopyTestAssembly("a.dll")); + + TypeDefinition fixtureType = captured.MainModule.GetType(ScannerFixtureTypeMetadataName); + MethodDefinition ordinaryCaller = fixtureType.Methods.Single( + method => method.Name == nameof(HotReloadCallSiteScannerFixture.OrdinaryCaller)); + // Why check the walk first: Cecil reads a body only when it is asked for, so a body + // that was never walked is also null and would pass for the wrong reason. + Assert.That( + entry.CallSites.Any(callSite => callSite.Caller == ordinaryCaller), + Is.True, + "The caller's body must have been walked for its call sites."); + Assert.That(bodyField.GetValue(ordinaryCaller), Is.Null); + } + finally + { + cache.Clear(); + } + } + + /// + /// What: an entry looks up call sites by the open declaring type's full name and the method + /// name, so a call through GenericHost<int> is found under GenericHost`1, while the + /// closed type name or another method name finds nothing. + /// + [Test] + public void GetOrLoad_IndexesCallSitesByOpenDeclaringTypeAndMethodName() + { + const string targetMethodName = nameof(GenericHost.Target); + HotReloadCompiledCallSiteCache.Entry entry = _cache.GetOrLoad(CopyTestAssembly("a.dll")); + + IReadOnlyList indices = entry.LookupCallSiteIndices(GenericHostTypeMetadataName, targetMethodName); + + Assert.That(indices, Is.Not.Empty); + foreach (int index in indices) + { + MethodReference openMethod = entry.CallSites[index].Operand.GetElementMethod(); + Assert.That(openMethod.Name, Is.EqualTo(targetMethodName)); + Assert.That(openMethod.DeclaringType.GetElementType().FullName, Is.EqualTo(GenericHostTypeMetadataName)); + } + + Assert.That( + entry.LookupCallSiteIndices(GenericHostTypeMetadataName + "", targetMethodName), + Is.Empty, + "The index key is the open type name."); + Assert.That( + entry.LookupCallSiteIndices(GenericHostTypeMetadataName, targetMethodName + "Missing"), + Is.Empty, + "The index key includes the method name."); + } + + /// + /// What: a call into a nested type is indexed under the type's metadata name, which nests + /// with '/', so the reflection spelling with '+' finds nothing. + /// + [Test] + public void GetOrLoad_IndexesCallSitesOfANestedDeclaringTypeUnderTheMetadataName() + { + const string targetMethodName = nameof(HotReloadCallSiteScannerFixture.NestedCallerHost.CalledFromOuterType); + HotReloadCompiledCallSiteCache.Entry entry = _cache.GetOrLoad(CopyTestAssembly("a.dll")); + + IReadOnlyList indices = entry.LookupCallSiteIndices(NestedCallerHostTypeMetadataName, targetMethodName); + + Assert.That(indices, Is.Not.Empty); + foreach (int index in indices) + { + MethodReference operand = entry.CallSites[index].Operand; + Assert.That(operand.Name, Is.EqualTo(targetMethodName)); + Assert.That(operand.DeclaringType.FullName, Is.EqualTo(NestedCallerHostTypeMetadataName)); + } + + Assert.That( + entry.LookupCallSiteIndices(ScannerFixtureTypeMetadataName + "+NestedCallerHost", targetMethodName), + Is.Empty, + "The index key nests with the metadata separator, not the reflection one."); + } + private static void ReadFirstMethodBody(AssemblyDefinition assembly) { MethodDefinition firstWithBody = assembly.MainModule.GetTypes() diff --git a/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs index 738ab7e97f..065e265d76 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs @@ -51,8 +51,8 @@ public void InstalledServices_UseProductionCollaborators() } /// - /// What: omitting --files retains existing warnings, appends selection warnings, and prefixes - /// the exact selection message. + /// What: omitting --files retains existing warnings, appends selection warnings, prefixes + /// the exact selection message, and names the selected files in the response. /// [Test] public async Task ExecuteAsync_WhenFilesAreOmittedAndChangesExist_AppliesSelectedFilesAndPrefixesMessage() @@ -92,6 +92,7 @@ public async Task ExecuteAsync_WhenFilesAreOmittedAndChangesExist_AppliesSelecte Assert.That( response.Warnings, Is.EqualTo(new[] { "orchestrator warning", "scan limit warning" })); + Assert.That(response.SelectedFiles, Is.EqualTo(new[] { "Assets/Selected.cs" })); } /// @@ -301,8 +302,9 @@ await ExecuteAsync( } /// - /// What: a script listed twice in --files reaches the run once, as the first raw entry, and - /// the response message starts with the sentence saying so. + /// What: a script listed twice in --files reaches the run once, as the first raw entry, the + /// response message starts with the sentence saying so, and the response names it once as a + /// project-relative path. /// [Test] public async Task ExecuteAsync_WhenFilesListAScriptTwice_RunsItOnceAndSaysSo() @@ -320,6 +322,7 @@ public async Task ExecuteAsync_WhenFilesListAScriptTwice_RunsItOnceAndSaysSo() Is.EqualTo( "--files listed 'Assets/Explicit.cs' 2 times; it was processed once. " + AppliedMessageTail)); + Assert.That(response.SelectedFiles, Is.EqualTo(new[] { "Assets/Explicit.cs" })); } /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorReadyRetryTests.cs b/Assets/Tests/Editor/HotReload/HotReloadEditorReadyRetryTests.cs new file mode 100644 index 0000000000..e04b405486 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadEditorReadyRetryTests.cs @@ -0,0 +1,68 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers when an apply response asks the CLI to wait for the Editor to settle and apply the + /// same request again: only when every failure of the run is the Editor compiling or importing. + /// + [TestFixture] + public sealed class HotReloadEditorReadyRetryTests + { + /// + /// What: a run refused only because the Editor was compiling or importing asks for the retry. + /// + [Test] + public void Decide_WithOnlyEditorNotReady_IsTrue() + { + Assert.That(HotReloadEditorReadyRetry.Decide(HotReloadFailureKinds.EditorNotReady), Is.True); + } + + /// + /// What: a failure the reader has to fix next to the busy Editor gives the same result after + /// a wait, so the run does not ask for the retry. + /// + [Test] + public void Decide_WithEditorNotReadyAndDeclaration_IsFalse() + { + Assert.That( + HotReloadEditorReadyRetry.Decide( + HotReloadFailureKinds.EditorNotReady | HotReloadFailureKinds.Declaration), + Is.False); + } + + /// + /// What: a failure the reader has to fix alone does not ask for the retry. + /// + [Test] + public void Decide_WithOnlyDeclaration_IsFalse() + { + Assert.That(HotReloadEditorReadyRetry.Decide(HotReloadFailureKinds.Declaration), Is.False); + } + + /// + /// What: a missing compiled assembly needs a compile, which waiting does not run, so the run + /// does not ask for the retry even when the Editor was busy too. + /// + [Test] + public void Decide_WithEditorNotReadyAndCompiledAssemblyMissing_IsFalse() + { + Assert.That( + HotReloadEditorReadyRetry.Decide( + HotReloadFailureKinds.EditorNotReady | HotReloadFailureKinds.CompiledAssemblyMissing), + Is.False); + } + + /// + /// What: no kind at all, from a run with no failure or a failure that lost its kind, does + /// not ask for the retry; the second is treated as one the reader fixes. + /// + [Test] + public void Decide_WithNoKind_IsFalse() + { + Assert.That(HotReloadEditorReadyRetry.Decide(HotReloadFailureKinds.None), Is.False); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorReadyRetryTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadEditorReadyRetryTests.cs.meta new file mode 100644 index 0000000000..93bcb30cf6 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadEditorReadyRetryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c710883bababd413ba095b8aaa737605 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorStateSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadEditorStateSnapshotTests.cs new file mode 100644 index 0000000000..1f54b61ea5 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadEditorStateSnapshotTests.cs @@ -0,0 +1,146 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers which Editor states stop hot reload, and whether each one passes once the Editor + /// settles or needs the reader to fix something. + /// + public sealed class HotReloadEditorStateSnapshotTests + { + /// + /// What: a compile in progress is the Editor not being ready, because it ends on its own. + /// + [Test] + public void GetNotReadyFailure_WhenCompiling_IsEditorNotReady() + { + HotReloadFailureDescription failure = + new HotReloadEditorStateSnapshot(isCompiling: true, isUpdating: false, scriptCompilationFailed: false) + .GetNotReadyFailure(); + + Assert.That(failure, Is.Not.Null); + Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.EditorNotReady)); + Assert.That(failure.Message, Does.Contain("compiling")); + } + + /// + /// What: an asset import in progress is the Editor not being ready, because it ends on its own. + /// + [Test] + public void GetNotReadyFailure_WhenImporting_IsEditorNotReady() + { + HotReloadFailureDescription failure = + new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: true, scriptCompilationFailed: false) + .GetNotReadyFailure(); + + Assert.That(failure, Is.Not.Null); + Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.EditorNotReady)); + Assert.That(failure.Message, Does.Contain("importing assets")); + } + + /// + /// What: a failed last compile is something the reader has to fix, because the compile + /// errors stay until someone fixes them and waiting never clears them. + /// + [Test] + public void GetNotReadyFailure_WhenTheLastCompileFailed_IsDeclaration() + { + HotReloadFailureDescription failure = + new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: false, scriptCompilationFailed: true) + .GetNotReadyFailure(); + + Assert.That(failure, Is.Not.Null); + Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.Declaration)); + Assert.That(failure.Message, Does.Contain("Fix the compile errors")); + } + + /// + /// What: a compile that started after a failed one is still the Editor not being ready, + /// because the compile in progress may clear the errors the last one left. + /// + [Test] + public void GetNotReadyFailure_WhenCompilingAfterAFailedCompile_IsEditorNotReady() + { + HotReloadFailureDescription failure = + new HotReloadEditorStateSnapshot(isCompiling: true, isUpdating: false, scriptCompilationFailed: true) + .GetNotReadyFailure(); + + Assert.That(failure, Is.Not.Null); + Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.EditorNotReady)); + Assert.That(failure.Message, Does.Contain("compiling")); + } + + /// + /// What: an Editor that is neither compiling, importing, nor left with compile errors stops nothing. + /// + [Test] + public void GetNotReadyFailure_WhenReady_IsNull() + { + HotReloadFailureDescription failure = + new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: false, scriptCompilationFailed: false) + .GetNotReadyFailure(); + + Assert.That(failure, Is.Null); + } + + /// + /// What: a compile in progress refuses a request before the transform, with the reason that + /// the domain reload after the compile would discard the reload. + /// + [Test] + public void GetBusyFailure_WhenCompiling_IsEditorNotReady() + { + HotReloadFailureDescription failure = + new HotReloadEditorStateSnapshot(isCompiling: true, isUpdating: false, scriptCompilationFailed: false) + .GetBusyFailure(); + + Assert.That(failure, Is.Not.Null); + Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.EditorNotReady)); + Assert.That(failure.Message, Is.EqualTo(HotReloadConstants.EditorCompilingBeforeTransformReason)); + } + + /// + /// What: an asset import in progress refuses a request before the transform. + /// + [Test] + public void GetBusyFailure_WhenImporting_IsEditorNotReady() + { + HotReloadFailureDescription failure = + new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: true, scriptCompilationFailed: false) + .GetBusyFailure(); + + Assert.That(failure, Is.Not.Null); + Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.EditorNotReady)); + Assert.That(failure.Message, Is.EqualTo(HotReloadConstants.EditorImportingBeforeTransformReason)); + } + + /// + /// What: a failed last compile is not a busy Editor: the loaded assemblies are the last good + /// build, and waiting does not clear the errors. + /// + [Test] + public void GetBusyFailure_WhenOnlyTheLastCompileFailed_IsNull() + { + HotReloadFailureDescription failure = + new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: false, scriptCompilationFailed: true) + .GetBusyFailure(); + + Assert.That(failure, Is.Null); + } + + /// + /// What: an idle Editor refuses nothing before the transform. + /// + [Test] + public void GetBusyFailure_WhenReady_IsNull() + { + HotReloadFailureDescription failure = + new HotReloadEditorStateSnapshot(isCompiling: false, isUpdating: false, scriptCompilationFailed: false) + .GetBusyFailure(); + + Assert.That(failure, Is.Null); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorStateSnapshotTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadEditorStateSnapshotTests.cs.meta new file mode 100644 index 0000000000..44983ae447 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadEditorStateSnapshotTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a25e530beb29444b9aac2910d13bde66 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs index 5bd2e560be..c3bfc866ae 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs @@ -1936,7 +1936,7 @@ private static HotReloadNewSourceMembershipEvidence CaptureCurrentMembershipEvid "Library", "ScriptAssemblies", AssemblyName + ".dll"); - string failure = HotReloadNewSourceMembershipValidator.TryCapture( + HotReloadFailureDescription failure = HotReloadNewSourceMembershipValidator.TryCapture( HotReloadCompositionRoot.Services.EditorStateSnapshotCapture, projectRoot, MissingNewSourcePath, diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeActivationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeActivationTests.cs index e5c6f5c3f6..6471d1806e 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeActivationTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeActivationTests.cs @@ -372,7 +372,7 @@ public async Task Run_SameTypeDeclaredAgainByALaterReload_ReusesTheActiveTypeWit "A reload that declares an already introduced type must not fail."); AssertCallerIsPatched(second); - HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, second, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false); + HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, second, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { hostPath, callerPath }); Assert.That( response.IntroducedTypes.Count, Is.EqualTo(1), @@ -629,7 +629,7 @@ public async Task Build_IntroducedTypeCompileFails_ReportsTheCompilerErrorWithIt CancellationToken.None); } - HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false); + HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { hostPath }); Assert.That(response.Success, Is.False, "A refused declaration must fail the run."); string ownerProjectRelativePath = HotReloadPatchTargetSupport.ToProjectRelativeScriptPath( HotReloadCompositionRoot.Services.PackageRootCapture, @@ -1606,7 +1606,8 @@ private static HotReloadGroupProcessorDependencies CreateFailingMembershipDepend HotReloadGroupOutcomeRouter.AppendGroupFailure( files, "(file)", - "The compiled assembly changed while the group was being processed."); + HotReloadFailureDescription.Declaration( + "The compiled assembly changed while the group was being processed.")); return false; }, (files, input, ct) => diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeE2ETestBase.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeE2ETestBase.cs index a76e1e30d2..e96a7887d4 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeE2ETestBase.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeE2ETestBase.cs @@ -119,7 +119,7 @@ private protected static Task RunReloadAsync( private protected static HotReloadResponse BuildResponse(HotReloadOrchestratorResult result) { - return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false); + return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: Array.Empty()); } // Why the preparation stage is the only one wrapped: the test reads the edited body back diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs index 0a3da50857..17a66dab1a 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs @@ -323,7 +323,7 @@ public async Task Build_RunIntroducesNoType_EmitsEmptyTypeListAndZeroTotalOnTheW "IntroducedTypeAbsentCaller.cs", EditTheCallerBody(File.ReadAllText(callerPath))), CancellationToken.None); - HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false); + HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { callerPath }); Assert.That(response.IntroducedTypes.Count, Is.EqualTo(0)); Assert.That(response.ActiveIntroducedTypeTotal, Is.EqualTo(0)); @@ -350,7 +350,7 @@ private static async Task RunIntroducingATypeAndEditingABodyA "IntroducedTypeAndBodyHost.cs", EditTheScaledBody(InsertIntroducedType(File.ReadAllText(hostPath)))), CancellationToken.None); - return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false); + return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { hostPath }); } private static HotReloadIntroducedTypeOutcome CreateInjectedTypeFailure() @@ -410,7 +410,7 @@ private static async Task RunEditingOnlyABodyAsync() "RetainedTypeOnlyCaller.cs", EditTheCallerBody(File.ReadAllText(callerPath))), CancellationToken.None); - return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false); + return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { callerPath }); } // The production pipeline with the preparation reporting one retained declaration and the @@ -515,7 +515,7 @@ public async Task Build_DeclarationCannotBeIntroduced_WarnsWithoutFailingTheRun( "IntroducedTypeNoticeHost.cs", InsertUnintroducibleDeclaration(File.ReadAllText(hostPath))), CancellationToken.None); - HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false); + HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { hostPath }); Assert.That( response.Success, @@ -559,7 +559,7 @@ public async Task Build_DeclarationCannotBeIntroducedInAFileWithoutABaseline_Add "IntroducedTypeNoticeHostWithoutBaseline.cs", InsertUnintroducibleDeclaration(File.ReadAllText(hostPath))), CancellationToken.None); - HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false); + HotReloadResponse response = HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { hostPath }); Assert.That( FindWarning(response, "requires a compile"), @@ -762,7 +762,7 @@ private static async Task RunAgainstTheHostAsync() "IntroducedTypeFailureHost.cs", InsertIntroducedType(File.ReadAllText(hostPath))), CancellationToken.None); - return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false); + return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { hostPath }); } private static int CountTypeRows(HotReloadResponse response, string kind) @@ -819,7 +819,7 @@ private static async Task RunIntroducingOnlyATypeAsync() FindFailureReason(result), Is.Null, "Precondition: a reload that only introduces a type must not fail a method."); - return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false); + return HotReloadApplyResponseBuilder.Build(HotReloadCompositionRoot.Services, result, null, Array.Empty(), Array.Empty(), isPlaying: false, isPaused: false, selectedFiles: new[] { hostPath }); } private static string FindFailureReason(HotReloadOrchestratorResult result) @@ -890,7 +890,8 @@ public void Build_BodyEditedDeclarationBesideAnAddedMethod_DoesNotCountTheAdditi Array.Empty(), Array.Empty(), isPlaying: false, - isPaused: false); + isPaused: false, + selectedFiles: Array.Empty()); Assert.That( response.Message, diff --git a/Assets/Tests/Editor/HotReload/HotReloadNewSourceMembershipTests.cs b/Assets/Tests/Editor/HotReload/HotReloadNewSourceMembershipTests.cs index 2bfe29e98e..7f657ec6e4 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadNewSourceMembershipTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadNewSourceMembershipTests.cs @@ -10,7 +10,9 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// - /// Verifies that new-source membership admission stops before group processing while Editor state is unsafe. + /// Verifies that the patch target resolver admits a new source only with membership evidence, and + /// that it stops a file before group processing, with the kinds of failure its row reports, while + /// the Editor state is unsafe or the file's compiled assembly is missing or replaced. /// public sealed class HotReloadNewSourceMembershipTests { @@ -32,16 +34,20 @@ public void TearDown() { } + // Why the kinds travel as their name: the test method is public, and a public method cannot + // take the internal enum as a parameter. /// /// Compiling, importing, and compilation-failed Editor states each stop the production target resolver before a group can be planned. + /// A compile or an import in progress is reported as the Editor not being ready, and a failed compile as something to fix. /// - [TestCase(true, false, false)] - [TestCase(false, true, false)] - [TestCase(false, false, true)] + [TestCase(true, false, false, "EditorNotReady")] + [TestCase(false, true, false, "EditorNotReady")] + [TestCase(false, false, true, "Declaration")] public void ResolvePatchTarget_WhenEditorStateIsUnsafe_ReturnsEarlyResult( bool isCompiling, bool isUpdating, - bool scriptCompilationFailed) + bool scriptCompilationFailed, + string expectedKindsName) { using IDisposable editorStateScope = HotReloadServicesTestScope.BeginWithEditorState( new HotReloadStubEditorStateSnapshotCapture(() => new HotReloadEditorStateSnapshot(isCompiling, isUpdating, scriptCompilationFailed))); @@ -63,6 +69,7 @@ public void ResolvePatchTarget_WhenEditorStateIsUnsafe_ReturnsEarlyResult( Assert.That(resolution.EarlyResult.Outcomes, Has.Count.EqualTo(1)); Assert.That(resolution.EarlyResult.Outcomes[0].Kind, Is.EqualTo(HotReloadMethodOutcomeKind.Failed)); Assert.That(resolution.EarlyResult.Outcomes[0].Reason, Does.Contain("retry hot reload")); + Assert.That(resolution.EarlyResult.Outcomes[0].FailureKinds.ToString(), Is.EqualTo(expectedKindsName)); Assert.That(resolution.ProjectRelativePath, Is.Null); Assert.That(resolution.AssemblyName, Is.Null); Assert.That(resolution.CompilationAssembly, Is.Null); @@ -118,7 +125,7 @@ public void ResolvePatchTarget_WhenAssemblyOwnsAnActiveIntroducedType_KeepsTheLe using (HotReloadCompositionRoot.BeginReplacement(HotReloadCompositionRoot.CreateProductionServices())) { HotReloadCompositionRoot.Services.Domain.AppliedSources.RecordAppliedSource(existingScriptPath, "stale-hash", true, "/worker-copy/Recorded.cs", Array.Empty()); - ActivateIntroducedTypeFor(existingScriptPath); + ActivateIntroducedTypeFor(existingScriptPath, "artifact.dll"); ResolveExistingScript("introduced-type-active"); @@ -177,6 +184,108 @@ public void ResolvePatchTarget_WhenLedgerEntryNoLongerMatches_LeavesTheEntryForT } } + /// + /// An Editor that starts compiling after the resolver's first look at it, but before the new + /// source's membership is captured, refuses the source as the Editor not being ready. + /// + [Test] + public void ResolvePatchTarget_WhenTheEditorStartsCompilingBeforeTheMembershipCapture_ReportsTheEditorAsNotReady() + { + int captureCount = 0; + using IDisposable editorStateScope = HotReloadServicesTestScope.BeginWithEditorState( + new HotReloadStubEditorStateSnapshotCapture(() => + { + captureCount++; + return new HotReloadEditorStateSnapshot( + isCompiling: captureCount > 1, + isUpdating: false, + scriptCompilationFailed: false); + })); + + HotReloadPatchTargetResolution resolution = HotReloadPatchTargetSupport.ResolvePatchTarget( + HotReloadCompositionRoot.Services.Domain, + HotReloadCompositionRoot.Services.PackageRootCapture, + HotReloadCompositionRoot.Services.EditorStateSnapshotCapture, + MissingHotReloadScriptPath, + MissingHotReloadScriptPath, + new List(), + new List(), + "new-source-busy-at-capture", + new List()); + + Assert.That(captureCount, Is.EqualTo(2), "Precondition: the membership capture must read the second state."); + HotReloadMethodOutcome refusal = AssertRefusedWithOneRow(resolution); + Assert.That(refusal.Reason, Does.Contain("retry hot reload")); + Assert.That(refusal.FailureKinds, Is.EqualTo(HotReloadFailureKinds.EditorNotReady)); + Assert.That(resolution.NewSourceMembershipEvidence, Is.Null); + } + + /// + /// A file whose compiled assembly is missing is refused with the missing-assembly kind, so the + /// response recommends a compile rather than a fix. + /// + [Test] + public void ResolvePatchTarget_WhenTheCompiledAssemblyIsMissing_ReportsTheMissingAssembly() + { + string missingImagePath = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "uloop-missing-" + Guid.NewGuid().ToString("N") + ".dll"); + + HotReloadPatchTargetResolution resolution = ResolveExistingScriptWithItsImageAt( + missingImagePath, + "compiled-assembly-missing"); + + HotReloadMethodOutcome refusal = AssertRefusedWithOneRow(resolution); + Assert.That( + refusal.Reason, + Is.EqualTo("Compiled assembly not found at '" + missingImagePath + "'. Compile the project first.")); + Assert.That(refusal.FailureKinds, Is.EqualTo(HotReloadFailureKinds.CompiledAssemblyMissing)); + } + + /// + /// A file whose loaded assembly is not the compiled image on disk, as after a compile replaced + /// the image during the run, is refused as the Editor not being ready. + /// + [Test] + public void ResolvePatchTarget_WhenTheLoadedAssemblyIsStale_ReportsTheEditorAsNotReady() + { + string anotherAssemblysImagePath = typeof(HotReloadConstants).Assembly.Location; + Assert.That( + System.IO.File.Exists(anotherAssemblysImagePath), + Is.True, + "Precondition: the stand-in image must exist, so only its Mvid differs."); + + HotReloadPatchTargetResolution resolution = ResolveExistingScriptWithItsImageAt( + anotherAssemblysImagePath, + "loaded-assembly-stale"); + + HotReloadMethodOutcome refusal = AssertRefusedWithOneRow(resolution); + Assert.That(refusal.Reason, Is.EqualTo(HotReloadConstants.StaleAssemblyHint)); + Assert.That(refusal.FailureKinds, Is.EqualTo(HotReloadFailureKinds.EditorNotReady)); + } + + // Why an introduced-type artifact stands in for the compiled assembly: the resolver reads the + // image from the domain's type home, and an active artifact is the one home a test can point + // at a missing or a different image without touching the project's compiled assemblies. + private static HotReloadPatchTargetResolution ResolveExistingScriptWithItsImageAt( + string imagePath, + string correlationId) + { + using (HotReloadCompositionRoot.BeginReplacement(HotReloadCompositionRoot.CreateProductionServices())) + { + ActivateIntroducedTypeFor(ExistingScriptPath, imagePath); + return ResolveExistingScript(correlationId); + } + } + + private static HotReloadMethodOutcome AssertRefusedWithOneRow(HotReloadPatchTargetResolution resolution) + { + Assert.That(resolution.EarlyResult, Is.Not.Null); + Assert.That(resolution.EarlyResult.Outcomes, Has.Count.EqualTo(1)); + Assert.That(resolution.EarlyResult.Outcomes[0].Kind, Is.EqualTo(HotReloadMethodOutcomeKind.Failed)); + return resolution.EarlyResult.Outcomes[0]; + } + private static HotReloadPatchTargetResolution ResolveExistingScript(string correlationId) { using IDisposable editorStateScope = HotReloadServicesTestScope.BeginWithEditorState( @@ -193,7 +302,7 @@ private static HotReloadPatchTargetResolution ResolveExistingScript(string corre new List()); } - private static void ActivateIntroducedTypeFor(string projectRelativeScriptPath) + private static void ActivateIntroducedTypeFor(string projectRelativeScriptPath, string artifactDllPath) { string assemblyName = System.IO.Path.GetFileNameWithoutExtension( UnityEditor.Compilation.CompilationPipeline.GetAssemblyNameFromScriptPath(projectRelativeScriptPath)); @@ -206,7 +315,7 @@ private static void ActivateIntroducedTypeFor(string projectRelativeScriptPath) "public class Introduced { }"); HotReloadIntroducedTypeArtifact artifact = new HotReloadIntroducedTypeArtifact( typeof(HotReloadNewSourceMembershipTests).Assembly, - "artifact.dll", + artifactDllPath, "artifact.pdb", new List { descriptor }); HotReloadCompositionRoot.Services.Domain.IntroducedTypes.RegisterPrepared(artifact); diff --git a/Assets/Tests/Editor/HotReload/HotReloadNewSourceMembershipValidatorEditorReadTests.cs b/Assets/Tests/Editor/HotReload/HotReloadNewSourceMembershipValidatorEditorReadTests.cs index 78487e0e33..83844469e7 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadNewSourceMembershipValidatorEditorReadTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadNewSourceMembershipValidatorEditorReadTests.cs @@ -36,7 +36,7 @@ public sealed class HotReloadNewSourceMembershipValidatorEditorReadTests [Test] public void TryCapture_WhenUnityResolvesTheSourceToAnotherAssembly_ReturnsDifferentAssemblyFailure() { - string failure = HotReloadNewSourceMembershipValidator.TryCapture( + HotReloadFailureDescription failure = HotReloadNewSourceMembershipValidator.TryCapture( CreateReadyCapture(), GetProjectRoot(), MissingSourceInExistingDirectory, @@ -45,7 +45,7 @@ public void TryCapture_WhenUnityResolvesTheSourceToAnotherAssembly_ReturnsDiffer BuildScriptAssemblyDllPath(HotReloadTestAssemblyName), out HotReloadNewSourceMembershipEvidence evidence); - Assert.That(failure, Is.EqualTo(DifferentAssemblyFailure)); + Assert.That(failure?.Message, Is.EqualTo(DifferentAssemblyFailure)); Assert.That(evidence, Is.Null); } @@ -64,10 +64,10 @@ public void TryRevalidate_WhenAssemblyIsNotInTheCompilationPipeline_ReturnsAssem null, Array.Empty()); - string failure = HotReloadNewSourceMembershipValidator.TryRevalidate(CreateReadyCapture(), evidence); + HotReloadFailureDescription failure = HotReloadNewSourceMembershipValidator.TryRevalidate(CreateReadyCapture(), evidence); Assert.That( - failure, + failure?.Message, Is.EqualTo("The resolved assembly is no longer present in the compilation pipeline. Compile the project and retry hot reload.")); } @@ -84,10 +84,10 @@ public void TryRevalidate_WhenSourceDirectoryIsMissing_ReturnsBoundaryFailure() HotReloadTestAssemblyName, Array.Empty()); - string failure = HotReloadNewSourceMembershipValidator.TryRevalidate(CreateReadyCapture(), evidence); + HotReloadFailureDescription failure = HotReloadNewSourceMembershipValidator.TryRevalidate(CreateReadyCapture(), evidence); Assert.That( - failure, + failure?.Message, Is.EqualTo("The new source membership boundary is not available on disk. Compile the project and retry hot reload.")); } @@ -102,9 +102,9 @@ public void TryRevalidate_WhenUnityResolvesTheSourceToAnotherAssembly_ReturnsDif EditorTestAssemblyName, Array.Empty()); - string failure = HotReloadNewSourceMembershipValidator.TryRevalidate(CreateReadyCapture(), evidence); + HotReloadFailureDescription failure = HotReloadNewSourceMembershipValidator.TryRevalidate(CreateReadyCapture(), evidence); - Assert.That(failure, Is.EqualTo(DifferentAssemblyFailure)); + Assert.That(failure?.Message, Is.EqualTo(DifferentAssemblyFailure)); } /// @@ -113,7 +113,7 @@ public void TryRevalidate_WhenUnityResolvesTheSourceToAnotherAssembly_ReturnsDif [Test] public void TryRevalidate_WhenCapturedBoundariesNoLongerMatch_ReturnsMembershipChangedFailure() { - string captureFailure = HotReloadNewSourceMembershipValidator.TryCapture( + HotReloadFailureDescription captureFailure = HotReloadNewSourceMembershipValidator.TryCapture( CreateReadyCapture(), GetProjectRoot(), MissingSourceInExistingDirectory, @@ -131,15 +131,49 @@ public void TryRevalidate_WhenCapturedBoundariesNoLongerMatch_ReturnsMembershipC captured.ResolvedAssemblyDefinitionPath, Array.Empty()); - string failure = HotReloadNewSourceMembershipValidator.TryRevalidate( + HotReloadFailureDescription failure = HotReloadNewSourceMembershipValidator.TryRevalidate( CreateReadyCapture(), withoutBoundaries); Assert.That( - failure, + failure?.Message, Is.EqualTo("Assembly definition membership changed while hot reload was preparing. Compile the project and retry hot reload.")); } + /// + /// Verifies that revalidation reports a compiled assembly rebuilt since the capture as the + /// Editor not being ready, because the compile that rebuilt it leaves nothing to fix. + /// + [Test] + public void TryRevalidate_WhenTheCompiledAssemblyWasRebuiltSinceTheCapture_ReportsTheEditorAsNotReady() + { + HotReloadFailureDescription captureFailure = HotReloadNewSourceMembershipValidator.TryCapture( + CreateReadyCapture(), + GetProjectRoot(), + MissingSourceInExistingDirectory, + HotReloadTestAssemblyName, + FindCompilationAssemblyByName(HotReloadTestAssemblyName), + BuildScriptAssemblyDllPath(HotReloadTestAssemblyName), + out HotReloadNewSourceMembershipEvidence captured); + Assert.That(captureFailure, Is.Null, "Precondition: the real capture must succeed."); + HotReloadNewSourceMembershipEvidence capturedBeforeARebuild = new HotReloadNewSourceMembershipEvidence( + captured.ProjectRelativePath, + captured.AssemblyName, + captured.TargetDllPath, + Guid.NewGuid().ToString(), + captured.ResolvedAssemblyDefinitionPath, + captured.Boundaries); + + HotReloadFailureDescription failure = HotReloadNewSourceMembershipValidator.TryRevalidate( + CreateReadyCapture(), + capturedBeforeARebuild); + + Assert.That( + failure?.Message, + Is.EqualTo("The compiled assembly changed while hot reload was preparing. Compile the project and retry hot reload.")); + Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.EditorNotReady)); + } + /// /// Verifies that resolution refuses when the compilation assembly passed in is not the assembly the caller named. /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadNewSourceMembershipValidatorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadNewSourceMembershipValidatorTests.cs index 715ae74e26..965f2172aa 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadNewSourceMembershipValidatorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadNewSourceMembershipValidatorTests.cs @@ -23,7 +23,7 @@ public void TryCapture_WhenEditorIsCompiling_ReturnsNotReadyReasonWithoutEvidenc HotReloadStubEditorStateSnapshotCapture capture = new HotReloadStubEditorStateSnapshotCapture( () => new HotReloadEditorStateSnapshot(true, false, false)); - string failure = HotReloadNewSourceMembershipValidator.TryCapture( + HotReloadFailureDescription failure = HotReloadNewSourceMembershipValidator.TryCapture( capture, CreateMissingProjectRoot(), SourcePath, @@ -33,8 +33,9 @@ public void TryCapture_WhenEditorIsCompiling_ReturnsNotReadyReasonWithoutEvidenc out HotReloadNewSourceMembershipEvidence evidence); Assert.That( - failure, + failure?.Message, Is.EqualTo("The Editor is compiling, so new source membership is not ready. Compile the project first and retry hot reload.")); + Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.EditorNotReady)); Assert.That(evidence, Is.Null); } @@ -47,7 +48,7 @@ public void TryCapture_WhenSourceDirectoryIsMissing_ReturnsBoundaryFailureWithou HotReloadStubEditorStateSnapshotCapture capture = new HotReloadStubEditorStateSnapshotCapture( () => new HotReloadEditorStateSnapshot(false, false, false)); - string failure = HotReloadNewSourceMembershipValidator.TryCapture( + HotReloadFailureDescription failure = HotReloadNewSourceMembershipValidator.TryCapture( capture, CreateMissingProjectRoot(), SourcePath, @@ -57,8 +58,9 @@ public void TryCapture_WhenSourceDirectoryIsMissing_ReturnsBoundaryFailureWithou out HotReloadNewSourceMembershipEvidence evidence); Assert.That( - failure, + failure?.Message, Is.EqualTo("The new source membership boundary is not available on disk. Compile the project and retry hot reload.")); + Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.Declaration)); Assert.That(evidence, Is.Null); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadPackageSourceE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadPackageSourceE2ETests.cs new file mode 100644 index 0000000000..7ccf01e11b --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPackageSourceE2ETests.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +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 an edit of a source in an embedded package, whose asset path + /// (Packages/<package-name>/...) and file path (Packages/<folder>/...) differ: the + /// edited method alone is patched, whichever of the two paths names the file. + /// + /// + /// This test assembly does not reference the fixture package, the same as an assembly a user + /// edits is not referenced by the tool, so the fixture type is reached through reflection. + /// + public class HotReloadPackageSourceE2ETests + { + private const string FixtureAssetPath = + "Packages/io.github.hatayama.uloop.hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs"; + private const string FixturePhysicalPath = + "Packages/uloop-hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs"; + private const string FixtureTypeName = "HotReloadPackageFixture"; + private const string FixtureAssemblyQualifiedTypeName = + "io.github.hatayama.UnityCliLoop.Tests.PackageFixture." + FixtureTypeName + + ", UnityCLILoop.Tests.HotReloadPackageFixture"; + private const string SecondCompiledBody = "return 2;"; + private const string SecondEditedBody = "return 20;"; + // Part of the warning for a file without a baseline, whatever the reason the baseline is missing. + private const string PatchingAllMethodsWarningPart = "patching all methods"; + + private HotReloadDomainTestScope _scope; + + [SetUp] + public void SetUp() + { + _scope = new HotReloadDomainTestScope(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + } + + [TearDown] + public void TearDown() + { + _scope.Dispose(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + VibeLogger.ClearMemoryLogs(); + } + + /// + /// What: an edit of one method of a package source named by its asset path patches that + /// method alone, without the missing-baseline warning, and the edited body runs. + /// + [Test] + public async Task Run_EditingOneMethodOfAPackageSource_PatchesOnlyThatMethod() + { + HotReloadOrchestratorResult result = await RunEditOfSecondAsync(FixtureAssetPath, "PackageSourceByAssetPath.cs"); + + AssertOnlySecondIsPatched(result); + Assert.That(CallFixture("Second"), Is.EqualTo(20), FormatOutcomes(result)); + Assert.That(CallFixture("First"), Is.EqualTo(1), FormatOutcomes(result)); + } + + /// + /// What: the same edit, with the file named by its path in the package's folder, gives the + /// same result, since that path is mapped to the asset path before anything else. + /// + [Test] + public async Task Run_EditingOneMethodOfAPackageSourceGivenByItsPhysicalPath_PatchesOnlyThatMethod() + { + HotReloadOrchestratorResult result = await RunEditOfSecondAsync(FixturePhysicalPath, "PackageSourceByPhysicalPath.cs"); + + AssertOnlySecondIsPatched(result); + Assert.That(CallFixture("Second"), Is.EqualTo(20), FormatOutcomes(result)); + Assert.That(CallFixture("First"), Is.EqualTo(1), FormatOutcomes(result)); + } + + // Unchanged methods get no row; they are only counted. Why at least two rather than exactly + // two: a method the compiler adds, such as an implicit constructor, may be counted too. + private static void AssertOnlySecondIsPatched(HotReloadOrchestratorResult result) + { + Assert.That(FindRow(result, "Second").Kind, Is.EqualTo(HotReloadMethodOutcomeKind.Patched), FormatOutcomes(result)); + Assert.That(CountRows(result, HotReloadMethodOutcomeKind.Patched), Is.EqualTo(1), FormatOutcomes(result)); + Assert.That(result.UnchangedTotal, Is.GreaterThanOrEqualTo(2), "First and Third are unchanged.\n" + FormatOutcomes(result)); + Assert.That(CountRows(result, HotReloadMethodOutcomeKind.Failed), Is.EqualTo(0), FormatOutcomes(result)); + foreach (string warning in result.Warnings) + { + Assert.That( + warning == null || !warning.Contains(PatchingAllMethodsWarningPart, StringComparison.Ordinal), + Is.True, + "A package source with a verified snapshot must have a baseline.\nUnexpected warning: " + warning); + } + } + + private static async Task RunEditOfSecondAsync(string inputPath, string editedFileName) + { + string fixtureFilePath = Path.GetFullPath(Path.Combine(Application.dataPath, "..", FixturePhysicalPath)); + string editedSource = ReplaceOnce(File.ReadAllText(fixtureFilePath), SecondCompiledBody, SecondEditedBody); + string editedPath = HotReloadTestSourceWriter.WriteEditedSource(editedFileName, editedSource); + return await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { inputPath }, + contentPathOverride: null, + CancellationToken.None, + new Dictionary { [inputPath] = editedPath }); + } + + // Why the uniqueness check: a fragment that also matched another method would edit a method + // the test does not look at, and the asserts 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 int CallFixture(string methodName) + { + Type fixtureType = Type.GetType(FixtureAssemblyQualifiedTypeName, throwOnError: true); + object fixture = Activator.CreateInstance(fixtureType); + MethodInfo method = fixtureType.GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance); + Assert.That(method, Is.Not.Null, "Fixture method missing: " + methodName); + return (int)method.Invoke(fixture, null); + } + + // Why the type and the parenthesis: a bare method name would also match a longer name that + // contains it. + private static HotReloadMethodOutcome FindRow(HotReloadOrchestratorResult result, string methodName) + { + string labelPart = "." + FixtureTypeName + "." + methodName + "("; + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + if (outcome.Method != null && outcome.Method.Contains(labelPart)) + { + return outcome; + } + } + + Assert.Fail("No row for " + FixtureTypeName + "." + methodName + ".\n" + FormatOutcomes(result)); + return null; + } + + private static int CountRows(HotReloadOrchestratorResult result, HotReloadMethodOutcomeKind kind) + { + int count = 0; + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + if (outcome.Kind == kind) + { + count++; + } + } + + return count; + } + + 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); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPackageSourceE2ETests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPackageSourceE2ETests.cs.meta new file mode 100644 index 0000000000..e9f5ba33f7 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPackageSourceE2ETests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f733b9b5715ab4dbea0274f4782b4f53 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPatchTargetSupportEditorReadTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPatchTargetSupportEditorReadTests.cs index 2af851e072..53a009e1af 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPatchTargetSupportEditorReadTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPatchTargetSupportEditorReadTests.cs @@ -97,6 +97,10 @@ private static void AssertSingleFailure(HotReloadPatchTargetResolution resolutio Assert.That(resolution.EarlyResult.Outcomes, Has.Count.EqualTo(1)); Assert.That(resolution.EarlyResult.Outcomes[0].Kind, Is.EqualTo(HotReloadMethodOutcomeKind.Failed)); Assert.That(resolution.EarlyResult.Outcomes[0].Reason, Is.EqualTo(expectedReason)); + Assert.That( + resolution.EarlyResult.Outcomes[0].FailureKinds, + Is.EqualTo(HotReloadFailureKinds.Declaration), + "None of these exits clears by waiting, so each keeps the fix advice."); Assert.That(resolution.NewSourceMembershipEvidence, Is.Null); } @@ -122,7 +126,7 @@ public void CaptureCurrent() { } - public IReadOnlyList Current => Array.Empty(); + public IReadOnlyList Current => Array.Empty(); } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadPatchTargetSupportTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPatchTargetSupportTests.cs index b620a7d1de..ed12f98ce9 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPatchTargetSupportTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPatchTargetSupportTests.cs @@ -26,7 +26,8 @@ public void BuildAssemblySourcePaths_WhenSourceFilesAreNull_ReturnsEmptyArray() } /// - /// Verifies that a loaded assembly whose MVID differs from the compiled dll is reported as stale. + /// Verifies that a loaded assembly whose MVID differs from the compiled dll is reported as + /// stale, and as the Editor not being ready, because a compile or a reload replaced it. /// [Test] public void CheckMvidGuard_WhenLoadedAssemblyMvidDiffersFromDll_ReturnsStaleHint() @@ -35,13 +36,16 @@ public void CheckMvidGuard_WhenLoadedAssemblyMvidDiffersFromDll_ReturnsStaleHint string otherDllPath = typeof(AssemblyDefinition).Assembly.Location; HotReloadTypeHome home = HotReloadTypeHome.ScriptAssemblies(loadedAssemblyName, otherDllPath); - string error = HotReloadPatchTargetSupport.CheckMvidGuard(home); + HotReloadFailureDescription failure = HotReloadPatchTargetSupport.CheckMvidGuard(home); - Assert.That(error, Is.EqualTo(HotReloadConstants.StaleAssemblyHint)); + Assert.That(failure, Is.Not.Null); + Assert.That(failure.Message, Is.EqualTo(HotReloadConstants.StaleAssemblyHint)); + Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.EditorNotReady)); } /// - /// Verifies that a compiled dll whose assembly name is not loaded in the domain is reported as not loaded. + /// Verifies that a compiled dll whose assembly name is not loaded in the domain is reported as + /// not loaded, and as a Declaration, because only the reader's code path loads it. /// [Test] public void CheckMvidGuard_WhenAssemblyIsNotLoaded_ReturnsNotLoadedHint() @@ -50,9 +54,11 @@ public void CheckMvidGuard_WhenAssemblyIsNotLoaded_ReturnsNotLoadedHint() string dllPath = typeof(HotReloadPatchTargetSupport).Assembly.Location; HotReloadTypeHome home = HotReloadTypeHome.ScriptAssemblies(notLoadedAssemblyName, dllPath); - string error = HotReloadPatchTargetSupport.CheckMvidGuard(home); + HotReloadFailureDescription failure = HotReloadPatchTargetSupport.CheckMvidGuard(home); - Assert.That(error, Is.EqualTo(HotReloadConstants.AssemblyNotLoadedHint)); + Assert.That(failure, Is.Not.Null); + Assert.That(failure.Message, Is.EqualTo(HotReloadConstants.AssemblyNotLoadedHint)); + Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.Declaration)); } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadPauseBeforeWiringWarningTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPauseBeforeWiringWarningTests.cs index a37079b72a..5ab34fef0e 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPauseBeforeWiringWarningTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPauseBeforeWiringWarningTests.cs @@ -154,7 +154,8 @@ private static HotReloadResponse Build( rewireFields, unrestoredWiredValues ?? Array.Empty(), isPlaying, - isPaused: false); + isPaused: false, + selectedFiles: Array.Empty()); } private static int CountPauseWarnings(HotReloadResponse response) diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs index a9b810939f..52e6701615 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs @@ -30,6 +30,11 @@ public class HotReloadPdbDocumentIndexTests "Assets/RegressionHarness/AnnotatedScreenshotMismatch/Editor/AnnotatedScreenshotMismatchSceneBuilder.cs"; private const string BodylessFixtureProjectRelativePath = "Assets/Tests/Editor/HotReload/HotReloadSnapshotBodylessFixture.cs"; + private const string PausePointsRuntimeAssemblyName = "UnityCLILoop.PausePoints.Runtime"; + // A file of this repository's embedded package, whose folder differs from its package name. + private const string PackageSourcePhysicalPath = "Packages/src/Runtime/PausePoints/UloopPausePoint.cs"; + private const string PackageSourceAssetPath = + "Packages/io.github.hatayama.uloopmcp/Runtime/PausePoints/UloopPausePoint.cs"; private HotReloadPdbDocumentIndex _index; @@ -83,6 +88,34 @@ public void TryFindDocument_ForAFileWithoutMethodBodies_ReturnsFalse() Assert.That(found, Is.False); } + /// + /// What: a source of an embedded package is found by the path of the file in the package's + /// folder, which is the path the compiler was given. + /// + [Test] + public void TryFindDocument_DocumentRecordedUnderThePackageFolder_IsFoundByThePhysicalPath() + { + // The PDB records this file as ./Packages/src/Runtime/PausePoints/UloopPausePoint.cs, so the + // physical path matches as the end of the url after a slash. + bool found = Find(_index, DllPath(PausePointsRuntimeAssemblyName), PackageSourcePhysicalPath, out HotReloadPdbDocument document); + + Assert.That(found, Is.True); + Assert.That(document.Url.Replace('\\', '/'), Does.EndWith("/" + PackageSourcePhysicalPath)); + } + + /// + /// What: the same document is not found by the asset path Unity reports for the file, which + /// names the package rather than the folder the PDB recorded. This is why the snapshot loader + /// maps the asset path before it looks the document up. + /// + [Test] + public void TryFindDocument_DocumentRecordedUnderThePackageFolder_IsNotFoundByTheAssetPath() + { + bool found = Find(_index, DllPath(PausePointsRuntimeAssemblyName), PackageSourceAssetPath, out HotReloadPdbDocument _); + + Assert.That(found, Is.False); + } + /// /// What: looking up the same file twice reads the dll and the PDB once. /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadRecommendedNextActionTests.cs b/Assets/Tests/Editor/HotReload/HotReloadRecommendedNextActionTests.cs index 5915396259..4c34586155 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadRecommendedNextActionTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadRecommendedNextActionTests.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; + using NUnit.Framework; using io.github.hatayama.UnityCliLoop.FirstPartyTools; @@ -9,6 +12,9 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload /// public sealed class HotReloadRecommendedNextActionTests { + private const int NothingApplied = 0; + private const int OnePatched = 1; + /// /// What: a Failed run that still patched methods recommends fix-and-rerun, compile, or /// revert-all. @@ -21,7 +27,8 @@ public void Resolve_WhenFailureWithPatchedMethods_ReturnsPartialApplyAction() patchedTotal: 1, addedCount: 0, introducedTypeCount: 0, - allRequestedSkipped: false); + allRequestedSkipped: false, + failureKinds: HotReloadFailureKinds.Declaration); Assert.That( action, @@ -40,7 +47,8 @@ public void Resolve_WhenFailureWithAddedMembers_ReturnsPartialApplyAction() patchedTotal: 0, addedCount: 1, introducedTypeCount: 0, - allRequestedSkipped: false); + allRequestedSkipped: false, + failureKinds: HotReloadFailureKinds.Declaration); Assert.That( action, @@ -59,7 +67,8 @@ public void Resolve_WhenFailureWithNothingApplied_ReturnsFixOrCompileAction() patchedTotal: 0, addedCount: 0, introducedTypeCount: 0, - allRequestedSkipped: false); + allRequestedSkipped: false, + failureKinds: HotReloadFailureKinds.Declaration); Assert.That( action, @@ -78,7 +87,8 @@ public void Resolve_WhenFailureWithIntroducedTypesOnly_ReturnsPartialApplyAction patchedTotal: 0, addedCount: 0, introducedTypeCount: 1, - allRequestedSkipped: false); + allRequestedSkipped: false, + failureKinds: HotReloadFailureKinds.Declaration); Assert.That( action, @@ -97,7 +107,8 @@ public void Resolve_NoFailureAndNotAllSkipped_ReturnsEmpty() patchedTotal: 1, addedCount: 1, introducedTypeCount: 0, - allRequestedSkipped: false); + allRequestedSkipped: false, + failureKinds: HotReloadFailureKinds.None); Assert.That(action, Is.EqualTo(string.Empty)); } @@ -115,12 +126,211 @@ public void Resolve_NoFailureButEveryRequestedMethodSkipped_RecommendsTheReasonF patchedTotal: 0, addedCount: 1, introducedTypeCount: 0, - allRequestedSkipped: true); + allRequestedSkipped: true, + failureKinds: HotReloadFailureKinds.None); Assert.That( action, Is.EqualTo( "Each Skipped row's Methods[].Reason names what to change (a file to pass with --files, an initializer to drop, a shape hot reload can patch; see Warnings); do that and rerun. Run 'uloop compile' to apply the Skipped edits as they are instead.")); } + + /// + /// What: a run that applied nothing and failed only because the Editor compiled or imported + /// during the reload says there is nothing to fix, and to wait and rerun. + /// + [Test] + public void Resolve_WhenOnlyEditorNotReadyFailed_RecommendsWaitingForTheEditor() + { + string action = ResolveFailure(HotReloadFailureKinds.EditorNotReady, NothingApplied); + + Assert.That(action, Is.EqualTo(HotReloadConstants.EditorNotReadyRecommendedNextAction)); + } + + /// + /// What: a run that patched part of the request before the Editor became busy says the rest + /// only needs a rerun, and still offers to revert what was applied. + /// + [Test] + public void Resolve_WhenEditorNotReadyFailedAfterAPartialApply_RecommendsWaitingOrRevert() + { + string action = ResolveFailure(HotReloadFailureKinds.EditorNotReady, OnePatched); + + Assert.That( + action, + Is.EqualTo(HotReloadConstants.EditorNotReadyAfterPartialApplyRecommendedNextAction)); + } + + /// + /// What: a Virtual Player that has no compiled assembly is told to compile the main + /// Editor's project, the only compile that reaches it. + /// + [Test] + public void Resolve_WhenOnlyVirtualPlayerFailed_RecommendsCompilingTheMainProject() + { + string action = ResolveFailure( + HotReloadFailureKinds.CompiledAssemblyMissing | HotReloadFailureKinds.VirtualPlayer, + NothingApplied); + + Assert.That(action, Is.EqualTo(HotReloadConstants.VirtualPlayerRecommendedNextAction)); + } + + /// + /// What: a missing compiled assembly in an ordinary project is told to compile, with + /// nothing to fix in the source. + /// + [Test] + public void Resolve_WhenOnlyCompiledAssemblyMissing_RecommendsACompile() + { + string action = ResolveFailure(HotReloadFailureKinds.CompiledAssemblyMissing, NothingApplied); + + Assert.That(action, Is.EqualTo(HotReloadConstants.CompiledAssemblyMissingRecommendedNextAction)); + } + + /// + /// What: a run whose failures all need a fix keeps the advice it had before the failure + /// kinds existed, with nothing appended, whether or not it applied anything. + /// + [Test] + public void Resolve_WhenDeclarationFailedOnly_IsUnchanged() + { + string nothingApplied = ResolveFailure(HotReloadFailureKinds.Declaration, NothingApplied); + string partiallyApplied = ResolveFailure(HotReloadFailureKinds.Declaration, OnePatched); + + Assert.That(nothingApplied, Is.EqualTo(HotReloadConstants.FailedWithNoApplyRecommendedNextAction)); + Assert.That(partiallyApplied, Is.EqualTo(HotReloadConstants.PartialApplyRecommendedNextAction)); + } + + /// + /// What: a run with more than one kind of failure leads with the advice for the failure the + /// reader has to fix, or for the busy Editor when nothing needs a fix, and adds a sentence + /// for each other kind. + /// + [TestCaseSource(nameof(ComposedCases))] + public void Resolve_ComposesTheAdviceFromTheFailureKinds(string kindsName, int patchedTotal, string expected) + { + HotReloadFailureKinds kinds = (HotReloadFailureKinds)Enum.Parse(typeof(HotReloadFailureKinds), kindsName); + + string action = ResolveFailure(kinds, patchedTotal); + + Assert.That(action, Is.EqualTo(expected)); + } + + private static IEnumerable ComposedCases() + { + HotReloadFailureKinds declaration = HotReloadFailureKinds.Declaration; + HotReloadFailureKinds editorNotReady = HotReloadFailureKinds.EditorNotReady; + HotReloadFailureKinds missing = HotReloadFailureKinds.CompiledAssemblyMissing; + HotReloadFailureKinds virtualPlayer = + HotReloadFailureKinds.CompiledAssemblyMissing | HotReloadFailureKinds.VirtualPlayer; + + yield return ComposedCase( + missing, + OnePatched, + HotReloadConstants.PartialApplyRecommendedNextAction, + HotReloadConstants.CompiledAssemblyMissingRecommendedNextAction); + yield return ComposedCase( + virtualPlayer, + OnePatched, + HotReloadConstants.PartialApplyRecommendedNextAction, + HotReloadConstants.VirtualPlayerRecommendedNextAction); + yield return ComposedCase( + declaration | editorNotReady, + NothingApplied, + HotReloadConstants.FailedWithNoApplyRecommendedNextAction, + HotReloadConstants.EditorNotReadyAppendedRecommendedNextAction); + yield return ComposedCase( + declaration | editorNotReady, + OnePatched, + HotReloadConstants.PartialApplyRecommendedNextAction, + HotReloadConstants.EditorNotReadyAppendedRecommendedNextAction); + yield return ComposedCase( + declaration | missing, + NothingApplied, + HotReloadConstants.FailedWithNoApplyRecommendedNextAction, + HotReloadConstants.CompiledAssemblyMissingRecommendedNextAction); + yield return ComposedCase( + declaration | missing, + OnePatched, + HotReloadConstants.PartialApplyRecommendedNextAction, + HotReloadConstants.CompiledAssemblyMissingRecommendedNextAction); + yield return ComposedCase( + declaration | virtualPlayer, + NothingApplied, + HotReloadConstants.FailedWithNoApplyRecommendedNextAction, + HotReloadConstants.VirtualPlayerRecommendedNextAction); + yield return ComposedCase( + declaration | virtualPlayer, + OnePatched, + HotReloadConstants.PartialApplyRecommendedNextAction, + HotReloadConstants.VirtualPlayerRecommendedNextAction); + yield return ComposedCase( + editorNotReady | missing, + NothingApplied, + HotReloadConstants.EditorNotReadyRecommendedNextAction, + HotReloadConstants.CompiledAssemblyMissingRecommendedNextAction); + yield return ComposedCase( + editorNotReady | missing, + OnePatched, + HotReloadConstants.EditorNotReadyAfterPartialApplyRecommendedNextAction, + HotReloadConstants.CompiledAssemblyMissingRecommendedNextAction); + yield return ComposedCase( + editorNotReady | virtualPlayer, + NothingApplied, + HotReloadConstants.EditorNotReadyRecommendedNextAction, + HotReloadConstants.VirtualPlayerRecommendedNextAction); + yield return ComposedCase( + editorNotReady | virtualPlayer, + OnePatched, + HotReloadConstants.EditorNotReadyAfterPartialApplyRecommendedNextAction, + HotReloadConstants.VirtualPlayerRecommendedNextAction); + yield return ComposedCase( + declaration | editorNotReady | missing, + NothingApplied, + HotReloadConstants.FailedWithNoApplyRecommendedNextAction, + HotReloadConstants.EditorNotReadyAppendedRecommendedNextAction, + HotReloadConstants.CompiledAssemblyMissingRecommendedNextAction); + yield return ComposedCase( + declaration | editorNotReady | missing, + OnePatched, + HotReloadConstants.PartialApplyRecommendedNextAction, + HotReloadConstants.EditorNotReadyAppendedRecommendedNextAction, + HotReloadConstants.CompiledAssemblyMissingRecommendedNextAction); + yield return ComposedCase( + declaration | editorNotReady | virtualPlayer, + NothingApplied, + HotReloadConstants.FailedWithNoApplyRecommendedNextAction, + HotReloadConstants.EditorNotReadyAppendedRecommendedNextAction, + HotReloadConstants.VirtualPlayerRecommendedNextAction); + yield return ComposedCase( + declaration | editorNotReady | virtualPlayer, + OnePatched, + HotReloadConstants.PartialApplyRecommendedNextAction, + HotReloadConstants.EditorNotReadyAppendedRecommendedNextAction, + HotReloadConstants.VirtualPlayerRecommendedNextAction); + } + + // Why the kinds travel as their name: the test method is public, and a public method cannot + // take the internal enum as a parameter. + private static TestCaseData ComposedCase( + HotReloadFailureKinds kinds, + int patchedTotal, + params string[] expectedSentences) + { + string applied = patchedTotal > 0 ? "AfterAPartialApply" : "WithNothingApplied"; + return new TestCaseData(kinds.ToString(), patchedTotal, string.Join(" ", expectedSentences)) + .SetName("Resolve_Composes_" + kinds.ToString().Replace(", ", "And") + "_" + applied); + } + + private static string ResolveFailure(HotReloadFailureKinds kinds, int patchedTotal) + { + return HotReloadRecommendedNextAction.Resolve( + hasFailure: true, + patchedTotal: patchedTotal, + addedCount: 0, + introducedTypeCount: 0, + allRequestedSkipped: false, + failureKinds: kinds); + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadRunSiblingLedgerUpdatesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadRunSiblingLedgerUpdatesTests.cs index 7e8e6583f6..4559ebfc5a 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadRunSiblingLedgerUpdatesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadRunSiblingLedgerUpdatesTests.cs @@ -73,6 +73,43 @@ public void ApplyTo_ChangedCompanion_IsRemovedFromTheLedger() } } + /// + /// What: a file left Skipped or Failed whose source went back to its compiled source loses + /// its applied-source record, so later reloads stop taking it for a file to retry. + /// + [Test] + public void ApplyTo_RevertedRetry_ClearsTheAppliedSourceRecord() + { + using (HotReloadDomain domain = HotReloadCompositionRoot.CreateProductionDomain()) + { + domain.AppliedSources.RecordAppliedSource(EnumPath, CurrentHash, false, "/worker-copy/Recorded.cs", Array.Empty()); + HotReloadRunSiblingLedgerUpdates updates = new HotReloadRunSiblingLedgerUpdates(domain); + updates.NoteRevertedRetry(EnumPath); + updates.ApplyTo(domain); + + Assert.That(domain.AppliedSources.TryGetAppliedSource(EnumPath), Is.Null); + } + } + + /// + /// What: such a file loses its companion record too, because the edited source that record + /// would bring back is gone. + /// + [Test] + public void ApplyTo_RevertedRetry_ForgetsTheCompanionRecordToo() + { + using (HotReloadDomain domain = HotReloadCompositionRoot.CreateProductionDomain()) + { + domain.AppliedSources.RecordAppliedSource(EnumPath, CurrentHash, false, "/worker-copy/Recorded.cs", Array.Empty()); + domain.CompanionSources.Record(EnumPath, OlderHash); + HotReloadRunSiblingLedgerUpdates updates = new HotReloadRunSiblingLedgerUpdates(domain); + updates.NoteRevertedRetry(EnumPath); + updates.ApplyTo(domain); + + Assert.That(domain.CompanionSources.TryGetHash(EnumPath), Is.Null); + } + } + /// /// What: a file that applied nothing now but holds changes of an earlier reload reads as /// active from an earlier run. diff --git a/Assets/Tests/Editor/HotReload/HotReloadScriptPathNormalizerTests.cs b/Assets/Tests/Editor/HotReload/HotReloadScriptPathNormalizerTests.cs deleted file mode 100644 index 49be5e6985..0000000000 --- a/Assets/Tests/Editor/HotReload/HotReloadScriptPathNormalizerTests.cs +++ /dev/null @@ -1,130 +0,0 @@ -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 pure normalization of an absolute path into a project-relative script path. - /// - public sealed class HotReloadScriptPathNormalizerTests - { - private static IReadOnlyList PackageRoots(params (string Resolved, string Asset)[] roots) - { - List mapped = new List(roots.Length); - foreach ((string Resolved, string Asset) root in roots) - { - mapped.Add(new HotReloadPackageRoot(root.Resolved, root.Asset)); - } - - return mapped; - } - - [Test] - public void ToProjectRelative_WhenPathIsInsideACachedPackage_ReturnsTheVirtualPackagePath() - { - // Verifies a package's physical folder is mapped back to the Packages/ path Unity expects. - string relative = HotReloadScriptPathNormalizer.ToProjectRelative( - "/proj/Library/PackageCache/com.example.core@abc123/Runtime/Foo.cs", - "/proj", - PackageRoots(("/proj/Library/PackageCache/com.example.core@abc123", "Packages/com.example.core")), - StringComparison.Ordinal); - - Assert.That(relative, Is.EqualTo("Packages/com.example.core/Runtime/Foo.cs")); - } - - [Test] - public void ToProjectRelative_WhenPackageIsEmbeddedUnderTheProjectRoot_PrefersTheVirtualPackagePath() - { - // Verifies an embedded package is not reduced to its physical folder, which resolves to the wrong assembly. - string relative = HotReloadScriptPathNormalizer.ToProjectRelative( - "/proj/Packages/src/Editor/Foo.cs", - "/proj", - PackageRoots(("/proj/Packages/src", "Packages/com.example.core")), - StringComparison.Ordinal); - - Assert.That(relative, Is.EqualTo("Packages/com.example.core/Editor/Foo.cs")); - } - - [Test] - public void ToProjectRelative_WhenPackageResolvesOutsideTheProject_ReturnsTheVirtualPackagePath() - { - // Verifies a file: package whose folder lives outside the project root still maps to its virtual path. - string relative = HotReloadScriptPathNormalizer.ToProjectRelative( - "/MyPackage/Runtime/Foo.cs", - "/proj", - PackageRoots(("/MyPackage", "Packages/com.example.core")), - StringComparison.Ordinal); - - Assert.That(relative, Is.EqualTo("Packages/com.example.core/Runtime/Foo.cs")); - } - - [Test] - public void ToProjectRelative_WhenAbsoluteAssetsPath_StripsTheProjectRoot() - { - // Verifies an absolute Assets path under the project root becomes project-relative. - string relative = HotReloadScriptPathNormalizer.ToProjectRelative( - "/proj/Assets/Scripts/Foo.cs", - "/proj", - PackageRoots(), - StringComparison.Ordinal); - - Assert.That(relative, Is.EqualTo("Assets/Scripts/Foo.cs")); - } - - [Test] - public void ToProjectRelative_WhenProjectRootEndsWithSlash_StripsTheSameRoot() - { - // Verifies a trailing slash on the project root does not change the result. - string relative = HotReloadScriptPathNormalizer.ToProjectRelative( - "/proj/Assets/Scripts/Foo.cs", - "/proj/", - PackageRoots(), - StringComparison.Ordinal); - - Assert.That(relative, Is.EqualTo("Assets/Scripts/Foo.cs")); - } - - [Test] - public void ToProjectRelative_WhenPathUsesBackslashes_NormalizesSeparators() - { - // Verifies Windows separators in the path, the root, and a package root are all normalized. - string relative = HotReloadScriptPathNormalizer.ToProjectRelative( - "C:\\proj\\Assets\\Scripts\\Foo.cs", - "C:\\proj", - PackageRoots(("C:\\proj\\Packages\\src", "Packages/com.example.core")), - StringComparison.OrdinalIgnoreCase); - - Assert.That(relative, Is.EqualTo("Assets/Scripts/Foo.cs")); - } - - [Test] - public void ToProjectRelative_WhenWindowsCaseDiffers_StillMapsThePackageRoot() - { - // Verifies the Windows comparison ignores case differences against a package root. - string relative = HotReloadScriptPathNormalizer.ToProjectRelative( - "c:/PROJ/Packages/SRC/Editor/Foo.cs", - "C:/proj", - PackageRoots(("C:/proj/Packages/src", "Packages/com.example.core")), - StringComparison.OrdinalIgnoreCase); - - Assert.That(relative, Is.EqualTo("Packages/com.example.core/Editor/Foo.cs")); - } - - [Test] - public void ToProjectRelative_WhenPathIsOutsideTheProject_OnlyNormalizesSeparators() - { - // Verifies a path outside the project root is returned as is, so the caller hits the existing failure path. - string relative = HotReloadScriptPathNormalizer.ToProjectRelative( - "/other/Assets/Scripts/Foo.cs", - "/proj", - PackageRoots(), - StringComparison.Ordinal); - - Assert.That(relative, Is.EqualTo("/other/Assets/Scripts/Foo.cs")); - } - } -} diff --git a/Assets/Tests/Editor/HotReload/HotReloadShapeFixtures.cs b/Assets/Tests/Editor/HotReload/HotReloadShapeFixtures.cs index d872f3d186..1d1ac2f839 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadShapeFixtures.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadShapeFixtures.cs @@ -100,6 +100,9 @@ int IHotReloadKeyNormB.Run() /// /// Property getters for hot-reload property-patch coverage: static expression-bodied and /// instance block get. Auto-properties are intentionally absent (not patch candidates). + /// Value, Initialized and Mode pair a getter with a set or init body, so a getter-only edit + /// shows whether the accessor left alone stays off the Skipped list. Mode's switch is a shape + /// the worker's annotations make unequal to itself, so only an unannotated compare sees it unchanged. /// internal class HotReloadPropertyGetterFixture { @@ -122,6 +125,33 @@ public int Value get { return _value; } set { _value = value; } } + + private int _initialized; + + public int Initialized + { + get { return _initialized; } + init { _initialized = value; } + } + + private int _mode; + + public int Mode + { + get { return _mode; } + set + { + switch (value) + { + case 0: + _mode = 0; + break; + default: + _mode = value; + break; + } + } + } } /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadShimReferenceForeignInternalE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadShimReferenceForeignInternalE2ETests.cs new file mode 100644 index 0000000000..c24b21d0c1 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadShimReferenceForeignInternalE2ETests.cs @@ -0,0 +1,180 @@ +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 an edited body whose extension method call a referenced + /// assembly declares twice: publicly, and as an internal type, an internal member it grants to + /// no one, or a private member. + /// The edited assembly's own compile binds the call to the public method, and the shim compile + /// must bind it the same way instead of failing it as ambiguous. + /// + public class HotReloadShimReferenceForeignInternalE2ETests + { + private const string FixtureFileName = "HotReloadShimReferenceForeignInternalFixture.cs"; + private const string ForeignAssemblyName = "UnityCLILoop.Tests.Editor.HotReload.ShimReferenceForeignInternal"; + + private HotReloadDomainTestScope _scope; + + [SetUp] + public void SetUp() + { + _scope = new HotReloadDomainTestScope(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + + // Why: the foreign assembly never recompiles, so its Mvid and the copies keyed by it + // outlive any change to the rewrite rule this class is meant to exercise. + PublicizedCopyTestCache.DeleteCopiesOf(ForeignAssemblyName, HotReloadConstants.PublicizedRefsRelativeDirectory); + PublicizedCopyTestCache.DeleteCopiesOf(ForeignAssemblyName, HotReloadConstants.PublicizedExternalRefsRelativeDirectory); + } + + [TearDown] + public void TearDown() + { + _scope.Dispose(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + VibeLogger.ClearMemoryLogs(); + } + + /// + /// What: an edited body calling an extension method that a referenced assembly also declares + /// on an internal type is patched with no failed row, and the call returns the edited value. + /// + [Test] + public async Task Run_BodyCallingAnExtensionShadowedByAForeignInternalClass_PatchesWithoutAmbiguity() + { + HotReloadOrchestratorResult result = await RunEditAsync( + "return value.Tripled();", + "return value.Tripled() + 1;", + "ShimReferenceForeignInternalTriple.cs"); + + AssertPatchedWithoutFailedRows(result, "TripleViaExtension"); + Assert.That( + new HotReloadShimReferenceForeignInternalFixture().TripleViaExtension(5), + Is.EqualTo(16), + FormatOutcomes(result)); + } + + /// + /// What: the same holds when the referenced assembly declares the extension method as an + /// internal member of a public type. + /// + [Test] + public async Task Run_BodyCallingAnExtensionShadowedByAForeignInternalMethodOfAPublicClass_PatchesWithoutAmbiguity() + { + HotReloadOrchestratorResult result = await RunEditAsync( + "return value.Quadrupled();", + "return value.Quadrupled() + 1;", + "ShimReferenceForeignInternalQuadruple.cs"); + + AssertPatchedWithoutFailedRows(result, "QuadrupleViaExtension"); + Assert.That( + new HotReloadShimReferenceForeignInternalFixture().QuadrupleViaExtension(5), + Is.EqualTo(21), + FormatOutcomes(result)); + } + + /// + /// What: the same holds when the referenced assembly declares the extension method as a + /// private member of a public type. + /// + [Test] + public async Task Run_BodyCallingAnExtensionShadowedByAForeignPrivateMethodOfAPublicClass_PatchesWithoutAmbiguity() + { + HotReloadOrchestratorResult result = await RunEditAsync( + "return value.Quintupled();", + "return value.Quintupled() + 1;", + "ShimReferenceForeignInternalQuintuple.cs"); + + AssertPatchedWithoutFailedRows(result, "QuintupleViaExtension"); + Assert.That( + new HotReloadShimReferenceForeignInternalFixture().QuintupleViaExtension(5), + Is.EqualTo(26), + FormatOutcomes(result)); + } + + private static async Task RunEditAsync( + string fragment, + string replacement, + string editedFileName) + { + string path = FixturePath(); + string editedPath = HotReloadTestSourceWriter.WriteEditedSource( + editedFileName, + ReplaceOnce(File.ReadAllText(path), fragment, replacement)); + return await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { path }, + contentPathOverride: null, + CancellationToken.None, + new Dictionary { [path] = editedPath }); + } + + private static void AssertPatchedWithoutFailedRows(HotReloadOrchestratorResult result, string methodName) + { + Assert.That(FindRow(result, methodName).Kind, Is.EqualTo(HotReloadMethodOutcomeKind.Patched), FormatOutcomes(result)); + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + Assert.That(outcome.Kind, Is.Not.EqualTo(HotReloadMethodOutcomeKind.Failed), FormatOutcomes(result)); + } + } + + // Why the type and the parenthesis: a bare method name would also match a longer name that + // contains it. + private static HotReloadMethodOutcome FindRow(HotReloadOrchestratorResult result, string methodName) + { + string labelPart = "." + nameof(HotReloadShimReferenceForeignInternalFixture) + "." + methodName + "("; + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + if (outcome.Method != null && outcome.Method.Contains(labelPart)) + { + return outcome; + } + } + + Assert.Fail("No row for " + methodName + ".\n" + FormatOutcomes(result)); + return null; + } + + // 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 path = Path.GetFullPath(Path.Combine(Application.dataPath, "Tests", "Editor", "HotReload", FixtureFileName)); + 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); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadShimReferenceForeignInternalE2ETests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadShimReferenceForeignInternalE2ETests.cs.meta new file mode 100644 index 0000000000..089a651a60 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadShimReferenceForeignInternalE2ETests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3bde1a43dc4504e9caa84c48a7991e31 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadShimReferenceForeignInternalFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadShimReferenceForeignInternalFixture.cs new file mode 100644 index 0000000000..8ff18dfb2c --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadShimReferenceForeignInternalFixture.cs @@ -0,0 +1,32 @@ +using System.Runtime.CompilerServices; + +using io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.ShimReferenceForeignInternal; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Calls extension methods that a referenced assembly declares twice: publicly, and on an + /// internal type or as an internal or private member. This assembly's compile sees only the + /// public ones, so a shim compile that also sees the others finds each call ambiguous. + /// + public class HotReloadShimReferenceForeignInternalFixture + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int TripleViaExtension(int value) + { + return value.Tripled(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int QuadrupleViaExtension(int value) + { + return value.Quadrupled(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int QuintupleViaExtension(int value) + { + return value.Quintupled(); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadShimReferenceForeignInternalFixture.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadShimReferenceForeignInternalFixture.cs.meta new file mode 100644 index 0000000000..fdd046cf41 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadShimReferenceForeignInternalFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 646b9b691acf643a9ac6c9ccb7e25027 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadSiblingCompanionE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadSiblingCompanionE2ETests.cs index b2ff5c44eb..c5c86a6319 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSiblingCompanionE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSiblingCompanionE2ETests.cs @@ -183,6 +183,106 @@ public async Task Run_RetryThatStaysSkipped_IsNotRetriedAgainAndAnExplicitReload /// [Test] public async Task Run_FailedFile_IsRetriedOnceAndTheUnrelatedFileStillApplies() + { + Dictionary overrides = await FailHostAsync(); + + HotReloadOrchestratorResult retried = await RunAsync(UnrelatedEdit(overrides, "return 1;"), overrides); + Assert.That(retried.ReappliedSiblingPaths, Does.Contain(ProjectRelativePath(HostFileName)), FormatOutcomes(retried)); + Assert.That(new HotReloadAddedFieldApplyFixture().ReadAdded(), Is.EqualTo(1), FormatOutcomes(retried)); + + HotReloadOrchestratorResult next = await RunAsync(UnrelatedEdit(overrides, "return 2;"), overrides); + Assert.That(next.ReappliedSiblingPaths, Does.Not.Contain(ProjectRelativePath(HostFileName)), FormatOutcomes(next)); + Assert.That(new HotReloadAddedFieldApplyFixture().ReadAdded(), Is.EqualTo(2), FormatOutcomes(next)); + } + + /// + /// What: a file whose edited body Failed and that was then reverted to the source the assembly + /// was compiled from is forgotten by the next reload of the assembly: no warning that its source + /// changed since, no retry, and no record left, while the unrelated file still applies. The + /// reload after that does not warn either. + /// + [Test] + public async Task Run_FailedFileRevertedToItsCompiledSource_IsForgottenWithoutAWarning() + { + string hostRelative = ProjectRelativePath(HostFileName); + Dictionary overrides = await FailHostAsync(); + overrides.Remove(FixturePath(HostFileName)); + + HotReloadOrchestratorResult reverted = await RunAsync(UnrelatedEdit(overrides, "return 1;"), overrides); + Assert.That(ChangedSinceWarnings(reverted), Is.Empty, FormatOutcomes(reverted)); + Assert.That(reverted.ReappliedSiblingPaths, Does.Not.Contain(hostRelative), FormatOutcomes(reverted)); + Assert.That(new HotReloadAddedFieldApplyFixture().ReadAdded(), Is.EqualTo(1), FormatOutcomes(reverted)); + Assert.That(Domain.AppliedSources.TryGetAppliedSource(hostRelative), Is.Null, FormatOutcomes(reverted)); + + HotReloadOrchestratorResult next = await RunAsync(UnrelatedEdit(overrides, "return 2;"), overrides); + Assert.That(ChangedSinceWarnings(next), Is.Empty, FormatOutcomes(next)); + Assert.That(next.ReappliedSiblingPaths, Does.Not.Contain(hostRelative), FormatOutcomes(next)); + Assert.That(new HotReloadAddedFieldApplyFixture().ReadAdded(), Is.EqualTo(2), FormatOutcomes(next)); + } + + /// + /// What: a file whose edited body Failed and that was then edited into something other than + /// its compiled source is still reported as changed since, and its record is kept. + /// + [Test] + public async Task Run_FailedFileEditedAgain_IsStillReportedAsChanged() + { + string hostPath = FixturePath(HostFileName); + string hostRelative = ProjectRelativePath(HostFileName); + Dictionary overrides = await FailHostAsync(); + overrides[hostPath] = HotReloadTestSourceWriter.WriteEditedSource( + "SiblingCompanionE2EEditedHost.cs", + ReplaceOnce(File.ReadAllText(hostPath), HandleBody, "_handled += payload.Value + 1;")); + + HotReloadOrchestratorResult edited = await RunAsync(UnrelatedEdit(overrides, "return 1;"), overrides); + + Assert.That( + edited.Warnings, + Does.Contain(string.Format(HotReloadConstants.RetrySiblingChangedSinceSkipWarningFormat, hostRelative)), + FormatOutcomes(edited)); + Assert.That(Domain.AppliedSources.TryGetAppliedSource(hostRelative), Is.Not.Null, FormatOutcomes(edited)); + } + + /// + /// What: a companion recorded while edited, whose explicit reload then Failed and whose source + /// was then reverted to its compiled source, is forgotten by the next reload of the assembly: + /// no changed-since warning of any kind, and neither its applied-source record nor its + /// companion record left. The reload after that does not warn either. + /// + [Test] + public async Task Run_FailedCompanionRevertedToItsCompiledSource_IsForgottenWithoutAWarning() + { + Dictionary overrides = PayloadAndHostOverrides(WireMethod); + string registryPath = FixturePath(RegistryFileName); + string registryRelative = ProjectRelativePath(RegistryFileName); + overrides[registryPath] = HotReloadTestSourceWriter.WriteEditedSource( + "SiblingCompanionE2ECommentedRegistry.cs", + ReplaceOnce(File.ReadAllText(registryPath), RegistryAssignment, "// edited\n " + RegistryAssignment)); + await RunWireWithRegistryAsync(overrides); + Assert.That(Domain.CompanionSources.TryGetHash(registryRelative), Is.Not.Null, "Precondition: the edited registry must be recorded as a companion."); + overrides[registryPath] = HotReloadTestSourceWriter.WriteEditedSource( + "SiblingCompanionE2EBrokenRegistry.cs", + ReplaceOnce(File.ReadAllText(registryPath), RegistryAssignment, "_handler = 42;")); + HotReloadOrchestratorResult broken = await RunAsync(new[] { registryPath }, overrides); + Assert.That(CountKind(broken, HotReloadMethodOutcomeKind.Failed), Is.GreaterThan(0), "Precondition: Register must fail.\n" + FormatOutcomes(broken)); + Assert.That(Domain.AppliedSources.ListNotFullyAppliedSourcePaths(), Does.Contain(registryRelative), "Precondition: the Failed reload must leave the registry to retry."); + Assert.That(Domain.CompanionSources.TryGetHash(registryRelative), Is.Not.Null, "Precondition: the Failed reload must keep the companion record."); + overrides.Remove(registryPath); + + HotReloadOrchestratorResult reverted = await RunAsync(UnrelatedEdit(overrides, "return 1;"), overrides); + Assert.That(ChangedSinceWarnings(reverted), Is.Empty, FormatOutcomes(reverted)); + Assert.That(Domain.AppliedSources.TryGetAppliedSource(registryRelative), Is.Null, FormatOutcomes(reverted)); + Assert.That(Domain.CompanionSources.TryGetHash(registryRelative), Is.Null, FormatOutcomes(reverted)); + + HotReloadOrchestratorResult next = await RunAsync(UnrelatedEdit(overrides, "return 2;"), overrides); + Assert.That(ChangedSinceWarnings(next), Is.Empty, FormatOutcomes(next)); + } + + private static HotReloadDomain Domain => HotReloadCompositionRoot.Services.Domain; + + // Passes the host with a Handle body that does not compile, and returns the overrides that + // still hold that broken host. + private static async Task> FailHostAsync() { string hostPath = FixturePath(HostFileName); Dictionary overrides = new Dictionary @@ -193,14 +293,8 @@ public async Task Run_FailedFile_IsRetriedOnceAndTheUnrelatedFileStillApplies() }; HotReloadOrchestratorResult first = await RunAsync(new[] { hostPath }, overrides); Assert.That(CountKind(first, HotReloadMethodOutcomeKind.Failed), Is.GreaterThan(0), "Precondition: Handle must fail.\n" + FormatOutcomes(first)); - - HotReloadOrchestratorResult retried = await RunAsync(UnrelatedEdit(overrides, "return 1;"), overrides); - Assert.That(retried.ReappliedSiblingPaths, Does.Contain(ProjectRelativePath(HostFileName)), FormatOutcomes(retried)); - Assert.That(new HotReloadAddedFieldApplyFixture().ReadAdded(), Is.EqualTo(1), FormatOutcomes(retried)); - - HotReloadOrchestratorResult next = await RunAsync(UnrelatedEdit(overrides, "return 2;"), overrides); - Assert.That(next.ReappliedSiblingPaths, Does.Not.Contain(ProjectRelativePath(HostFileName)), FormatOutcomes(next)); - Assert.That(new HotReloadAddedFieldApplyFixture().ReadAdded(), Is.EqualTo(2), FormatOutcomes(next)); + Assert.That(Domain.AppliedSources.ListNotFullyAppliedSourcePaths(), Does.Contain(ProjectRelativePath(HostFileName)), "Precondition: the Failed reload must leave the host to retry."); + return overrides; } private static async Task RunWireWithRegistryAsync(Dictionary overrides) @@ -298,6 +392,22 @@ private static int CountChangedCompanionWarnings(HotReloadOrchestratorResult res return count; } + // Every changed-source warning a reload gives about a sibling holds this phrase, whether the + // sibling had active patches, was left Skipped or Failed, or was a companion. + private static List ChangedSinceWarnings(HotReloadOrchestratorResult result) + { + List warnings = new List(); + foreach (string warning in result.Warnings ?? new List()) + { + if (warning.Contains("its source changed since")) + { + warnings.Add(warning); + } + } + + return warnings; + } + private static string ReplaceOnce(string source, string anchor, string replacement) { Assert.That(source, Does.Contain(anchor), "Precondition: anchor must exist."); diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs index 795e4776ae..74914225fd 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs @@ -37,6 +37,11 @@ public class HotReloadSourceSnapshotTests "Assets/RegressionHarness/AnnotatedScreenshotMismatch/Editor/AnnotatedScreenshotMismatchSceneBuilder.cs"; private const string BodylessFixtureProjectRelativePath = "Assets/Tests/Editor/HotReload/HotReloadSnapshotBodylessFixture.cs"; + private const string PausePointsRuntimeAssemblyName = "UnityCLILoop.PausePoints.Runtime"; + // A file of this repository's embedded package, whose folder differs from its package name. + private const string PackageSourceAssetPath = + "Packages/io.github.hatayama.uloopmcp/Runtime/PausePoints/UloopPausePoint.cs"; + private const string PackageSourcePhysicalPath = "Packages/src/Runtime/PausePoints/UloopPausePoint.cs"; /// /// What: the portable PDB next to a script assembly carries a per-document checksum that matches the hash of the source file bytes, which the snapshot baseline validation relies on. @@ -155,6 +160,27 @@ public void LoadVerifiedSnapshotSource_ForPredefinedEditorAssembly_ReturnsOnDisk Assert.That(loaded, Is.EqualTo(File.ReadAllText(fixtureAbsolutePath))); } + /// + /// What: a source of an embedded package, given by the asset path Unity reports for it, loads + /// its verified snapshot, and the miss description agrees that nothing is missing. + /// + [Test] + public void LoadVerifiedSnapshotSource_PackageSourceGivenByItsAssetPath_ReturnsTheVerifiedSource() + { + HotReloadSourceSnapshotter.CaptureAfterDomainReload(); + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string dllPath = Path.Combine( + projectRoot, + HotReloadConstants.ScriptAssembliesRelativeDirectory, + PausePointsRuntimeAssemblyName + HotReloadConstants.CompiledAssemblyExtension); + + string loaded = HotReloadSourceBaseline.LoadVerifiedSnapshotSource(PackageSourceAssetPath, dllPath); + HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMiss(PackageSourceAssetPath, dllPath); + + Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.None)); + Assert.That(loaded, Is.EqualTo(File.ReadAllText(Path.Combine(projectRoot, PackageSourcePhysicalPath)))); + } + /// /// What: a one-byte tamper of the snapshot bytes fails PDB checksum validation and yields null. /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs index a50e847b5a..83eef59828 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs @@ -2161,7 +2161,8 @@ public void Build_RewireFields_WarnsToWireOnlyThoseFieldsAgain() new[] { "Ns.Host.Speed" }, Array.Empty(), isPlaying: false, - isPaused: false); + isPaused: false, + selectedFiles: Array.Empty()); string warning = response.Warnings.FirstOrDefault( entry => entry.Contains(RewireAfterDomainReloadWarningMarker)); @@ -2184,7 +2185,8 @@ public void Build_NoRewireField_DoesNotWarnToWireAgain() Array.Empty(), Array.Empty(), isPlaying: false, - isPaused: false); + isPaused: false, + selectedFiles: Array.Empty()); Assert.That( response.Warnings.Any(entry => entry.Contains(RewireAfterDomainReloadWarningMarker)), @@ -2207,7 +2209,8 @@ public void Build_RewireWarningBesideTwoOrchestratorWarnings_OmitsSingleCompileR new[] { "Ns.Host.Speed" }, Array.Empty(), isPlaying: false, - isPaused: false); + isPaused: false, + selectedFiles: Array.Empty()); HotReloadResponse withoutRewire = HotReloadApplyResponseBuilder.Build( HotReloadCompositionRoot.Services, CreatePatchedResultWithTwoWarningsAndAddedField(), @@ -2215,7 +2218,8 @@ public void Build_RewireWarningBesideTwoOrchestratorWarnings_OmitsSingleCompileR Array.Empty(), Array.Empty(), isPlaying: false, - isPaused: false); + isPaused: false, + selectedFiles: Array.Empty()); Assert.That( withRewire.Message, diff --git a/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs index cbc657a8f9..268d3b5946 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs @@ -132,15 +132,21 @@ public void IsVirtualPlayerProjectRoot_WindowsSeparators_ReturnsTrue() /// /// 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. + /// brings the edit in, instead of asking for a compile first, and the failure is marked as a + /// Virtual Player's so the next step names the main Editor's project. /// [Test] public void DescribeMissingCompiledAssembly_VirtualPlayerRoot_SaysHotReloadCannotPatchAVirtualPlayer() { string dllPath = Path.Combine(VirtualPlayerRoot, "Library", "ScriptAssemblies", "Sample.dll"); - string reason = HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(VirtualPlayerRoot, dllPath); + HotReloadFailureDescription failure = + HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(VirtualPlayerRoot, dllPath); + string reason = failure.Message; + Assert.That( + failure.Kinds, + Is.EqualTo(HotReloadFailureKinds.CompiledAssemblyMissing | HotReloadFailureKinds.VirtualPlayer)); Assert.That(reason, Does.Contain(dllPath)); Assert.That(reason, Does.Contain("Virtual Player")); Assert.That(reason, Does.Contain("main Editor")); @@ -149,17 +155,20 @@ public void DescribeMissingCompiledAssembly_VirtualPlayerRoot_SaysHotReloadCanno } /// - /// What: for an ordinary project, the reason keeps the compile-first text unchanged. + /// What: for an ordinary project, the reason keeps the compile-first text unchanged, and the + /// failure is not marked as a Virtual Player's. /// [Test] public void DescribeMissingCompiledAssembly_OrdinaryRoot_KeepsTheCompileFirstText() { string dllPath = Path.Combine(OrdinaryProjectRoot, "Library", "ScriptAssemblies", "Sample.dll"); - string reason = HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(OrdinaryProjectRoot, dllPath); + HotReloadFailureDescription failure = + HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(OrdinaryProjectRoot, dllPath); + Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.CompiledAssemblyMissing)); Assert.That( - reason, + failure.Message, Is.EqualTo("Compiled assembly not found at '" + dllPath + "'. Compile the project first.")); } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadWorkerNoticeAppenderTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWorkerNoticeAppenderTests.cs index d4e4454df2..6b1221f679 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWorkerNoticeAppenderTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWorkerNoticeAppenderTests.cs @@ -166,6 +166,46 @@ public void AppendWorkerNotices_WhenFileHasNoIntroducedType_KeepsTheNoSnapshotWa Assert.That(warnings[0], Does.Contain("patching all methods")); } + /// + /// What: a file with no snapshot for the current build is told the capture may not have + /// finished yet, so the reader knows a moment's wait can be enough. + /// + [Test] + public void AppendWorkerNotices_WhenSnapshotIsMissingForTheCurrentBuild_SaysTheCaptureHadNotFinished() + { + string warning = AppendTheOnlyBaselineWarning(HotReloadSnapshotMissReason.NoSnapshotFile); + + AssertIsAMissingSnapshotWarning(warning); + Assert.That(warning, Does.Contain("matches the current build")); + Assert.That(warning, Does.Contain("the capture had not finished")); + } + + /// + /// What: a snapshot whose hash does not match the compiled file is named as such, rather + /// than reported as if no snapshot had been captured. + /// + [Test] + public void AppendWorkerNotices_WhenSnapshotHashMismatches_SaysTheSnapshotDoesNotMatchTheCompiledFile() + { + string warning = AppendTheOnlyBaselineWarning(HotReloadSnapshotMissReason.HashMismatch); + + AssertIsAMissingSnapshotWarning(warning); + Assert.That(warning, Does.Contain("the snapshot does not match the compiled file")); + } + + /// + /// What: a file whose compiled assembly or PDB is missing is told so, because that is what + /// the compile it is asked to run has to produce. + /// + [Test] + public void AppendWorkerNotices_WhenCompiledAssemblyIsMissing_SaysSo() + { + string warning = AppendTheOnlyBaselineWarning(HotReloadSnapshotMissReason.NoCompiledAssembly); + + AssertIsAMissingSnapshotWarning(warning); + Assert.That(warning, Does.Contain("the compiled assembly or its PDB is missing")); + } + /// /// What: a file the PDB lists no document for is told no compile gives it a baseline, /// instead of being asked to run uloop compile, which would change nothing for it. @@ -334,6 +374,40 @@ public void AppendWorkerNotices_WhenFileDeclaresARefusedIntroducedType_AddsNoMis Assert.That(summary, Is.Empty, "A re-applied sibling that declares one is left out of the summary too."); } + // The one warning a file with a patch candidate and no introduced type gets for a snapshot + // that is unusable for the given reason. + private static string AppendTheOnlyBaselineWarning(HotReloadSnapshotMissReason snapshotMissReason) + { + List outcomes = new List(); + List warnings = new List(); + + HotReloadWorkerNoticeAppender.AppendWorkerNotices( + CreateFileOutput(Array.Empty()), + Array.Empty(), + 1, + snapshotMissReason, + false, + false, + ProjectRelativePath, + AssemblyName, + AssemblyResolvePath, + outcomes, + warnings, + null); + + Assert.That(warnings.Count, Is.EqualTo(1), string.Join(" | ", warnings)); + return warnings[0]; + } + + // Why these three phrases: readers and tests look for the warning by its opening words, + // and every reason still patches all methods and is cleared by a compile. + private static void AssertIsAMissingSnapshotWarning(string warning) + { + Assert.That(warning, Does.StartWith("No verified source snapshot for Broken.cs (assembly Some.Assembly)")); + Assert.That(warning, Does.Contain("patching all methods")); + Assert.That(warning, Does.Contain("uloop compile")); + } + private static TransformWorkerFileOutputDto CreateFileOutput(string[] parseErrors) { return new TransformWorkerFileOutputDto diff --git a/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs index abac8d519c..f1a6fdac7b 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs @@ -19,6 +19,15 @@ public class HotReloadWorkerReasonTextTests private const string GenericMethodFragment = "inaccessible generic method calls are not rewritten."; + // The sentences every internal-member reason has between its lead and its next step. + private const string UnpassedInternalMemberRule = + "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. "; + /// /// What: every reason code has a byte-match case below, so a code added without a /// sentence - or a case left behind after a code was removed - fails here. @@ -230,15 +239,21 @@ private static IEnumerable RenderCases() + "--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'."); + new[] { "CS0117: 'Host' does not contain a definition for 'Value'", "'Host'", "'Value'" }, + "'Value' is internal to 'Host', whose source this reload was not given (CS0117: 'Host' does not " + + "contain a definition for 'Value'). " + UnpassedInternalMemberRule + "Run 'uloop compile'."); + yield return Case( + HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberBareName, + new[] { "CS0103: The name 'Value' does not exist in the current context", "'Host'", "'Value'" }, + "'Value' is internal to 'Host', whose source this reload was not given (CS0103: The name 'Value' " + + "does not exist in the current context). " + UnpassedInternalMemberRule + + "Qualify the bare name with 'this.' or the type name, or run 'uloop compile'."); + yield return Case( + HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberInsideClosure, + new[] { "CS0117: 'Host' does not contain a definition for 'Value'", "'Host'", "'Value'" }, + "'Value' is internal to 'Host', whose source this reload was not given (CS0117: 'Host' does not " + + "contain a definition for 'Value'). " + UnpassedInternalMemberRule + + "Move the use out of the lambda, local function or query, or run 'uloop compile'."); yield return Case( HotReloadWorkerReasonCode.MethodTransformStructHost, NoArgs, diff --git a/Assets/Tests/Editor/HotReload/IsExternalInit.cs b/Assets/Tests/Editor/HotReload/IsExternalInit.cs new file mode 100644 index 0000000000..20fc93a9a7 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/IsExternalInit.cs @@ -0,0 +1,8 @@ +namespace System.Runtime.CompilerServices +{ + // Unity's .NET profile does not provide this marker type, but init accessors require it. The + // package declares its own copy as internal, so this test assembly cannot see that one. + internal static class IsExternalInit + { + } +} diff --git a/Assets/Tests/Editor/HotReload/IsExternalInit.cs.meta b/Assets/Tests/Editor/HotReload/IsExternalInit.cs.meta new file mode 100644 index 0000000000..2269d6388d --- /dev/null +++ b/Assets/Tests/Editor/HotReload/IsExternalInit.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 26ad9f5cf72f84f84ad374ecc66088a4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs b/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs new file mode 100644 index 0000000000..205d93bd26 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers enabling a pause point on a script named in each path form: an embedded package's + /// asset path, the path under its folder relative to the project, the absolute path, and an + /// absolute Assets path. Every form arms the marker named by the asset path, and the edited + /// file and the resolved line text are read from the file on disk. + /// + public sealed class PausePointScriptPathFormTests + { + private const string FixtureAssetPath = + "Packages/io.github.hatayama.uloop.hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs"; + private const string FixturePhysicalPath = + "Packages/uloop-hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs"; + private const int SecondStatementLine = 16; + private const string SecondStatementText = "return 2;"; + private const string AssetsFixturePath = + "Assets/Tests/Editor/HotReload/HotReloadPausePointLineDriftFixture.cs"; + private const string AssetsFixtureStatementText = "return 22;"; + // Part of the warning added when --line could not be mapped through a verified snapshot. + private const string NoVerifiedSnapshotWarningPart = "No verified source snapshot"; + + private HotReloadDomainTestScope _scope; + + [SetUp] + public void SetUp() + { + _scope = new HotReloadDomainTestScope(); + UloopPausePointRegistry.ConfigureForTests(new FakePausePointPauseController(), () => DateTime.UtcNow); + } + + [TearDown] + public void TearDown() + { + _scope.Dispose(); + SourcePausePointPatcher.UnpatchAll(); + UloopPausePointRegistry.ResetForTests(); + } + + /// + /// What: a package script named by its asset path arms the requested statement on the + /// edited-file basis, through the real snapshot and the file on disk. + /// + [Test] + public void Enable_PackageSourceGivenByItsAssetPath_ArmsTheStatementOnTheEditedFileBasis() + { + HotReloadSourceSnapshotter.CaptureAfterDomainReload(); + + PausePointResponse response = Enable(FixtureAssetPath, SecondStatementLine); + + AssertArmedSecondOnTheEditedFileBasis(response); + } + + /// + /// What: the same script named by the path under the package's folder arms the marker named + /// by the asset path. + /// + [Test] + public void Enable_PackageSourceGivenByItsPhysicalPath_ArmsTheSameMarkerAsTheAssetPath() + { + HotReloadSourceSnapshotter.CaptureAfterDomainReload(); + + PausePointResponse response = Enable(FixturePhysicalPath, SecondStatementLine); + + AssertArmedSecondOnTheEditedFileBasis(response); + } + + /// + /// What: the same script named by its absolute path arms the marker named by the asset path. + /// + [Test] + public void Enable_PackageSourceGivenByItsAbsolutePhysicalPath_ArmsTheSameMarkerAsTheAssetPath() + { + HotReloadSourceSnapshotter.CaptureAfterDomainReload(); + + PausePointResponse response = Enable(ToAbsolutePath(FixturePhysicalPath), SecondStatementLine); + + AssertArmedSecondOnTheEditedFileBasis(response); + } + + /// + /// What: when the compiled source had the requested statement one line lower, --line is + /// mapped through the snapshot, and the mapped compiled line is found in the PDB, which + /// records a package script by the path under its folder rather than by its asset path. + /// In this harness the PDB line numbers match the file on disk, so the injected snapshot + /// plays the compiled source (the roles are reversed from production): edited line 16 maps + /// to compiled line 17, which the PDB places on Second's closing brace, and that maps back + /// to edited line 16. + /// + [Test] + public void Enable_PackageSourceWithASnapshotShiftedBelowTheRequestedLine_MapsTheLineThroughTheSnapshot() + { + string shifted = InsertBlankLinesAfterLine(ReadFixtureFromDisk(), 8, 1); + + PausePointResponse response = EnableWithSnapshot(FixtureAssetPath, SecondStatementLine, shifted); + + AssertArmedSecondOnTheEditedFileBasis(response); + } + + /// + /// What: the same mapping holds when the script is named by the path under the package's + /// folder, and the snapshot is asked for by the asset path it is keyed by. The roles of + /// the snapshot and the PDB are reversed from production, as in the test above. + /// + [Test] + public void Enable_PackageSourceGivenByItsPhysicalPathWithAShiftedSnapshot_MapsTheLineThroughTheSnapshot() + { + string shifted = InsertBlankLinesAfterLine(ReadFixtureFromDisk(), 8, 1); + + PausePointResponse response = EnableWithSnapshot(FixturePhysicalPath, SecondStatementLine, shifted); + + AssertArmedSecondOnTheEditedFileBasis(response); + } + + /// + /// What: an Assets script named by its absolute path arms the marker named by its + /// project-relative path, on the edited-file basis. + /// + [Test] + public void Enable_AssetsSourceGivenByItsAbsolutePath_ArmsTheSameMarkerAsTheRelativePath() + { + HotReloadSourceSnapshotter.CaptureAfterDomainReload(); + int line = FindLineNumber(File.ReadAllText(ToAbsolutePath(AssetsFixturePath)), AssetsFixtureStatementText); + Assert.That(line, Is.GreaterThan(0)); + + PausePointResponse response = Enable(ToAbsolutePath(AssetsFixturePath), line); + + Assert.That(response.Success, Is.True, response.Message + " / " + response.RecommendedNextAction); + Assert.That(response.Id, Is.EqualTo(AssetsFixturePath + ":" + line)); + Assert.That(response.LineBasis, Is.EqualTo("EditedFile")); + Assert.That(response.ResolvedLineText, Does.Contain(AssetsFixtureStatementText)); + } + + private static PausePointResponse Enable(string file, int line) + { + return new PausePointUseCase().Enable(new EnablePausePointSchema + { + File = file, + Line = line, + TimeoutSeconds = 30, + Mode = UloopPausePointCaptureMode.SingleShot + }); + } + + private static PausePointResponse EnableWithSnapshot(string file, int line, string snapshot) + { + using (HotReloadSidePortScope scope = new HotReloadSidePortScope()) + { + scope.Port.VerifiedSnapshotSource = (string snapshotFile, string dllPath) => + { + // Snapshots are keyed by the asset path, so any other form here would miss it. + Assert.That(snapshotFile, Is.EqualTo(FixtureAssetPath)); + return snapshot; + }; + return Enable(file, line); + } + } + + private static void AssertArmedSecondOnTheEditedFileBasis(PausePointResponse response) + { + Assert.That(response.Success, Is.True, response.Message + " / " + response.RecommendedNextAction); + Assert.That(response.Id, Is.EqualTo(FixtureAssetPath + ":" + SecondStatementLine)); + Assert.That(response.ResolvedMethod, Does.Contain("Second")); + Assert.That(response.LineBasis, Is.EqualTo("EditedFile")); + Assert.That(response.ResolvedLine, Is.EqualTo(SecondStatementLine)); + Assert.That(response.ResolvedLineText, Does.Contain(SecondStatementText)); + Assert.That(response.Warning ?? string.Empty, Does.Not.Contain(NoVerifiedSnapshotWarningPart)); + } + + private static string ReadFixtureFromDisk() + { + return File.ReadAllText(ToAbsolutePath(FixturePhysicalPath)); + } + + private static string ToAbsolutePath(string projectRelativePath) + { + return Path.GetFullPath(Path.Combine(UnityCliLoopPathResolver.GetProjectRoot(), projectRelativePath)); + } + + private static string InsertBlankLinesAfterLine(string source, int line, int count) + { + List lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList(); + lines.InsertRange(line, Enumerable.Repeat(string.Empty, count)); + return string.Join("\n", lines); + } + + private static int FindLineNumber(string source, string fragment) + { + string[] lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'); + for (int index = 0; index < lines.Length; index++) + { + if (lines[index].Contains(fragment, StringComparison.Ordinal)) + { + return index + 1; + } + } + + return -1; + } + + private sealed class FakePausePointPauseController : IUloopPausePointPauseController + { + public bool IsPlaying => true; + public bool IsPaused => false; + + public void Pause() + { + } + + public void Resume() + { + } + } + } +} diff --git a/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs.meta b/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs.meta new file mode 100644 index 0000000000..b16f21629e --- /dev/null +++ b/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6d1c170e51e0b43ceaa9b9d9e9de8361 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/PublicizedCopyTestCache.cs b/Assets/Tests/Editor/HotReload/PublicizedCopyTestCache.cs new file mode 100644 index 0000000000..fe36270691 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/PublicizedCopyTestCache.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Deletes cached rewritten copies of one assembly image, so the next request for a copy + /// rewrites the image under the current rule. A copy is keyed by the image's name and Mvid only, + /// so a copy written before a rule change would otherwise be returned without the rule running. + /// + internal static class PublicizedCopyTestCache + { + /// + /// Deletes the "<assemblyName>-<Mvid>.dll" copies under the project-relative + /// . Copies of an assembly whose name only starts with + /// stay. + /// + internal static void DeleteCopiesOf(string assemblyName, string relativeDirectory) + { + string projectRootPath = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string outputDirectory = Path.Combine(projectRootPath, relativeDirectory); + if (!Directory.Exists(outputDirectory)) + { + return; + } + + foreach (string candidatePath in Directory.GetFiles(outputDirectory, assemblyName + "-*.dll")) + { + string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(candidatePath); + if (fileNameWithoutExtension.Length <= assemblyName.Length + 1) + { + continue; + } + + string mvidCandidate = fileNameWithoutExtension.Substring(assemblyName.Length + 1); + if (Guid.TryParseExact(mvidCandidate, "N", out Guid _)) + { + File.Delete(candidatePath); + } + } + } + } +} diff --git a/Assets/Tests/Editor/HotReload/PublicizedCopyTestCache.cs.meta b/Assets/Tests/Editor/HotReload/PublicizedCopyTestCache.cs.meta new file mode 100644 index 0000000000..177432df07 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/PublicizedCopyTestCache.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e80c4db1215de4605817605b1fa6cdf9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/ReferencePublicizerTests.cs b/Assets/Tests/Editor/HotReload/ReferencePublicizerTests.cs index 48d26f61a3..16900db67f 100644 --- a/Assets/Tests/Editor/HotReload/ReferencePublicizerTests.cs +++ b/Assets/Tests/Editor/HotReload/ReferencePublicizerTests.cs @@ -14,6 +14,7 @@ using CecilFieldAttributes = Mono.Cecil.FieldAttributes; using CecilMethodAttributes = Mono.Cecil.MethodAttributes; +using CecilTypeAttributes = Mono.Cecil.TypeAttributes; using ReflectionAssembly = System.Reflection.Assembly; namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload @@ -27,6 +28,21 @@ public class ReferencePublicizerTests private const string FixtureTypeFullName = "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReloadSpike.SpikePrivateAccessFixture"; + private const string HiddenExtensionsTypeFullName = + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.ShimReferenceHiddenIntExtensions"; + + private const string PublicHostTypeFullName = + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.ShimReferencePublicHost"; + + private const string PublicBaseTypeFullName = + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.ShimReferencePublicBase"; + + // The test assembly grants internals to this name, and to no assembly named like the + // stranger target. + private const string FriendTargetAssemblyName = "UloopShimReferenceFriendTarget"; + + private const string StrangerTargetAssemblyName = "UloopShimReferenceStrangerTarget"; + private const string ArtifactAssemblyName = "UloopIntroducedTypes_PublicizerFixture"; private const string ArtifactIntroducedTypeMetadataName = "PublicizerFixture.Introduced"; @@ -143,7 +159,7 @@ public void GetOrCreatePublicizedCopy_DoesNotDeleteHyphenatedSiblingAssemblyCach Directory.CreateDirectory(outputDirectory); // Force the write+prune path: remove existing exact-mvid caches for this assembly. - DeleteExactMvidCachesForAssembly(outputDirectory, TestAssemblyName); + PublicizedCopyTestCache.DeleteCopiesOf(TestAssemblyName, HotReloadConstants.PublicizedRefsRelativeDirectory); string siblingCachePath = Path.Combine( outputDirectory, @@ -170,24 +186,6 @@ public void GetOrCreatePublicizedCopy_DoesNotDeleteHyphenatedSiblingAssemblyCach "A true stale same-assembly cache (name-.dll) must still be pruned."); } - private static void DeleteExactMvidCachesForAssembly(string outputDirectory, string assemblyName) - { - foreach (string candidatePath in Directory.GetFiles(outputDirectory, assemblyName + "-*.dll")) - { - string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(candidatePath); - if (fileNameWithoutExtension.Length <= assemblyName.Length + 1) - { - continue; - } - - string mvidCandidate = fileNameWithoutExtension.Substring(assemblyName.Length + 1); - if (Guid.TryParseExact(mvidCandidate, "N", out Guid _)) - { - File.Delete(candidatePath); - } - } - } - private static void AssertNoNonPublicTypesOrMembersRemain(AssemblyDefinition assemblyDefinition) { foreach (TypeDefinition type in assemblyDefinition.MainModule.GetTypes()) @@ -369,6 +367,187 @@ private static void AssertNoArtifactCompileErrors(CompilerMessage[] compilerMess Assert.That(errors, Is.Empty, "Artifact compilation failed:\n" + string.Join("\n", errors)); } + /// + /// What: a shim reference copy built for a target the assembly grants no internals to keeps + /// a top-level internal type, and the private, internal and private protected members of + /// public types, as they are, while a protected member and a nested type are publicized as + /// before. + /// + [Test] + public void GetOrCreateShimReferenceCopy_WhenTheAssemblyDoesNotGrantInternalsToTheTarget_KeepsInternalTypesAndMembersHidden() + { + using AssemblyDefinition copy = ReadFreshShimReferenceCopyOfTestAssembly(StrangerTargetAssemblyName); + + Assert.That( + FindType(copy, HiddenExtensionsTypeFullName).Attributes & CecilTypeAttributes.VisibilityMask, + Is.EqualTo(CecilTypeAttributes.NotPublic), + "A top-level internal type must stay internal: the target's own compile never saw it."); + + TypeDefinition publicHostType = FindType(copy, PublicHostTypeFullName); + Assert.That( + MethodAccessOf(publicHostType, "Hidden"), + Is.EqualTo(CecilMethodAttributes.Assembly), + "An internal member of a public type must stay internal: the target's own compile never saw it."); + Assert.That( + publicHostType.Fields.First(field => field.Name == "HiddenField").Attributes & CecilFieldAttributes.FieldAccessMask, + Is.EqualTo(CecilFieldAttributes.Assembly), + "An internal field of a public type must stay internal: the target's own compile never saw it."); + TypeDefinition publicBaseType = FindType(copy, PublicBaseTypeFullName); + Assert.That( + MethodAccessOf(publicBaseType, "Guarded"), + Is.EqualTo(CecilMethodAttributes.FamANDAssem), + "A private protected member must stay as it is: another assembly reaches it only through a grant."); + Assert.That( + MethodAccessOf(publicHostType, "Secret"), + Is.EqualTo(CecilMethodAttributes.Private), + "A private member must stay private: no other assembly's compile sees it."); + Assert.That( + publicHostType.Fields.First(field => field.Name == "SecretField").Attributes & CecilFieldAttributes.FieldAccessMask, + Is.EqualTo(CecilFieldAttributes.Private), + "A private field must stay private: no other assembly's compile sees it."); + + Assert.That( + MethodAccessOf(publicBaseType, "Shielded"), + Is.EqualTo(CecilMethodAttributes.Public), + "A protected member must still be publicized: a shim calls it from outside the type hierarchy."); + + TypeDefinition nestedInternalType = publicHostType.NestedTypes.First(type => type.Name == "NestedInternal"); + Assert.That( + nestedInternalType.Attributes & CecilTypeAttributes.VisibilityMask, + Is.EqualTo(CecilTypeAttributes.NestedPublic), + "A nested type must still be publicized."); + } + + /// + /// What: a shim reference copy built for a target the assembly grants internals to + /// publicizes an internal member of a public type, as every shim reference copy did before. + /// + [Test] + public void GetOrCreateShimReferenceCopy_WhenTheAssemblyGrantsInternalsToTheTarget_PublicizesInternalMembers() + { + using AssemblyDefinition copy = ReadFreshShimReferenceCopyOfTestAssembly(FriendTargetAssemblyName); + + Assert.That( + MethodAccessOf(FindType(copy, PublicHostTypeFullName), "Hidden"), + Is.EqualTo(CecilMethodAttributes.Public), + "An internal member must be publicized for a target the assembly grants internals to."); + } + + /// + /// What: a shim reference copy built for a target the assembly grants internals to + /// publicizes a top-level internal type, as every shim reference copy did before. + /// + [Test] + public void GetOrCreateShimReferenceCopy_WhenTheAssemblyGrantsInternalsToTheTarget_PublicizesInternalTopLevelTypes() + { + using AssemblyDefinition copy = ReadFreshShimReferenceCopyOfTestAssembly(FriendTargetAssemblyName); + + Assert.That( + FindType(copy, HiddenExtensionsTypeFullName).Attributes & CecilTypeAttributes.VisibilityMask, + Is.EqualTo(CecilTypeAttributes.Public), + "A top-level internal type must be publicized for a target the assembly grants internals to."); + } + + /// + /// What: the copy that keeps internals hidden and the fully publicized copy of the same image + /// are cached in different directories, so neither is returned in place of the other. + /// + [Test] + public void GetOrCreateShimReferenceCopy_WritesTheTwoVariantsToDifferentDirectories() + { + HotReloadTypeHome home = ResolveTestAssemblyHome(); + IReadOnlyCollection searchDirectories = PublicizerTestSearchDirectories.ForHotReloadTestAssembly(); + + string strangerCopyPath = ReferencePublicizer.GetOrCreateShimReferenceCopy( + home, + searchDirectories, + StrangerTargetAssemblyName); + string friendCopyPath = ReferencePublicizer.GetOrCreateShimReferenceCopy( + home, + searchDirectories, + FriendTargetAssemblyName); + + Assert.That(strangerCopyPath, Is.Not.EqualTo(friendCopyPath), "The two variants must not share a path."); + Assert.That(File.Exists(strangerCopyPath), Is.True, "The copy that keeps internals hidden must exist."); + Assert.That(File.Exists(friendCopyPath), Is.True, "The fully publicized copy must exist."); + Assert.That( + NormalizedDirectoryOf(strangerCopyPath), + Is.EqualTo(NormalizedProjectDirectory(HotReloadConstants.PublicizedExternalRefsRelativeDirectory)), + "The copy that keeps internals hidden must be cached under PublicizedExternalRefs."); + Assert.That( + NormalizedDirectoryOf(friendCopyPath), + Is.EqualTo(NormalizedProjectDirectory(HotReloadConstants.PublicizedRefsRelativeDirectory)), + "The fully publicized copy must be cached under PublicizedRefs."); + } + + /// + /// What: the friend name of an InternalsVisibleTo argument is the text before the first + /// comma without surrounding spaces, so a grant with a public key still names its assembly. + /// + [Test] + public void ParseFriendAssemblyName_StripsThePublicKeySuffix() + { + Assert.That( + ReferencePublicizer.ParseFriendAssemblyName("Friend, PublicKey=0024000004800000"), + Is.EqualTo("Friend")); + Assert.That(ReferencePublicizer.ParseFriendAssemblyName(" Friend "), Is.EqualTo("Friend")); + Assert.That(ReferencePublicizer.ParseFriendAssemblyName("Friend"), Is.EqualTo("Friend")); + } + + /// + /// What: a grant matches the target assembly name regardless of case, the way the compiler + /// matches assembly simple names. + /// + [Test] + public void GetOrCreateShimReferenceCopy_MatchesTheTargetNameIgnoringCase() + { + using AssemblyDefinition copy = ReadFreshShimReferenceCopyOfTestAssembly("uloopshimreferencefriendtarget"); + + Assert.That( + FindType(copy, HiddenExtensionsTypeFullName).Attributes & CecilTypeAttributes.VisibilityMask, + Is.EqualTo(CecilTypeAttributes.Public), + "A grant whose name differs only in case must still publicize internal types."); + } + + // Why delete the cached copies of both variants first: a copy is reused by assembly name and + // Mvid alone, so one that an earlier test or run left behind would pass without the rewrite + // under test running. + private static AssemblyDefinition ReadFreshShimReferenceCopyOfTestAssembly(string shimTargetAssemblyName) + { + PublicizedCopyTestCache.DeleteCopiesOf(TestAssemblyName, HotReloadConstants.PublicizedRefsRelativeDirectory); + PublicizedCopyTestCache.DeleteCopiesOf(TestAssemblyName, HotReloadConstants.PublicizedExternalRefsRelativeDirectory); + + string copyPath = ReferencePublicizer.GetOrCreateShimReferenceCopy( + ResolveTestAssemblyHome(), + PublicizerTestSearchDirectories.ForHotReloadTestAssembly(), + shimTargetAssemblyName); + return AssemblyDefinition.ReadAssembly(copyPath); + } + + private static TypeDefinition FindType(AssemblyDefinition assemblyDefinition, string fullName) + { + TypeDefinition type = assemblyDefinition.MainModule.GetType(fullName); + Assert.That(type, Is.Not.Null, $"Type not found: {fullName}"); + return type; + } + + private static CecilMethodAttributes MethodAccessOf(TypeDefinition type, string methodName) + { + MethodDefinition method = type.Methods.First(candidate => candidate.Name == methodName); + return method.Attributes & CecilMethodAttributes.MemberAccessMask; + } + + private static string NormalizedDirectoryOf(string filePath) + { + return Path.GetDirectoryName(Path.GetFullPath(filePath)).Replace('\\', '/'); + } + + private static string NormalizedProjectDirectory(string relativeDirectory) + { + string projectRootPath = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + return Path.GetFullPath(Path.Combine(projectRootPath, relativeDirectory)).Replace('\\', '/'); + } + private static HotReloadTypeHome ResolveTestAssemblyHome() { string projectRootPath = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); diff --git a/Assets/Tests/Editor/HotReload/ScriptPathNormalizerTests.cs b/Assets/Tests/Editor/HotReload/ScriptPathNormalizerTests.cs new file mode 100644 index 0000000000..f34f56a5eb --- /dev/null +++ b/Assets/Tests/Editor/HotReload/ScriptPathNormalizerTests.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers the pure mapping between a script's physical path and the project-relative asset + /// path Unity's script APIs use, in both directions, and the resolution of a path given in + /// any form to that asset path. + /// + public sealed class ScriptPathNormalizerTests + { + // Why through GetFullPath: ToAssetPath resolves its input with GetFullPath, which qualifies a + // rooted path with the drive on Windows, so the root has to be in that form to be matched. + private static readonly string AssetPathProjectRoot = Path.GetFullPath("/proj"); + + private static IReadOnlyList PackageRoots(params (string Resolved, string Asset)[] roots) + { + List mapped = new List(roots.Length); + foreach ((string Resolved, string Asset) root in roots) + { + mapped.Add(new ScriptPackageRoot(root.Resolved, root.Asset)); + } + + return mapped; + } + + [Test] + public void ToProjectRelative_WhenPathIsInsideACachedPackage_ReturnsTheVirtualPackagePath() + { + // Verifies a package's physical folder is mapped back to the Packages/ path Unity expects. + string relative = ScriptPathNormalizer.ToProjectRelative( + "/proj/Library/PackageCache/com.example.core@abc123/Runtime/Foo.cs", + "/proj", + PackageRoots(("/proj/Library/PackageCache/com.example.core@abc123", "Packages/com.example.core")), + StringComparison.Ordinal); + + Assert.That(relative, Is.EqualTo("Packages/com.example.core/Runtime/Foo.cs")); + } + + [Test] + public void ToProjectRelative_WhenPackageIsEmbeddedUnderTheProjectRoot_PrefersTheVirtualPackagePath() + { + // Verifies an embedded package is not reduced to its physical folder, which resolves to the wrong assembly. + string relative = ScriptPathNormalizer.ToProjectRelative( + "/proj/Packages/src/Editor/Foo.cs", + "/proj", + PackageRoots(("/proj/Packages/src", "Packages/com.example.core")), + StringComparison.Ordinal); + + Assert.That(relative, Is.EqualTo("Packages/com.example.core/Editor/Foo.cs")); + } + + [Test] + public void ToProjectRelative_WhenPackageResolvesOutsideTheProject_ReturnsTheVirtualPackagePath() + { + // Verifies a file: package whose folder lives outside the project root still maps to its virtual path. + string relative = ScriptPathNormalizer.ToProjectRelative( + "/MyPackage/Runtime/Foo.cs", + "/proj", + PackageRoots(("/MyPackage", "Packages/com.example.core")), + StringComparison.Ordinal); + + Assert.That(relative, Is.EqualTo("Packages/com.example.core/Runtime/Foo.cs")); + } + + [Test] + public void ToProjectRelative_WhenAbsoluteAssetsPath_StripsTheProjectRoot() + { + // Verifies an absolute Assets path under the project root becomes project-relative. + string relative = ScriptPathNormalizer.ToProjectRelative( + "/proj/Assets/Scripts/Foo.cs", + "/proj", + PackageRoots(), + StringComparison.Ordinal); + + Assert.That(relative, Is.EqualTo("Assets/Scripts/Foo.cs")); + } + + [Test] + public void ToProjectRelative_WhenProjectRootEndsWithSlash_StripsTheSameRoot() + { + // Verifies a trailing slash on the project root does not change the result. + string relative = ScriptPathNormalizer.ToProjectRelative( + "/proj/Assets/Scripts/Foo.cs", + "/proj/", + PackageRoots(), + StringComparison.Ordinal); + + Assert.That(relative, Is.EqualTo("Assets/Scripts/Foo.cs")); + } + + [Test] + public void ToProjectRelative_WhenPathUsesBackslashes_NormalizesSeparators() + { + // Verifies Windows separators in the path, the root, and a package root are all normalized. + string relative = ScriptPathNormalizer.ToProjectRelative( + "C:\\proj\\Assets\\Scripts\\Foo.cs", + "C:\\proj", + PackageRoots(("C:\\proj\\Packages\\src", "Packages/com.example.core")), + StringComparison.OrdinalIgnoreCase); + + Assert.That(relative, Is.EqualTo("Assets/Scripts/Foo.cs")); + } + + [Test] + public void ToProjectRelative_WhenWindowsCaseDiffers_StillMapsThePackageRoot() + { + // Verifies the Windows comparison ignores case differences against a package root. + string relative = ScriptPathNormalizer.ToProjectRelative( + "c:/PROJ/Packages/SRC/Editor/Foo.cs", + "C:/proj", + PackageRoots(("C:/proj/Packages/src", "Packages/com.example.core")), + StringComparison.OrdinalIgnoreCase); + + Assert.That(relative, Is.EqualTo("Packages/com.example.core/Editor/Foo.cs")); + } + + [Test] + public void ToProjectRelative_WhenPathIsOutsideTheProject_OnlyNormalizesSeparators() + { + // Verifies a path outside the project root is returned as is, so the caller hits the existing failure path. + string relative = ScriptPathNormalizer.ToProjectRelative( + "/other/Assets/Scripts/Foo.cs", + "/proj", + PackageRoots(), + StringComparison.Ordinal); + + Assert.That(relative, Is.EqualTo("/other/Assets/Scripts/Foo.cs")); + } + + [Test] + public void ToPhysicalProjectRelative_AssetsPath_IsReturnedUnchanged() + { + // Verifies an Assets path, which has no virtual root, keeps the exact string the snapshot and the PDB already agree on. + string physical = ScriptPathNormalizer.ToPhysicalProjectRelative( + "Assets/Scripts/A.cs", + "/proj", + PackageRoots(("/proj/Packages/src", "Packages/io.example.pkg")), + StringComparison.Ordinal); + + Assert.That(physical, Is.EqualTo("Assets/Scripts/A.cs")); + } + + [Test] + public void ToPhysicalProjectRelative_EmbeddedPackage_MapsToTheFolderUnderPackages() + { + // Verifies an embedded package's virtual path maps to its folder, whose name differs from the package name. + string physical = ScriptPathNormalizer.ToPhysicalProjectRelative( + "Packages/io.example.pkg/Runtime/A.cs", + "/proj", + PackageRoots(("/proj/Packages/src", "Packages/io.example.pkg")), + StringComparison.Ordinal); + + Assert.That(physical, Is.EqualTo("Packages/src/Runtime/A.cs")); + } + + [Test] + public void ToPhysicalProjectRelative_LocalPackageInsideTheProject_MapsToTheProjectRelativeFolder() + { + // Verifies a file: package that lives in the project outside Packages maps to its project-relative folder. + string physical = ScriptPathNormalizer.ToPhysicalProjectRelative( + "Packages/io.example.pkg/Runtime/A.cs", + "/proj", + PackageRoots(("/proj/Modules/pkg", "Packages/io.example.pkg")), + StringComparison.Ordinal); + + Assert.That(physical, Is.EqualTo("Modules/pkg/Runtime/A.cs")); + } + + [Test] + public void ToPhysicalProjectRelative_LocalPackageOutsideTheProject_ReturnsTheAbsolutePath() + { + // Verifies a file: package outside the project root yields the absolute path, since no project-relative path names it. + string physical = ScriptPathNormalizer.ToPhysicalProjectRelative( + "Packages/io.example.pkg/Runtime/A.cs", + "/proj", + PackageRoots(("/elsewhere/pkg", "Packages/io.example.pkg")), + StringComparison.Ordinal); + + Assert.That(physical, Is.EqualTo("/elsewhere/pkg/Runtime/A.cs")); + } + + [Test] + public void ToPhysicalProjectRelative_PackageNameThatPrefixesAnother_DoesNotMatchTheLongerName() + { + // Verifies a package whose name prefixes another's is not taken for the longer one, even when it is listed first. + string physical = ScriptPathNormalizer.ToPhysicalProjectRelative( + "Packages/io.example.pkg.extra/Runtime/A.cs", + "/proj", + PackageRoots( + ("/proj/Packages/short-folder", "Packages/io.example.pkg"), + ("/proj/Packages/long-folder", "Packages/io.example.pkg.extra")), + StringComparison.Ordinal); + + Assert.That(physical, Is.EqualTo("Packages/long-folder/Runtime/A.cs")); + } + + [Test] + public void ToPhysicalProjectRelative_NoMatchingRoot_IsReturnedUnchanged() + { + // Verifies a package path that no registered package claims is left as is rather than guessed at. + string physical = ScriptPathNormalizer.ToPhysicalProjectRelative( + "Packages/io.example.unregistered/Runtime/A.cs", + "/proj", + PackageRoots(("/proj/Packages/src", "Packages/io.example.pkg")), + StringComparison.Ordinal); + + Assert.That(physical, Is.EqualTo("Packages/io.example.unregistered/Runtime/A.cs")); + } + + [Test] + public void ToPhysicalProjectRelative_OnWindowsSeparators_NormalizesToForwardSlashes() + { + // Verifies Windows separators in the path, the project root, and the package root all come out as forward slashes. + string physical = ScriptPathNormalizer.ToPhysicalProjectRelative( + "Packages\\io.example.pkg\\Runtime\\A.cs", + "C:\\proj", + PackageRoots(("C:\\proj\\Packages\\src", "Packages\\io.example.pkg")), + StringComparison.OrdinalIgnoreCase); + + Assert.That(physical, Is.EqualTo("Packages/src/Runtime/A.cs")); + } + + [Test] + public void ToAssetPath_RelativePhysicalPackagePath_IsResolvedAgainstTheProjectRootAndMappedToTheAssetPath() + { + // Verifies a project-relative path into a package's folder comes back as the package's asset path. + string assetPath = ScriptPathNormalizer.ToAssetPath( + "Packages/folder/A.cs", + AssetPathProjectRoot, + PackageRoots((AssetPathProjectRoot + "/Packages/folder", "Packages/io.example.pkg"))); + + Assert.That(assetPath, Is.EqualTo("Packages/io.example.pkg/A.cs")); + } + + [Test] + public void ToAssetPath_DotSlashPrefix_IsRemoved() + { + // Verifies a leading ./ is folded away, so the same file always yields the same asset path. + string assetPath = ScriptPathNormalizer.ToAssetPath( + "./Assets/A.cs", + AssetPathProjectRoot, + PackageRoots((AssetPathProjectRoot + "/Packages/folder", "Packages/io.example.pkg"))); + + Assert.That(assetPath, Is.EqualTo("Assets/A.cs")); + } + + [Test] + public void ToAssetPath_BackslashesUnderAssets_AreNormalized() + { + // Verifies Windows separators in an Assets path come out as forward slashes. + string assetPath = ScriptPathNormalizer.ToAssetPath( + @"Assets\Sub\A.cs", + AssetPathProjectRoot, + PackageRoots((AssetPathProjectRoot + "/Packages/folder", "Packages/io.example.pkg"))); + + Assert.That(assetPath, Is.EqualTo("Assets/Sub/A.cs")); + } + + [Test] + public void ToAssetPath_AssetPathOfAPackage_IsReturnedUnchanged() + { + // Verifies a path that is already a package's asset path is not mapped a second time. + string assetPath = ScriptPathNormalizer.ToAssetPath( + "Packages/io.example.pkg/A.cs", + AssetPathProjectRoot, + PackageRoots((AssetPathProjectRoot + "/Packages/folder", "Packages/io.example.pkg"))); + + Assert.That(assetPath, Is.EqualTo("Packages/io.example.pkg/A.cs")); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadScriptPathNormalizerTests.cs.meta b/Assets/Tests/Editor/HotReload/ScriptPathNormalizerTests.cs.meta similarity index 100% rename from Assets/Tests/Editor/HotReload/HotReloadScriptPathNormalizerTests.cs.meta rename to Assets/Tests/Editor/HotReload/ScriptPathNormalizerTests.cs.meta diff --git a/Assets/Tests/Editor/HotReload/ShimReferenceFriendGrant.cs b/Assets/Tests/Editor/HotReload/ShimReferenceFriendGrant.cs new file mode 100644 index 0000000000..a492b3f169 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/ShimReferenceFriendGrant.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +// Grants internals to a name no assembly in this project carries, so the shim reference copy tests +// can tell an assembly that grants internals to the edited one from an assembly that does not. +[assembly: InternalsVisibleTo("UloopShimReferenceFriendTarget")] diff --git a/Assets/Tests/Editor/HotReload/ShimReferenceFriendGrant.cs.meta b/Assets/Tests/Editor/HotReload/ShimReferenceFriendGrant.cs.meta new file mode 100644 index 0000000000..2c72d9ab7a --- /dev/null +++ b/Assets/Tests/Editor/HotReload/ShimReferenceFriendGrant.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5e9fa7f061fc4431e8488b45c448a7f6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/ShimReferenceVisibilityFixtures.cs b/Assets/Tests/Editor/HotReload/ShimReferenceVisibilityFixtures.cs new file mode 100644 index 0000000000..1f26f834d4 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/ShimReferenceVisibilityFixtures.cs @@ -0,0 +1,59 @@ +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// A top-level internal type with an extension method. A shim reference copy of this assembly + /// built for a target it grants no internals to must keep the type internal: the target's own + /// compile never saw it, so neither may the shim compile. + /// + internal static class ShimReferenceHiddenIntExtensions + { + public static int Doubled(this int value) + { + return value * 2; + } + } + + /// + /// A public type whose private and internal members a shim reference copy keeps as they are for + /// a target the assembly grants no internals to, while its nested type is publicized as before. + /// + public static class ShimReferencePublicHost + { + internal static int HiddenField = 1; + + private static int SecretField = 1; + + private static int Secret() + { + return SecretField; + } + + internal static int Hidden(this int value) + { + return value + 1; + } + + internal class NestedInternal + { + } + } + + /// + /// A public base type for a target the assembly grants no internals to: a shim reference copy + /// keeps its private protected member as it is, because a derived type in another assembly + /// reaches that member only through such a grant, and still publicizes its protected member, + /// because a shim calls it from outside the type hierarchy. + /// + public class ShimReferencePublicBase + { + private protected int Guarded() + { + return 2; + } + + protected int Shielded() + { + return 3; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/ShimReferenceVisibilityFixtures.cs.meta b/Assets/Tests/Editor/HotReload/ShimReferenceVisibilityFixtures.cs.meta new file mode 100644 index 0000000000..9a9063c00d --- /dev/null +++ b/Assets/Tests/Editor/HotReload/ShimReferenceVisibilityFixtures.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 421bed8b83cc4404493417974268784d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/SourcePausePointIdTests.cs b/Assets/Tests/Editor/HotReload/SourcePausePointIdTests.cs new file mode 100644 index 0000000000..c70152f03b --- /dev/null +++ b/Assets/Tests/Editor/HotReload/SourcePausePointIdTests.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Verifies which query ids are rewritten to the asset path, and when the package roots are read. + /// + public sealed class SourcePausePointIdTests + { + private static readonly string ProjectRoot = Path.GetFullPath("/proj"); + + // A local package whose folder sits inside the project root but outside Packages/. + private static readonly ScriptPackageRoot LocalPackage = new ScriptPackageRoot( + Path.Combine(ProjectRoot, "LocalPackages", "foo"), + "Packages/com.example.foo"); + + /// + /// What: the project-relative folder path of a local package outside Packages/ is rewritten + /// to the package's asset path. + /// + [Test] + public void ToMarkerId_FolderPathOfAPackageOutsidePackagesFolder_RewritesToTheAssetPath() + { + string markerId = SourcePausePointId.ToMarkerId( + "LocalPackages/foo/Runtime/X.cs:3", ProjectRoot, NotRegistered, () => new[] { LocalPackage }); + + Assert.That(markerId, Is.EqualTo("Packages/com.example.foo/Runtime/X.cs:3")); + } + + /// + /// What: a plain Assets path comes back as given without reading the package roots, since an + /// await polls status every second. + /// + [Test] + public void ToMarkerId_AssetsPath_ReturnsItWithoutReadingPackageRoots() + { + string markerId = SourcePausePointId.ToMarkerId( + "Assets/X.cs:3", ProjectRoot, NotRegistered, FailWhenRead); + + Assert.That(markerId, Is.EqualTo("Assets/X.cs:3")); + } + + /// + /// What: a package script already named by its asset path comes back as given after the roots are read. + /// + [Test] + public void ToMarkerId_PackageAssetPath_ReturnsItUnchanged() + { + string markerId = SourcePausePointId.ToMarkerId( + "Packages/com.example.foo/Runtime/X.cs:3", ProjectRoot, NotRegistered, () => new[] { LocalPackage }); + + Assert.That(markerId, Is.EqualTo("Packages/com.example.foo/Runtime/X.cs:3")); + } + + private static bool NotRegistered(string id) + { + return false; + } + + private static IReadOnlyList FailWhenRead() + { + Assert.Fail("The package roots must not be read for an Assets path."); + return Array.Empty(); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/SourcePausePointIdTests.cs.meta b/Assets/Tests/Editor/HotReload/SourcePausePointIdTests.cs.meta new file mode 100644 index 0000000000..1acea700fd --- /dev/null +++ b/Assets/Tests/Editor/HotReload/SourcePausePointIdTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 531306ebba6d24d5484e706505669b52 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerBindingSplitTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerBindingSplitTests.cs index 2aeb4b1da1..1db140f3a2 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerBindingSplitTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerBindingSplitTests.cs @@ -55,6 +55,14 @@ public class TransformWorkerBindingSplitTests "\n\n public void Wire()\n {\n" + " new HotReloadBindingSplitNestedRegistry.Inner().Register(p => Handle(p));\n }"; + // The installed client names a type's file through the package roots a run captures, and + // these tests call it directly rather than through a run. + [SetUp] + public void SetUp() + { + HotReloadCompositionRoot.Services.PackageRootCapture.CaptureCurrent(); + } + /// /// What: with the host alone in the run, the lambda binds against the compiled payload and /// the added method is applied through the accessor rewrite, so the guard added for the diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs index a2e36dce3b..17e2ce4d56 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs @@ -116,6 +116,7 @@ public async Task RunWorker_UnknownOperation_ReturnsFailure() "HotReloadInternalSignatureProbe.cs", "HotReloadGlobalUsingBehaviourBase.cs", "HotReloadGlobalUsingMode.cs", + "IsExternalInit.cs", }; /// @@ -1555,35 +1556,138 @@ public async Task Run_ExistingPropertyGetterBodyEdit_KeepsPatchedGetterWithoutOu } /// - /// What: editing only the getter of an existing getter+setter property still emits the - /// setter skip row and does not emit outside-body drift. Pins the snapshot ContainsKey - /// guard so an existing property is not stripped from the current tree alone. + /// What: editing only the getter of an existing getter+setter property patches the getter + /// and leaves the setter it did not touch off the Skipped list, without outside-body drift. /// [Test] - public async Task Run_ExistingGetterAndSetterProperty_GetterBodyEdit_SkipsSetterWithoutOutsideBodyWarning() + public async Task Run_ExistingGetterAndSetterProperty_GetterBodyEdit_PatchesTheGetterAndLeavesTheUnchangedSetterOut() { const string fileName = "ExistingGetterAndSetterPropertyGetterEdit.cs"; - string onDisk = File.ReadAllText(ResolveShapeFixturePath()); - string editedSource = onDisk.Replace( - "get { return _value; }", - "get { return _value + 1; }", - StringComparison.Ordinal); - Assert.That(editedSource, Is.Not.EqualTo(onDisk), "Precondition: getter body must differ."); + TransformWorkerClientResult result = await RunWorkerOnEditedShapeCopyAsync( + fileName, + onDisk => ReplaceUniqueFragment( + onDisk, + "get { return _value; }", + "get { return _value + 1; }")); - 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); + AssertEmittedWithoutSkip(result, "get_Value"); + AssertDoesNotContainOutsideMethodBodyDriftWarning(result, fileName); + AssertSkippedDoesNotContain(result, "set_Value"); + } + + /// + /// What: editing both accessor bodies of an existing getter+setter property patches the + /// getter and still skips the setter, because the setter is the one that changed. + /// + [Test] + public async Task Run_ExistingGetterAndSetterProperty_BothBodiesEdited_PatchesTheGetterAndSkipsTheSetter() + { + const string fileName = "ExistingGetterAndSetterPropertyBothEdited.cs"; + TransformWorkerClientResult result = await RunWorkerOnEditedShapeCopyAsync( + fileName, + onDisk => ReplaceUniqueFragment( + ReplaceUniqueFragment(onDisk, "get { return _value; }", "get { return _value + 1; }"), + "set { _value = value; }", + "set { _value = value + 1; }")); + + AssertEmittedWithoutSkip(result, "get_Value"); + AssertSkippedContains(result, "set_Value", ExpectedExplicitAccessorSkipReason); + AssertDoesNotContainOutsideMethodBodyDriftWarning(result, fileName); + } + + /// + /// What: editing only the getter of an existing getter+init property leaves the init + /// accessor it did not touch off the Skipped list. init is reported through set_. + /// + [Test] + public async Task Run_ExistingGetterAndInitProperty_GetterBodyEdit_LeavesTheUnchangedInitOut() + { + const string fileName = "ExistingGetterAndInitPropertyGetterEdit.cs"; + TransformWorkerClientResult result = await RunWorkerOnEditedShapeCopyAsync( + fileName, + onDisk => ReplaceUniqueFragment( + onDisk, + "get { return _initialized; }", + "get { return _initialized + 1; }")); + + AssertEmittedWithoutSkip(result, "get_Initialized"); + AssertSkippedDoesNotContain(result, "set_Initialized"); + } + + /// + /// What: a getter-only edit leaves an unchanged setter whose switch the worker annotates off + /// the Skipped list. The annotated setter no longer equals its own snapshot text, so only a + /// compare against the unannotated tree recognizes it as unchanged. + /// + [Test] + public async Task Run_ExistingPropertyWithASwitchSetter_GetterBodyEdit_LeavesTheUnchangedSetterOut() + { + const string fileName = "ExistingSwitchSetterPropertyGetterEdit.cs"; + TransformWorkerClientResult result = await RunWorkerOnEditedShapeCopyAsync( + fileName, + onDisk => ReplaceUniqueFragment( + onDisk, + "get { return _mode; }", + "get { return _mode + 1; }")); + + AssertEmittedWithoutSkip(result, "get_Mode"); + AssertSkippedDoesNotContain(result, "set_Mode"); + } + + /// + /// What: an attribute added to an existing property, with both accessor bodies untouched, + /// patches the getter (its compare covers the declaration), leaves the setter off the + /// Skipped list, and names the property in the outside-body drift warning. + /// + [Test] + public async Task Run_ExistingProperty_DeclarationOnlyEdit_LeavesTheUnchangedSetterOutAndWarnsDrift() + { + const string fileName = "ExistingPropertyDeclarationOnlyEdit.cs"; + TransformWorkerClientResult result = await RunWorkerOnEditedShapeCopyAsync( + fileName, + onDisk => ReplaceUniqueFragment( + onDisk, + " public int Value\n", + " [System.Obsolete]\n public int Value\n")); + + Assert.That( + result.Output.files[0].declarationDriftWarnings, + Does.Contain( + "Edits outside method bodies in ExistingPropertyDeclarationOnlyEdit.cs (property: Value) since the last compile are not applied by hot reload; run uloop compile to pick them up.")); + AssertEmittedWithoutSkip(result, "get_Value"); + AssertSkippedDoesNotContain(result, "set_Value"); + } + + /// + /// What: a property the snapshot does not declare still reports its setter as Skipped, and + /// the property is not read as an edit outside method bodies because the setter row marks it + /// as added to the current tree. + /// + [Test] + public async Task Run_PropertyAbsentFromSnapshot_SetterBodyIsStillSkipped() + { + string sourcePath = ResolveShapeFixturePath(); + string onDisk = File.ReadAllText(sourcePath); + string snapshotSource = ReplaceUniqueFragment( + onDisk, + " public int Value\n" + + " {\n" + + " get { return _value; }\n" + + " set { _value = value; }\n" + + " }\n", + string.Empty); TransformWorkerClientResult result = await RunWorkerOnSourceAsync( sourcePath, ResolveShapeFixtureProjectRelativePath(), - snapshotSource: onDisk); + snapshotSource: snapshotSource); Assert.That(result.Success, Is.True, result.ErrorMessage); AssertSkippedContains(result, "set_Value", ExpectedExplicitAccessorSkipReason); - AssertDoesNotContainOutsideMethodBodyDriftWarning(result, fileName); + Assert.That( + result.Output.files[0].declarationDriftWarnings, + Has.None.Contain("Edits outside method bodies"), + "A property the snapshot lacks must be stripped from the current tree before the compare."); } /// @@ -1852,10 +1956,11 @@ await RunWorkerAndFindEntryAsync( /// /// What: with an identical snapshot, property getters with bodies are listed in - /// unchangedMethods as get_<Name> (Skipped-only accessors would leave them out). + /// unchangedMethods as get_<Name> (Skipped-only accessors would leave them out), and + /// no set or init body is reported as Skipped. /// [Test] - public async Task Run_WithIdenticalSnapshotOnPropertyGetterFixture_ListsGettersUnchanged() + public async Task Run_WithIdenticalSnapshotOnPropertyGetterFixture_ListsGettersUnchangedAndSkipsNoSetter() { string sourcePath = ResolveShapeFixturePath(); string onDisk = File.ReadAllText(sourcePath); @@ -1889,6 +1994,9 @@ public async Task Run_WithIdenticalSnapshotOnPropertyGetterFixture_ListsGettersU Does.Contain("get_Score"), "Unedited block getter must appear in unchangedMethods; got: " + string.Join(", ", unchangedNames)); + AssertSkippedDoesNotContain(result, "set_Value"); + AssertSkippedDoesNotContain(result, "set_Initialized"); + AssertSkippedDoesNotContain(result, "set_Mode"); } /// @@ -2033,11 +2141,12 @@ public async Task Run_WithLocalAliasShadowingGlobalAlias_KeepsLocalAliasOnly() } /// - /// What: when only a property setter body differs from the snapshot, the unchanged getter - /// stays out of entries (baseline compare is getter-scoped, not whole-property). + /// What: when only a property setter body differs from the snapshot, the setter is Skipped + /// and the unchanged getter stays out of entries (both compares are per accessor, not + /// whole-property). /// [Test] - public async Task Run_WithSnapshotDifferingOnlyInSetter_DoesNotEmitUnchangedGetter() + public async Task Run_WithSnapshotDifferingOnlyInSetter_SkipsTheSetterAndDoesNotEmitUnchangedGetter() { string sourcePath = ResolveShapeFixturePath(); string onDisk = File.ReadAllText(sourcePath); @@ -2053,6 +2162,7 @@ public async Task Run_WithSnapshotDifferingOnlyInSetter_DoesNotEmitUnchangedGett snapshotSource: snapshotSource); Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertSkippedContains(result, "set_Value", ExpectedExplicitAccessorSkipReason); if (result.Output.entries != null) { foreach (TransformWorkerEntryDto entry in result.Output.entries) @@ -2811,6 +2921,28 @@ private static async Task RunWorkerOnEditedE2ECopyA return result; } + private static async Task RunWorkerOnEditedShapeCopyAsync( + string fileName, + Func edit) + { + string onDisk = File.ReadAllText(ResolveShapeFixturePath()); + string editedSource = edit(onDisk); + Assert.That(editedSource, Is.Not.EqualTo(onDisk), "Precondition: snapshot must differ."); + + 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); + 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) @@ -4035,7 +4167,8 @@ private static TransformWorkerOutputInterpreter CreateOutputInterpreter() { return new TransformWorkerOutputInterpreter( new TransformWorkerOutputValidator(), - new TransformWorkerCompiledTypeFileCompleter()); + new TransformWorkerCompiledTypeFileCompleter( + new FixedPackageRootCapture(Array.Empty()))); } private static string CreateMatchingPreparationOutputJson(string assemblyName, string assemblyMvid) diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerCompiledTypeFileCompleterTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerCompiledTypeFileCompleterTests.cs index ca5b73c9a0..e66653f51d 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerCompiledTypeFileCompleterTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerCompiledTypeFileCompleterTests.cs @@ -1,4 +1,6 @@ +using System; using System.IO; +using System.Linq; using NUnit.Framework; @@ -23,6 +25,16 @@ public class TransformWorkerCompiledTypeFileCompleterTests "Assets/Tests/Editor/HotReload/HotReloadBindingSplitRegistry.cs"; private const string MissingTypeMetadataName = "Example.Kinds"; private const string WorkerPlacedPath = "Assets/Scripts/Kinds.cs"; + // An embedded package whose folder name differs from its package name, so its PDB records + // the script under a path that is not the asset path. + private const string PackageFixtureAssemblyName = "UnityCLILoop.Tests.HotReloadPackageFixture"; + private const string PackageFixtureTypeMetadataName = + "io.github.hatayama.UnityCliLoop.Tests.PackageFixture.HotReloadPackageFixture"; + private const string PackageFixtureRootAssetPath = "Packages/io.github.hatayama.uloop.hotreload-package-fixture"; + private const string PackageFixtureAssetPath = + "Packages/io.github.hatayama.uloop.hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs"; + private const string PackageFixturePhysicalPath = + "Packages/uloop-hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs"; /// /// What: a split reason carried as the detail of another reason, as when a member reads an @@ -49,7 +61,7 @@ public void Complete_SplitReasonAsDetail_CompletesTheDetailWithTheDeclaringFile( } }; - new TransformWorkerCompiledTypeFileCompleter().Complete( + CompleterWithNoPackageRoots().Complete( new TransformWorkerInputDto { targetTypesAssemblyPath = TargetAssemblyPath() }, output); @@ -77,7 +89,7 @@ public void Complete_PdbOfAnotherBuild_NamesTheTypeInsteadOfThrowing() skipped = new[] { new TransformWorkerSkippedDto { method = "Example.Host.Wire()", reason = split } } }; - new TransformWorkerCompiledTypeFileCompleter().Complete( + CompleterWithNoPackageRoots().Complete( new TransformWorkerInputDto { targetTypesAssemblyPath = dllPath }, output); @@ -140,27 +152,99 @@ public void Complete_FilesTheWorkerPlaced_AreKeptAndNamedInTheSentence() Assert.That(reason.args[3], Is.EqualTo("'" + WorkerPlacedPath + "'")); } + /// + /// What: a type declared in an embedded package's script is named by the script's asset + /// path, the form a reload takes, rather than by the folder path its PDB records. + /// + [Test] + public void Complete_TypeDeclaredInAPackageSource_NamesTheAssetPath() + { + TransformWorkerReasonDto split = SplitReasonNaming(PackageFixtureTypeMetadataName); + ScriptPackageRoot fixtureRoot = ScriptPackageRoots.ReadCurrent() + .FirstOrDefault(root => root.AssetPath == PackageFixtureRootAssetPath); + if (fixtureRoot == null) + { + Assert.Fail("The package fixture is not registered: " + PackageFixtureRootAssetPath); + } + + CompleteFrom( + PackageFixtureAssemblyPath(), + new FixedPackageRootCapture(new[] { fixtureRoot }), + split); + + Assert.That(split.declaringFiles, Is.EqualTo(new[] { PackageFixtureAssetPath })); + string text = HotReloadWorkerReasonText.Render(split); + Assert.That(text, Does.Contain("declared in '" + PackageFixtureAssetPath + "'"), text); + } + + /// + /// What: with no package roots captured, the same type is named by its folder path relative + /// to the project, without the ./ the PDB records it with. + /// + [Test] + public void Complete_TypeDeclaredInAPackageSource_WithNoPackageRoots_NamesThePhysicalPathWithoutTheDotSlash() + { + TransformWorkerReasonDto split = SplitReasonNaming(PackageFixtureTypeMetadataName); + + CompleteFrom(PackageFixtureAssemblyPath(), NoPackageRoots(), split); + + Assert.That(split.declaringFiles, Is.EqualTo(new[] { PackageFixturePhysicalPath })); + } + private static void Complete(TransformWorkerReasonDto reason) + { + CompleteFrom(TargetAssemblyPath(), NoPackageRoots(), reason); + } + + private static void CompleteFrom( + string targetTypesAssemblyPath, + IHotReloadPackageRootCapture packageRootCapture, + TransformWorkerReasonDto reason) { TransformWorkerOutputDto output = new TransformWorkerOutputDto { skipped = new[] { new TransformWorkerSkippedDto { method = "Example.Host.Wire()", reason = reason } } }; - new TransformWorkerCompiledTypeFileCompleter().Complete( - new TransformWorkerInputDto { targetTypesAssemblyPath = TargetAssemblyPath() }, + new TransformWorkerCompiledTypeFileCompleter(packageRootCapture).Complete( + new TransformWorkerInputDto { targetTypesAssemblyPath = targetTypesAssemblyPath }, output); } + private static TransformWorkerCompiledTypeFileCompleter CompleterWithNoPackageRoots() + { + return new TransformWorkerCompiledTypeFileCompleter(NoPackageRoots()); + } + + // Enough for the tests reading this test assembly's own PDB: it records Assets scripts + // only, which no package root claims. + private static IHotReloadPackageRootCapture NoPackageRoots() + { + return new FixedPackageRootCapture(Array.Empty()); + } + private static TransformWorkerReasonDto SplitReason() + { + return SplitReasonNaming(RegistryTypeMetadataName); + } + + private static TransformWorkerReasonDto SplitReasonNaming(string typeMetadataName) { return new TransformWorkerReasonDto { code = HotReloadWorkerReasonCode.AddedMethodBodyBindsCompiledSignature, - args = new[] { "CS1503", "'Example.Payload'", "'" + RegistryTypeMetadataName + "'" }, - typeMetadataNames = new[] { RegistryTypeMetadataName } + args = new[] { "CS1503", "'Example.Payload'", "'" + typeMetadataName + "'" }, + typeMetadataNames = new[] { typeMetadataName } }; } + private static string PackageFixtureAssemblyPath() + { + string path = Path.GetFullPath( + Path.Combine(Application.dataPath, "..", "Library", "ScriptAssemblies", PackageFixtureAssemblyName + ".dll")); + Assert.That(File.Exists(path), Is.True, "Package fixture dll missing: " + path); + return path; + } + private static string OtherAssemblyPdbPath() { string path = Path.GetFullPath( diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs index da0c1005b3..2d2415adf4 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs @@ -78,9 +78,26 @@ public class TransformWorkerPartialTypeTests private const string CallerPlainValueBody = "return 1;"; private const string PlainDerivedValueBody = "return 10;"; private const string PartialDerivedValueBody = "return 9;"; + + // The next steps an internal-member reason ends with, each after the sentence before it. + private const string QualifyTheBareNameAdvice = + " Qualify the bare name with 'this.' or the type name, or run 'uloop compile'."; + + private const string MoveTheUseOutAdvice = + " Move the use out of the lambda, local function or query, or run 'uloop compile'."; + + private const string CompileOnlyAdvice = " Run 'uloop compile'."; private const string InternalMemberOfATypeOfAnotherAssembly = "global::io.github.hatayama.UnityCliLoop.FirstPartyTools.PausePointCapturedVariable.FromSnapshot(null).Name.Length"; + // The installed client names a type's file through the package roots a run captures, and + // these tests call it directly rather than through a run. + [SetUp] + public void SetUp() + { + HotReloadCompositionRoot.Services.PackageRootCapture.CaptureCurrent(); + } + /// /// What: a body that reads a private field declared in another part of the type is emitted. /// @@ -776,6 +793,186 @@ public async Task Skip_PlainTypeBodyTheGuardCannotTellFromAnOutOfReachUse_IsSkip Assert.That(AssertSkipped(result, "DerivedValue"), Does.Contain("is internal to 'HotReloadInternalMemberHost'"), FormatSkipped(result)); } + /// + /// What: an internal member named bare in the method's own statements ends its reason with the + /// advice to qualify the name, on a partial and a plain type, because the qualified name is a + /// use the patched method can reach. + /// + [TestCase("Partial", PartialDerivedFileName, PartialDerivedValueBody)] + [TestCase("Plain", PlainDerivedFileName, PlainDerivedValueBody)] + public async Task Skip_BareInternalName_EndsWithTheQualifyAdvice(string typeKind, string fileName, string fragment) + { + string reason = await ReadTheInternalMemberSkipReasonAsync( + fileName, + "ReasonEndingBareName" + typeKind + ".cs", + "DerivedValue", + fragment, + "return InternalInstanceValue();"); + + Assert.That(reason, Does.EndWith(QualifyTheBareNameAdvice)); + } + + /// + /// What: an internal member used inside a lambda or a query, or a lambda that works with a value + /// such a member gave, ends its reason with the advice to move the use out of the closure, + /// because the closure runs outside the patched method whatever receiver the use has. + /// + [TestCase("PartialLambda", PartialDerivedFileName, PartialDerivedValueBody, "System.Func read = () => HotReloadInternalMemberHost.InternalStaticValue(); return read();")] + [TestCase("PlainLambda", PlainDerivedFileName, PlainDerivedValueBody, "System.Func read = () => HotReloadInternalMemberHost.InternalStaticValue(); return read();")] + [TestCase("PlainLambdaCapturingTheResultAsAValue", PlainDerivedFileName, PlainDerivedValueBody, "var seed = new HotReloadInternalMemberHost().InternalField; System.Func read = () => seed + 100; return read();")] + [TestCase("PlainQuerySourceExpression", PlainDerivedFileName, PlainDerivedValueBody, "return (from host in HotReloadInternalMemberHost.InternalHosts() select 1).Count();")] + public async Task Skip_InternalMemberUsedInsideALambda_EndsWithTheMoveOutAdvice( + string form, + string fileName, + string fragment, + string replacement) + { + string reason = await ReadTheInternalMemberSkipReasonAsync( + fileName, + "ReasonEndingInsideClosure" + form + ".cs", + "DerivedValue", + fragment, + replacement); + + Assert.That(reason, Does.EndWith(MoveTheUseOutAdvice)); + } + + /// + /// What: an internal member used in an async or iterator method ends its reason with the compile + /// alone, with its receiver, by a bare name, or inside a lambda, because the state machine runs + /// outside the patched method however the use is written or wherever it is moved. + /// + [TestCase("PartialAsyncThroughThis", PartialDerivedFileName, "AsyncValue", "return 50;", "return this.InternalInstanceValue();")] + [TestCase("PlainAsyncThroughThis", PlainDerivedFileName, "AsyncValue", "return 50;", "return this.InternalInstanceValue();")] + [TestCase("PlainIteratorThroughThis", PlainDerivedFileName, "IteratorValues", "yield return _seed;", "yield return this.InternalInstanceValue();")] + [TestCase("PlainAsyncBareName", PlainDerivedFileName, "AsyncValue", "return 50;", "return InternalInstanceValue();")] + [TestCase("PartialAsyncLambda", PartialDerivedFileName, "AsyncValue", "return 50;", "System.Func read = () => HotReloadInternalMemberHost.InternalStaticValue(); return read();")] + [TestCase("PlainAsyncLambda", PlainDerivedFileName, "AsyncValue", "return 50;", "System.Func read = () => HotReloadInternalMemberHost.InternalStaticValue(); return read();")] + [TestCase("PlainIteratorLambda", PlainDerivedFileName, "IteratorValues", "yield return _seed;", "System.Func read = () => HotReloadInternalMemberHost.InternalStaticValue(); yield return read();")] + public async Task Skip_InternalMemberUsedInAnAsyncOrIteratorMethod_EndsWithCompileOnly( + string form, + string fileName, + string methodName, + string fragment, + string replacement) + { + string reason = await ReadTheInternalMemberSkipReasonAsync( + fileName, + "ReasonEndingStateMachine" + form + ".cs", + methodName, + fragment, + replacement); + + Assert.That(reason, Does.EndWith(CompileOnlyAdvice)); + } + + /// + /// What: an internal member used inside the lambda of a getter that runs through a delegating + /// shim, because the lambda also reads a private member, ends its reason with the compile + /// alone, not with the advice to move the use out: the shim runs the whole getter outside the + /// patched method, so moving the use out of the lambda does not bring it within reach. + /// + [TestCase("Partial", PartialDerivedFileName)] + [TestCase("Plain", PlainDerivedFileName)] + public async Task Skip_InternalMemberUsedInALambdaOfAGetterPatchedThroughADelegatingShim_EndsWithCompileOnly( + string typeKind, + string fileName) + { + string reason = await ReadTheInternalMemberSkipReasonAsync( + fileName, + "ReasonEndingShimGetterLambda" + typeKind + ".cs", + "get_DerivedProperty", + "return 40;", + "System.Func read = () => this._seed + HotReloadInternalMemberHost.InternalStaticValue(); return read();"); + + Assert.That(reason, Does.EndWith(CompileOnlyAdvice)); + } + + /// + /// What: a method named bare to be passed as a delegate ends its reason with the advice to + /// qualify it on a plain type, which emits the qualified method group, and with the compile + /// alone on a partial type, which does not patch a method group in place however it is named. + /// + [TestCase("Partial", PartialDerivedFileName, PartialDerivedValueBody, CompileOnlyAdvice)] + [TestCase("Plain", PlainDerivedFileName, PlainDerivedValueBody, QualifyTheBareNameAdvice)] + public async Task Skip_BareInternalMethodPassedAsADelegate_EndsWithTheStepThatReachesIt( + string typeKind, + string fileName, + string fragment, + string expectedEnding) + { + string reason = await ReadTheInternalMemberSkipReasonAsync( + fileName, + "ReasonEndingBareMethodGroup" + typeKind + ".cs", + "DerivedValue", + fragment, + "System.Func read = InternalInstanceValue; return read();"); + + Assert.That(reason, Does.EndWith(expectedEnding)); + } + + /// + /// What: a partial type's use of an internal member that has its receiver but is neither a + /// field, a property nor an invoked method (a method passed as a delegate, an event + /// subscription) ends its reason with the compile alone, because neither qualifying the use + /// nor moving it makes it one hot reload patches. + /// + [TestCase("MethodPassedAsDelegate", "System.Func read = HotReloadInternalMemberHost.InternalStaticValue; return read();")] + [TestCase("EventSubscription", "HotReloadInternalMemberHost host = new HotReloadInternalMemberHost(); host.InternalEvent += HotReloadInternalMemberHost.NoOp; return host.RaiseInternalEvent();")] + public async Task Skip_PartialTypeUseThatCannotBePatchedInPlace_EndsWithCompileOnly(string form, string replacement) + { + string reason = await ReadTheInternalMemberSkipReasonAsync( + PartialDerivedFileName, + "ReasonEndingPartialNotInPlace" + form + ".cs", + "DerivedValue", + PartialDerivedValueBody, + replacement); + + Assert.That(reason, Does.EndWith(CompileOnlyAdvice)); + } + + /// + /// What: an internal member named bare inside a lambda ends its reason with the advice to move + /// the use out of the lambda, not to qualify it, because a qualified name inside the lambda + /// still runs outside the patched method. + /// + [TestCase("Partial", PartialDerivedFileName, PartialDerivedValueBody)] + [TestCase("Plain", PlainDerivedFileName, PlainDerivedValueBody)] + public async Task Skip_BareInternalNameInsideALambda_PrefersTheMoveOutAdvice(string typeKind, string fileName, string fragment) + { + string reason = await ReadTheInternalMemberSkipReasonAsync( + fileName, + "ReasonEndingBareNameInsideALambda" + typeKind + ".cs", + "DerivedValue", + fragment, + "System.Func read = () => InternalInstanceValue(); return read();"); + + Assert.That(reason, Does.EndWith(MoveTheUseOutAdvice)); + } + + /// + /// What: the internal-member reason opens with the member and the type that declares it, and + /// gives the compiler's diagnostic after them in parentheses, so the reader learns which member + /// is out of reach before reading the diagnostic. + /// + [Test] + public async Task Skip_InternalMemberReason_LeadsWithTheMemberAndTheType() + { + string reason = await ReadTheInternalMemberSkipReasonAsync( + PlainDerivedFileName, + "ReasonLeadBareName.cs", + "DerivedValue", + PlainDerivedValueBody, + "return InternalInstanceValue();"); + + Assert.That( + reason, + Does.StartWith( + "'InternalInstanceValue' is internal to 'HotReloadInternalMemberHost', whose source this " + + "reload was not given (CS0103: ")); + Assert.That(reason, Does.Contain("does not exist in the current context). Hot reload patches")); + } + /// /// 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 @@ -993,6 +1190,7 @@ public async Task Skip_PlainSiblingBroughtBack_GetterUsingInternalMemberInsideAL 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)); + Assert.That(reason, Does.EndWith(MoveTheUseOutAdvice), FormatSkipped(result)); } /// @@ -1038,6 +1236,24 @@ public async Task Skip_SiblingBroughtBack_UsingInternalMemberNextToAnUnresolvedN Assert.That(AssertSkipped(result, "PlainValue"), Does.Contain("brought back to re-bind"), FormatSkipped(result)); } + // The internal-member skip reason of the one method an edit of a fixture file changes. + private static async Task ReadTheInternalMemberSkipReasonAsync( + string fileName, + string editedFileName, + string methodName, + string fragment, + string replacement) + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildEditedFixtureSource(fileName, editedFileName, fragment, replacement) + }); + + string reason = AssertSkipped(result, methodName); + Assert.That(reason, Does.Contain("is internal to 'HotReloadInternalMemberHost'"), FormatSkipped(result)); + return reason; + } + // 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( diff --git a/Assets/Tests/Editor/HotReload/UnityCLILoop.Tests.Editor.HotReload.asmdef b/Assets/Tests/Editor/HotReload/UnityCLILoop.Tests.Editor.HotReload.asmdef index f074e8696a..8d6e832462 100644 --- a/Assets/Tests/Editor/HotReload/UnityCLILoop.Tests.Editor.HotReload.asmdef +++ b/Assets/Tests/Editor/HotReload/UnityCLILoop.Tests.Editor.HotReload.asmdef @@ -14,7 +14,9 @@ "GUID:5079a8d3a72924a81aa1cbc25f65ed1b", "GUID:0b83f8a313f54fdea6fcf945d7fd0615", "GUID:384dc39e13234d8aae3b1b1c44c9507e", - "GUID:1e3293e9639242278dcb27724b34beb4" + "GUID:1e3293e9639242278dcb27724b34beb4", + "GUID:5390a0826a36841e7a977485f863d05f", + "GUID:24d448abf7c10486d9578018b3a3c54b" ], "includePlatforms": [ "Editor" diff --git a/Assets/Tests/Editor/HotReloadShimReferenceForeignInternal.meta b/Assets/Tests/Editor/HotReloadShimReferenceForeignInternal.meta new file mode 100644 index 0000000000..16529d2629 --- /dev/null +++ b/Assets/Tests/Editor/HotReloadShimReferenceForeignInternal.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7b18b0e63bc5043c794eef8d07c7ff59 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReloadShimReferenceForeignInternal/ShimReferenceForeignInternalExtensions.cs b/Assets/Tests/Editor/HotReloadShimReferenceForeignInternal/ShimReferenceForeignInternalExtensions.cs new file mode 100644 index 0000000000..de192c3b99 --- /dev/null +++ b/Assets/Tests/Editor/HotReloadShimReferenceForeignInternal/ShimReferenceForeignInternalExtensions.cs @@ -0,0 +1,60 @@ +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.ShimReferenceForeignInternal +{ + /// + /// An internal type declaring an extension method with the signature of a public one beside it. + /// This assembly grants no internals, so a referencing assembly never sees this type and binds + /// the call to the public method. + /// + internal static class ForeignHiddenIntExtensions + { + public static int Tripled(this int value) + { + return value * 3; + } + } + + /// + /// The public extension methods a referencing assembly binds its calls to. + /// + public static class ForeignVisibleIntExtensions + { + public static int Tripled(this int value) + { + return value + value + value; + } + + public static int Quadrupled(this int value) + { + return value * 4; + } + + public static int Quintupled(this int value) + { + return value * 5; + } + } + + /// + /// A public type whose internal extension method has the signature of a public one beside it, + /// so a referencing assembly never sees this member and binds the call to the public method. + /// + public static class ForeignHostWithInternalExtension + { + internal static int Quadrupled(this int value) + { + return value + value + value + value; + } + } + + /// + /// A public type whose private extension method has the signature of a public one beside it, + /// so a referencing assembly never sees this member and binds the call to the public method. + /// + public static class ForeignHostWithPrivateExtension + { + private static int Quintupled(this int value) + { + return value + value + value + value + value; + } + } +} diff --git a/Assets/Tests/Editor/HotReloadShimReferenceForeignInternal/ShimReferenceForeignInternalExtensions.cs.meta b/Assets/Tests/Editor/HotReloadShimReferenceForeignInternal/ShimReferenceForeignInternalExtensions.cs.meta new file mode 100644 index 0000000000..47081bf68b --- /dev/null +++ b/Assets/Tests/Editor/HotReloadShimReferenceForeignInternal/ShimReferenceForeignInternalExtensions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5e887244443ae4727a3a1065bd369aee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReloadShimReferenceForeignInternal/UnityCLILoop.Tests.Editor.HotReload.ShimReferenceForeignInternal.asmdef b/Assets/Tests/Editor/HotReloadShimReferenceForeignInternal/UnityCLILoop.Tests.Editor.HotReload.ShimReferenceForeignInternal.asmdef new file mode 100644 index 0000000000..e266fb9b25 --- /dev/null +++ b/Assets/Tests/Editor/HotReloadShimReferenceForeignInternal/UnityCLILoop.Tests.Editor.HotReload.ShimReferenceForeignInternal.asmdef @@ -0,0 +1,16 @@ +{ + "name": "UnityCLILoop.Tests.Editor.HotReload.ShimReferenceForeignInternal", + "rootNamespace": "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.ShimReferenceForeignInternal", + "references": [], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/Tests/Editor/HotReloadShimReferenceForeignInternal/UnityCLILoop.Tests.Editor.HotReload.ShimReferenceForeignInternal.asmdef.meta b/Assets/Tests/Editor/HotReloadShimReferenceForeignInternal/UnityCLILoop.Tests.Editor.HotReload.ShimReferenceForeignInternal.asmdef.meta new file mode 100644 index 0000000000..6c434ad426 --- /dev/null +++ b/Assets/Tests/Editor/HotReloadShimReferenceForeignInternal/UnityCLILoop.Tests.Editor.HotReload.ShimReferenceForeignInternal.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5390a0826a36841e7a977485f863d05f +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs index 330b861b86..9b67955f27 100644 --- a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs +++ b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs @@ -26,6 +26,7 @@ public sealed class OnionAssemblyDependencyTests private const string FirstPartyToolsAssemblyNamePrefix = "UnityCLILoop.FirstPartyTools."; private const string ClearConsoleAssemblyName = "UnityCLILoop.FirstPartyTools.ClearConsole.Editor"; private const string CommonConsoleAssemblyName = "UnityCLILoop.FirstPartyTools.Common.Console.Editor"; + private const string CommonScriptPathAssemblyName = "UnityCLILoop.FirstPartyTools.Common.ScriptPath.Editor"; private const string CompileAssemblyName = "UnityCLILoop.FirstPartyTools.Compile.Editor"; private const string ControlPlayModeAssemblyName = "UnityCLILoop.FirstPartyTools.ControlPlayMode.Editor"; private const string ExecuteDynamicCodeAssemblyName = "UnityCLILoop.FirstPartyTools.ExecuteDynamicCode.Editor"; @@ -498,7 +499,8 @@ public void PresentationAsmdef_WhenLoaded_DependsOnApplicationAndDoesNotReferenc [Test] public void InfrastructureAsmdef_WhenLoaded_DependsOnApplicationRuntimeAndDoesNotReferencePresentation() { - // Tests that infrastructure can bridge public runtime APIs while presentation remains a sibling outer layer. + // Tests that infrastructure can bridge public runtime APIs, and map a pause point query's + // script path through the shared script path mapping, while presentation remains a sibling outer layer. string[] references = ReadResolvedReferences("Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef"); Assert.That(references, Is.EquivalentTo(new[] @@ -507,6 +509,7 @@ public void InfrastructureAsmdef_WhenLoaded_DependsOnApplicationRuntimeAndDoesNo ApplicationAssemblyName, DomainAssemblyName, CommonConsoleAssemblyName, + CommonScriptPathAssemblyName, PausePointsRuntimeAssemblyName, ToolContractsAssemblyName })); diff --git a/Assets/Tests/Editor/PausePointQueryScriptPathFormTests.cs b/Assets/Tests/Editor/PausePointQueryScriptPathFormTests.cs new file mode 100644 index 0000000000..ceab01ad31 --- /dev/null +++ b/Assets/Tests/Editor/PausePointQueryScriptPathFormTests.cs @@ -0,0 +1,148 @@ +using System; + +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Infrastructure; +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies that status, extend, and clear reach a source pause point whichever path form the + /// query names its file by, while a named marker is looked up by its id as given. + /// + public sealed class PausePointQueryScriptPathFormTests + { + // An embedded package whose folder name differs from its package name, so the folder path + // and the asset path of the same script differ. + private const string PackageAssetPath = + "Packages/io.github.hatayama.uloop.hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs"; + private const string PackagePhysicalPath = + "Packages/uloop-hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs"; + private const string AssetsScriptPath = "Assets/Tests/Editor/PausePointQueryScriptPathFormTests.cs"; + private const string Line = ":16"; + private const string ProjectRootToken = "/"; + + [SetUp] + public void SetUp() + { + UloopPausePointRegistry.ConfigureForTests(new FakePauseController(), () => DateTime.UtcNow); + } + + [TearDown] + public void TearDown() + { + UloopPausePointRegistry.ResetForTests(); + } + + /// + /// What: a status query naming the file by its folder path, its absolute path, or a ./ path + /// finds the marker enabled under the asset path. + /// + [TestCase(PackageAssetPath, PackagePhysicalPath)] + [TestCase(PackageAssetPath, ProjectRootToken + PackagePhysicalPath)] + [TestCase(PackageAssetPath, "./" + PackagePhysicalPath)] + [TestCase(PackageAssetPath, "./" + PackageAssetPath)] + [TestCase(AssetsScriptPath, "./" + AssetsScriptPath)] + [TestCase(AssetsScriptPath, ProjectRootToken + AssetsScriptPath)] + [TestCase(AssetsScriptPath, "Assets/Tests/../Tests/Editor/PausePointQueryScriptPathFormTests.cs")] + public void Status_ByAnotherPathForm_FindsTheMarkerEnabledByTheAssetPath(string enabledPath, string queriedPath) + { + string markerId = enabledPath + Line; + UloopPausePointRegistry.Enable(markerId, 30); + + PausePointStatusResponse response = PausePointStatusBridgeCommand.Execute(IdParams(queriedPath + Line)); + + Assert.That(response.Status, Is.EqualTo(UloopPausePointStatus.Enabled)); + Assert.That(response.Id, Is.EqualTo(markerId)); + } + + /// + /// What: the extend an await sends names the file as the user typed it and still extends the marker. + /// + [Test] + public void Extend_ByTheFolderPath_ExtendsTheMarkerEnabledByTheAssetPath() + { + string markerId = PackageAssetPath + Line; + UloopPausePointRegistry.Enable(markerId, 1); + JObject parameters = IdParams(PackagePhysicalPath + Line); + parameters["MinimumRemainingSeconds"] = 60; + + PausePointStatusResponse response = PausePointStatusBridgeCommand.Extend(parameters); + + Assert.That(response.Id, Is.EqualTo(markerId)); + Assert.That(response.RemainingMilliseconds, Is.GreaterThan(30000)); + } + + /// + /// What: the clear sent after an await times out removes the marker when it names the folder path. + /// + [Test] + public void BridgeClear_ByTheFolderPath_ClearsTheMarkerEnabledByTheAssetPath() + { + string markerId = PackageAssetPath + Line; + UloopPausePointRegistry.Enable(markerId, 30); + + PausePointStatusBridgeCommand.Clear(IdParams(PackagePhysicalPath + Line)); + + Assert.That(UloopPausePointRegistry.IsArmed(markerId), Is.False); + } + + /// + /// What: clear-pause-point --file with the folder path removes the marker enabled under the asset path. + /// + [Test] + public void ToolClear_ByTheFolderPath_ClearsTheMarkerEnabledByTheAssetPath() + { + string markerId = PackageAssetPath + Line; + UloopPausePointRegistry.Enable(markerId, 30); + + new PausePointUseCase().Clear(new ClearPausePointSchema { Id = PackagePhysicalPath + Line }); + + Assert.That(UloopPausePointRegistry.IsArmed(markerId), Is.False); + } + + /// + /// What: a named marker is looked up by the id as given, even when the id reads like a path + /// and line that a rewrite would change. + /// + [TestCase("./jump")] + [TestCase("./jump:1")] + [TestCase("Enemy\\Attack:2")] + public void Status_NamedMarker_IsLookedUpByItsIdAsGiven(string markerId) + { + UloopPausePointRegistry.Enable(markerId, 30); + + PausePointStatusResponse response = PausePointStatusBridgeCommand.Execute(IdParams(markerId)); + + Assert.That(response.Status, Is.EqualTo(UloopPausePointStatus.Enabled)); + Assert.That(response.Id, Is.EqualTo(markerId)); + } + + private static JObject IdParams(string id) + { + string resolved = id.StartsWith(ProjectRootToken, StringComparison.Ordinal) + ? UnityCliLoopPathResolver.GetProjectRoot().Replace('\\', '/').TrimEnd('/') + "/" + + id.Substring(ProjectRootToken.Length) + : id; + return new JObject { ["Id"] = resolved }; + } + + private sealed class FakePauseController : IUloopPausePointPauseController + { + public bool IsPlaying => true; + public bool IsPaused => false; + + public void Pause() + { + } + + public void Resume() + { + } + } + } +} diff --git a/Assets/Tests/Editor/PausePointQueryScriptPathFormTests.cs.meta b/Assets/Tests/Editor/PausePointQueryScriptPathFormTests.cs.meta new file mode 100644 index 0000000000..8500b111f2 --- /dev/null +++ b/Assets/Tests/Editor/PausePointQueryScriptPathFormTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 14efd6d86bccf4403b32f9032b454de3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs b/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs index cef541b55b..052b7fd261 100644 --- a/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs +++ b/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs @@ -15,6 +15,13 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor public sealed class SourcePausePointResolverTests { private const string FixturesDirectory = "Assets/Tests/Editor/SourcePausePointResolver/Fixtures/"; + // A script in an embedded package whose folder name differs from its package name, so the + // asset path Unity reports for it and the path its PDB records differ. + private const string PackageFixtureAssetPath = + "Packages/io.github.hatayama.uloop.hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs"; + private const string PackageFixturePhysicalPath = + "Packages/uloop-hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs"; + private const int PackageFixtureSecondStatementLine = 16; [Test] public void Resolve_NormalMethod_ResolvesLineWithLocalsAndParameters() @@ -551,5 +558,50 @@ public void MethodMatchesFilter_WhenLambda_DoesNotMatchOuterMethodName() "CoroutineMethodFixture"), Is.False); } + + /// + /// What: a statement in an embedded package's script, named by its asset path, resolves + /// although the PDB records the script under the package's folder. + /// + [Test] + public void Resolve_PackageSourceGivenByItsAssetPath_FindsTheStatement() + { + SourcePausePointResolveResult result = SourcePausePointResolver.Resolve( + PackageFixtureAssetPath, PackageFixtureSecondStatementLine); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(result.Resolution.ResolvedLine, Is.EqualTo(PackageFixtureSecondStatementLine)); + Assert.That(result.Resolution.MethodDisplayName, Does.Contain("Second")); + } + + /// + /// What: locating an embedded package's script by its asset path reports the path under the + /// package's folder, which is what the PDB records and what reaches the file on disk. + /// + [Test] + public void Locate_PackageSourceGivenByItsAssetPath_ReportsThePhysicalPath() + { + SourcePausePointCompiledAssemblyLocation location = + SourcePausePointCompiledAssemblyLocator.Locate(PackageFixtureAssetPath); + + Assert.That(location.Found, Is.True, location.FailureMessage); + Assert.That(location.PhysicalPath, Is.EqualTo(PackageFixturePhysicalPath)); + } + + /// + /// What: an Assets script has no folder behind a virtual path, so the physical path it + /// reports is the path it was located by. + /// + [Test] + public void Locate_AssetsSourcePath_ReportsItselfAsThePhysicalPath() + { + string assetsPath = FixturesDirectory + "NormalMethodFixture.cs"; + + SourcePausePointCompiledAssemblyLocation location = + SourcePausePointCompiledAssemblyLocator.Locate(assetsPath); + + Assert.That(location.Found, Is.True, location.FailureMessage); + Assert.That(location.PhysicalPath, Is.EqualTo(assetsPath)); + } } } diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index d41611ea49..06ef467c18 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -221,6 +221,12 @@ "com.unity.modules.imgui": "1.0.0" } }, + "io.github.hatayama.uloop.hotreload-package-fixture": { + "version": "file:uloop-hotreload-package-fixture", + "depth": 0, + "source": "embedded", + "dependencies": {} + }, "io.github.hatayama.uloopmcp": { "version": "file:src", "depth": 0, diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 2379953977..67af9e67d0 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -33,7 +33,7 @@ Enable a pause point so Unity pauses when that code path is reached, either by a | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `--id` | string | - | Named pause point id passed to UloopPausePoint.Pause. Mutually exclusive with File/Line | -| `--file` | string | - | Project-relative source file path to patch a pause point into. Requires Line; mutually exclusive with Id | +| `--file` | string | - | Source file to patch a pause point into: `Assets/...`, `Packages//...`, a package folder path, or an absolute path. Requires Line; mutually exclusive with Id | | `--line` | integer | - | 1-based source line to resolve within File. Requires File; mutually exclusive with Id | | `--timeout-seconds` | integer | `30` | Seconds before the enable request expires and stops pausing late hits | | `--mode` | enum | `single-shot` | Capture mode: single-shot pauses once, continuous pauses on every hit, trace records hits without pausing | @@ -95,7 +95,7 @@ On a wait timeout, `PAUSE_POINT_EXPIRED`, or an enable failure, read `Error.Deta - Patches drop on every compile or domain reload (the compile / Play-entry responses warn). `--persist` re-arms automatically (`references/persist.md`). - Physics message methods, their helpers, and pre-bound delegates can miss hits on pre-existing GameObjects; enable warns where detectable. - An `--id` marker waits on your `UloopPausePoint.Pause(id)` call (`using io.github.hatayama.UnityCliLoop.Runtime;`); its hits record no `CapturedVariables`. -- For scripts under `Packages/`, pass the package-id path form (`Packages//...`); physical checkout paths do not resolve. +- Every `--file` form resolves to the asset path (`Packages//...` for a package script), which the marker `Id` uses; status, await, and clear find it by any form. ## Reference Guides @@ -109,4 +109,4 @@ Read the one whose trigger matches: - `references/condition-triggered-pause.md` — runtime-condition pauses. - `references/fast-progressing-games.md` — freezing self-progressing games, `--resume-play`. - `references/persist.md` — what `--persist` restores, timeout restart, first-frame gap, the re-arm report. -- `references/troubleshooting.md` — timeouts, missed hits, hot reload, Debug switch, failure codes. +- `references/troubleshooting.md` — timeouts, missed hits, hot reload, Debug switch, failure codes, path forms. diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/troubleshooting.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/troubleshooting.md index 38f4136b25..dbe447522b 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/troubleshooting.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/troubleshooting.md @@ -84,6 +84,10 @@ If enable fails with a "No sequence point found" error (worded "No compiled stat A "No method named '' ..." failure means no method matching `--method` has a statement on or after `--line`. The match is case-sensitive, on the simple name or `Type.Method`, and a name that matches no method in the file fails on every line and after every compile, so check `--method` (or drop it) before moving `--line`. +## File Path Forms + +`--file` takes the asset path Unity shows (`Assets/...` or `Packages//...`), the path under a package's folder (`Packages//...` for an embedded package), or an absolute path. Every form resolves to the asset path, and a file:line marker's `Id` is `:`. `pause-point-status`, `await-pause-point`, and `clear-pause-point` resolve `--file` (or an `--id` of the form `:`) the same way, so every form finds the same marker. A named marker's `Id` is looked up as given. + ## Debug-switch trade-offs The warning that the Debug switch happened means the pause point is already armed: do not diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath.meta b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath.meta new file mode 100644 index 0000000000..4ea030d993 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5717b5136ef4d47b7ac82406e07e78a1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/AssemblyInfo.cs new file mode 100644 index 0000000000..8f57186ca2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/AssemblyInfo.cs @@ -0,0 +1,7 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.HotReload.Shared.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.HotReload.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.PausePoint.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.Infrastructure")] +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor.HotReload")] diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/AssemblyInfo.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/AssemblyInfo.cs.meta new file mode 100644 index 0000000000..aae570ceaa --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fc40ae7a41c814980bfc2f4989f7758e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPackageRoot.cs b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPackageRoot.cs new file mode 100644 index 0000000000..7ada76f178 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPackageRoot.cs @@ -0,0 +1,24 @@ +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One package's physical folder and the virtual path Unity exposes it under. + /// + internal sealed class ScriptPackageRoot + { + internal ScriptPackageRoot(string resolvedPath, string assetPath) + { + Debug.Assert(!string.IsNullOrEmpty(resolvedPath), "resolvedPath must not be empty."); + Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty."); + ResolvedPath = resolvedPath; + AssetPath = assetPath; + } + + /// Absolute folder the package's files actually live in. + internal string ResolvedPath { get; } + + /// Virtual folder Unity's script APIs expect, in the form Packages/<package-id>. + internal string AssetPath { get; } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPackageRoot.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPackageRoot.cs.meta new file mode 100644 index 0000000000..1a332fb69b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPackageRoot.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a65ad172f1a414e57980d78f780f1c4e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPackageRoots.cs b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPackageRoots.cs new file mode 100644 index 0000000000..7840689ba3 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPackageRoots.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using UnityEngine; + +using PackageManagerPackageInfo = UnityEditor.PackageManager.PackageInfo; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Asks the Package Manager where the registered packages live. Both members read Package + /// Manager state, so both must be called on the Unity main thread. + /// + internal static class ScriptPackageRoots + { + /// + /// Lists every registered package that has both a folder on disk and a virtual path. + /// + internal static IReadOnlyList ReadCurrent() + { + PackageManagerPackageInfo[] packages = PackageManagerPackageInfo.GetAllRegisteredPackages(); + List roots = new List(packages.Length); + foreach (PackageManagerPackageInfo package in packages) + { + if (string.IsNullOrEmpty(package.resolvedPath) || string.IsNullOrEmpty(package.assetPath)) + { + continue; + } + + roots.Add(new ScriptPackageRoot(package.resolvedPath, package.assetPath)); + } + + return roots; + } + + /// + /// Returns the path of the file behind a script's asset path: project-relative when the + /// file is in the project, absolute when its package lives outside it, and the input itself + /// when no package owns the path, as for Assets. This is the form a portable PDB records + /// for the document, and Path.Combine(projectRoot, result) reaches the file on disk. + /// + internal static string ToPhysicalPath(string projectRoot, string slashNormalizedAssetPath) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be empty."); + Debug.Assert(!string.IsNullOrEmpty(slashNormalizedAssetPath), "slashNormalizedAssetPath must not be empty."); + + PackageManagerPackageInfo package = PackageManagerPackageInfo.FindForAssetPath(slashNormalizedAssetPath); + if (package == null || string.IsNullOrEmpty(package.resolvedPath) || string.IsNullOrEmpty(package.assetPath)) + { + return slashNormalizedAssetPath; + } + + ScriptPackageRoot root = new ScriptPackageRoot(package.resolvedPath, package.assetPath); + StringComparison comparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return ScriptPathNormalizer.ToPhysicalProjectRelative( + slashNormalizedAssetPath, + projectRoot, + new[] { root }, + comparison); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPackageRoots.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPackageRoots.cs.meta new file mode 100644 index 0000000000..4a965a1795 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPackageRoots.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e8f040c7e9a604f2c81a6fda93bcf93e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPathNormalizer.cs b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPathNormalizer.cs new file mode 100644 index 0000000000..ddf312fdd9 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPathNormalizer.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Maps a script path between the file on disk and the project-relative form that + /// CompilationPipeline.GetAssemblyNameFromScriptPath accepts, in either direction. + /// + internal static class ScriptPathNormalizer + { + /// + /// Turns a script path given in any form (relative to the project root or absolute, with + /// either separator, naming the package's folder or its virtual path) into the asset path + /// Unity's script APIs use. A path outside the project and every package comes back absolute. + /// + internal static string ToAssetPath( + string path, + string projectRoot, + IReadOnlyList packageRoots) + { + Debug.Assert(!string.IsNullOrEmpty(path), "path must not be empty."); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be empty."); + Debug.Assert(packageRoots != null, "packageRoots must not be null."); + + // Resolved against the project root rather than the current directory: the two are the + // same in the Editor, and the root keeps the result independent of where the caller runs. + // Path.Combine keeps a rooted path as is, and GetFullPath folds ./ and ../ away. In the + // Editor, GetFullPath also turns the virtual Packages//... path of a registered + // package into the folder behind it, which the package roots map back to the virtual path. + // A name no package registers stays under the project root and comes back unchanged once + // the root is stripped. + string fullPath = Path.GetFullPath(Path.Combine(projectRoot, path.Replace('\\', '/'))); + StringComparison comparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return ToProjectRelative(fullPath, projectRoot, packageRoots, comparison); + } + + internal static string ToProjectRelative( + string fullPath, + string projectRoot, + IReadOnlyList packageRoots, + StringComparison comparison) + { + Debug.Assert(!string.IsNullOrEmpty(fullPath), "fullPath must not be empty."); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be empty."); + Debug.Assert(packageRoots != null, "packageRoots must not be null."); + + string normalized = fullPath.Replace('\\', '/'); + // Package folders are matched before the project root: an embedded package lives under + // the project root, and stripping the root would yield the physical Packages/ + // path, which resolves to the wrong assembly. + string packageRelative = TryMapToPackagePath(normalized, packageRoots, comparison); + if (packageRelative != null) + { + return packageRelative; + } + + string root = WithTrailingSlash(projectRoot.Replace('\\', '/')); + // Assets has no virtual mapping of its own, so it stays absolute and needs the root removed. + if (normalized.StartsWith(root, comparison)) + { + return normalized.Substring(root.Length); + } + + // Outside the project: returned as is so GetAssemblyNameFromScriptPath yields an empty + // name and the caller reports the existing "not part of any compiled assembly" failure. + return normalized; + } + + private static string TryMapToPackagePath( + string normalized, + IReadOnlyList packageRoots, + StringComparison comparison) + { + foreach (ScriptPackageRoot packageRoot in packageRoots) + { + string resolvedRoot = WithTrailingSlash(packageRoot.ResolvedPath.Replace('\\', '/')); + if (!normalized.StartsWith(resolvedRoot, comparison)) + { + continue; + } + + string assetRoot = WithTrailingSlash(packageRoot.AssetPath.Replace('\\', '/')); + return assetRoot + normalized.Substring(resolvedRoot.Length); + } + + return null; + } + + /// + /// Turns a project-relative asset path into the path of the file behind it: the + /// project-relative physical path, or the absolute one when the package lives outside + /// the project. A path under no package root comes back with forward slashes only. + /// + internal static string ToPhysicalProjectRelative( + string assetRelativePath, + string projectRoot, + IReadOnlyList packageRoots, + StringComparison comparison) + { + Debug.Assert(!string.IsNullOrEmpty(assetRelativePath), "assetRelativePath must not be empty."); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be empty."); + Debug.Assert(packageRoots != null, "packageRoots must not be null."); + + string normalized = assetRelativePath.Replace('\\', '/'); + foreach (ScriptPackageRoot packageRoot in packageRoots) + { + // Matched with the trailing slash so Packages/io.example.pkg does not claim a file of + // Packages/io.example.pkg.extra. + string assetRoot = WithTrailingSlash(packageRoot.AssetPath.Replace('\\', '/')); + if (!normalized.StartsWith(assetRoot, comparison)) + { + continue; + } + + string physical = WithTrailingSlash(packageRoot.ResolvedPath.Replace('\\', '/')) + + normalized.Substring(assetRoot.Length); + string root = WithTrailingSlash(projectRoot.Replace('\\', '/')); + // A package outside the project has no project-relative path, so the absolute one stands. + return physical.StartsWith(root, comparison) ? physical.Substring(root.Length) : physical; + } + + // Assets has no virtual root, so its asset path already names the file itself. + return normalized; + } + + private static string WithTrailingSlash(string path) + { + return path.EndsWith("/", StringComparison.Ordinal) ? path : path + "/"; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadScriptPathNormalizer.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPathNormalizer.cs.meta similarity index 100% rename from Packages/src/Editor/FirstPartyTools/HotReload/HotReloadScriptPathNormalizer.cs.meta rename to Packages/src/Editor/FirstPartyTools/Common/ScriptPath/ScriptPathNormalizer.cs.meta diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/SourcePausePointId.cs b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/SourcePausePointId.cs new file mode 100644 index 0000000000..9401780d36 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/SourcePausePointId.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The id of a pause point set at a source location, <asset path>:<line>: built + /// once when the marker is enabled, and rebuilt from whatever path form a later query names + /// the file by, so every form reaches the same marker. + /// + internal static class SourcePausePointId + { + internal static string Build(string assetPath, int line) + { + Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty."); + return assetPath + ":" + line; + } + + /// + /// Returns the id a query names its marker by. An id already registered, such as a named + /// marker, comes back as given. Otherwise an id in the source form has its path rewritten + /// to the asset path; any other id, and a source id whose path is already the asset path, + /// comes back unchanged. The package roots are read only when the path needs them. Must be + /// called on the Unity main thread. + /// + internal static string ToMarkerId( + string id, + string projectRoot, + Func isRegistered, + Func> readPackageRoots) + { + Debug.Assert(!string.IsNullOrEmpty(id), "id must not be empty."); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be empty."); + Debug.Assert(isRegistered != null, "isRegistered must not be null."); + Debug.Assert(readPackageRoots != null, "readPackageRoots must not be null."); + + // Why first: a named marker may itself read like :, and the rewrite would + // send its query to an id nobody enabled. + if (isRegistered(id)) + { + return id; + } + + int separator = id.LastIndexOf(':'); + if (separator <= 0 || !int.TryParse(id.Substring(separator + 1), out int line)) + { + return id; + } + + string path = id.Substring(0, separator); + // Why skip the Package Manager for an Assets path: await polls status every second, and + // such a path is already its asset path. + if (IsPlainAssetsPath(path)) + { + return id; + } + + string assetPath = ScriptPathNormalizer.ToAssetPath(path, projectRoot, readPackageRoots()); + return assetPath == path ? id : Build(assetPath, line); + } + + // Why only Assets/: any other project-relative path may be the folder of a package, under + // Packages/ or elsewhere in the project, and only the package roots tell. Why . and .. + // segments are not skipped: enable folds them away, while the CLI sends them as typed. + private static bool IsPlainAssetsPath(string path) + { + return path.StartsWith("Assets/", StringComparison.Ordinal) + && path.IndexOf('\\') < 0 + && !HasDotSegment(path); + } + + private static bool HasDotSegment(string path) + { + foreach (string segment in path.Split('/')) + { + if (segment == "." || segment == "..") + { + return true; + } + } + + return false; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/SourcePausePointId.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/SourcePausePointId.cs.meta new file mode 100644 index 0000000000..4a63438c5b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/SourcePausePointId.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d31bc7150491947ce84b201a779bf622 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/UnityCLILoop.FirstPartyTools.Common.ScriptPath.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/UnityCLILoop.FirstPartyTools.Common.ScriptPath.Editor.asmdef new file mode 100644 index 0000000000..75a8bb6d9d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/UnityCLILoop.FirstPartyTools.Common.ScriptPath.Editor.asmdef @@ -0,0 +1,16 @@ +{ + "name": "UnityCLILoop.FirstPartyTools.Common.ScriptPath.Editor", + "rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools", + "references": [], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/UnityCLILoop.FirstPartyTools.Common.ScriptPath.Editor.asmdef.meta b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/UnityCLILoop.FirstPartyTools.Common.ScriptPath.Editor.asmdef.meta new file mode 100644 index 0000000000..d731e01232 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/UnityCLILoop.FirstPartyTools.Common.ScriptPath.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 24d448abf7c10486d9578018b3a3c54b +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadActiveSiblingRebindPlanner.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadActiveSiblingRebindPlanner.cs index 7a7bd7d334..5f4dde964f 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadActiveSiblingRebindPlanner.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadActiveSiblingRebindPlanner.cs @@ -43,7 +43,8 @@ internal HotReloadSiblingInclusion( /// /// Files of one assembly that should be pulled into this reload so their patches bind - /// to the newest shim, and the files that would have been but changed since. + /// to the newest shim, the files that would have been but changed since, and the files left + /// Skipped or Failed that went back to their compiled source. /// /// /// Added methods are emitted into a per-run shim assembly. Callers patched against the @@ -55,12 +56,14 @@ internal HotReloadActiveSiblingRebindPlan( IReadOnlyList filesToInclude, IReadOnlyList changedSinceApplyPaths, IReadOnlyList changedSinceSkipPaths, - IReadOnlyList changedCompanionPaths) + IReadOnlyList changedCompanionPaths, + IReadOnlyList revertedSinceSkipPaths) { FilesToInclude = filesToInclude; ChangedSinceApplyPaths = changedSinceApplyPaths; ChangedSinceSkipPaths = changedSinceSkipPaths; ChangedCompanionPaths = changedCompanionPaths; + RevertedSinceSkipPaths = revertedSinceSkipPaths; } internal IReadOnlyList FilesToInclude { get; } @@ -70,6 +73,12 @@ internal HotReloadActiveSiblingRebindPlan( internal IReadOnlyList ChangedSinceSkipPaths { get; } internal IReadOnlyList ChangedCompanionPaths { get; } + + /// + /// Files left Skipped or Failed whose source holds the compile snapshot bytes again, so they + /// have nothing left to apply. + /// + internal IReadOnlyList RevertedSinceSkipPaths { get; } } /// @@ -78,18 +87,23 @@ internal HotReloadActiveSiblingRebindPlan( /// internal static class HotReloadActiveSiblingRebindPlanner { + // matchesVerifiedBaseline answers whether the worker source of a project-relative path holds + // the compile snapshot bytes. Why a delegate: the snapshot is named by the assembly's DLL, + // which the caller holds, and the planner itself reads only the domain and the sources. internal static HotReloadActiveSiblingRebindPlan Plan( HotReloadDomain domain, string assemblyName, string[] assemblySourceFiles, IReadOnlyCollection pathsAlreadyInRun, - Func resolveWorkerSourcePath) + Func resolveWorkerSourcePath, + Func matchesVerifiedBaseline) { Debug.Assert(domain != null, "domain must not be null."); Debug.Assert(!string.IsNullOrEmpty(assemblyName), "assemblyName must not be empty."); Debug.Assert(assemblySourceFiles != null, "assemblySourceFiles must not be null."); Debug.Assert(pathsAlreadyInRun != null, "pathsAlreadyInRun must not be null."); Debug.Assert(resolveWorkerSourcePath != null, "resolveWorkerSourcePath must not be null."); + Debug.Assert(matchesVerifiedBaseline != null, "matchesVerifiedBaseline must not be null."); StringComparer comparer = HotReloadSourcePathNormalizer.ProjectRelativePathComparer(); // Why every reason is kept rather than the first: each reason carries its own recorded @@ -126,6 +140,7 @@ internal static HotReloadActiveSiblingRebindPlan Plan( [HotReloadSiblingInclusionReason.RetryAfterSkip] = new List(), [HotReloadSiblingInclusionReason.Companion] = new List() }; + List revertedSinceSkipPaths = new List(); foreach (KeyValuePair> candidate in candidates) { if (!BelongsToAssembly( @@ -144,8 +159,10 @@ internal static HotReloadActiveSiblingRebindPlan Plan( candidate.Key, evidence, resolveWorkerSourcePath, + matchesVerifiedBaseline, filesToInclude, - changedByReason); + changedByReason, + revertedSinceSkipPaths); } filesToInclude.Sort( @@ -155,11 +172,14 @@ internal static HotReloadActiveSiblingRebindPlan Plan( changed.Sort(string.CompareOrdinal); } + revertedSinceSkipPaths.Sort(string.CompareOrdinal); + return new HotReloadActiveSiblingRebindPlan( filesToInclude, changedByReason[HotReloadSiblingInclusionReason.ActiveChanges], changedByReason[HotReloadSiblingInclusionReason.RetryAfterSkip], - changedByReason[HotReloadSiblingInclusionReason.Companion]); + changedByReason[HotReloadSiblingInclusionReason.Companion], + revertedSinceSkipPaths); } // Why the sources are added strongest first: ClassifyCandidate takes the first reason @@ -270,8 +290,10 @@ private static void ClassifyCandidate( string path, HotReloadNewSourceMembershipEvidence evidence, Func resolveWorkerSourcePath, + Func matchesVerifiedBaseline, List filesToInclude, - Dictionary> changedByReason) + Dictionary> changedByReason, + List revertedSinceSkipPaths) { if (expectedHashes.Count == 0) { @@ -299,7 +321,18 @@ private static void ClassifyCandidate( } } - changedByReason[expectedHashes[0].Reason].Add(path); + HotReloadSiblingInclusionReason strongest = expectedHashes[0].Reason; + // Why only a retry candidate: a file with live patches that went back to its compiled + // source still differs from what runs, so that warning stays. A skipped file that went + // back has nothing left to apply, and its record only exists to bring the edit back. + if (strongest == HotReloadSiblingInclusionReason.RetryAfterSkip + && matchesVerifiedBaseline(path, workerSourcePath)) + { + revertedSinceSkipPaths.Add(path); + return; + } + + changedByReason[strongest].Add(path); } private static bool ContainsPath(IReadOnlyCollection pathsAlreadyInRun, string path) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs index a5e81132ba..45d9568c04 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs @@ -20,11 +20,13 @@ public static HotReloadResponse Build( IReadOnlyList rewireFields, IReadOnlyList unrestoredWiredValues, bool isPlaying, - bool isPaused) + bool isPaused, + IReadOnlyList selectedFiles) { Debug.Assert(services != null, "services must not be null."); Debug.Assert(result != null, "result must not be null."); Debug.Assert(unrestoredWiredValues != null, "unrestoredWiredValues must not be null."); + Debug.Assert(selectedFiles != null, "selectedFiles must not be null."); Func toProjectRelativeScriptPath = path => HotReloadPatchTargetSupport.ToProjectRelativeScriptPath( @@ -34,12 +36,14 @@ public static HotReloadResponse Build( new HotReloadReappliedSiblingFiles(result.ReappliedSiblingPaths, toProjectRelativeScriptPath); List methods = new List(result.Methods.Count); bool hasFailure = false; + HotReloadFailureKinds failureKinds = HotReloadFailureKinds.None; for (int index = 0; index < result.Methods.Count; index++) { HotReloadMethodOutcome outcome = result.Methods[index]; if (outcome.Kind == HotReloadMethodOutcomeKind.Failed) { hasFailure = true; + failureKinds |= outcome.FailureKinds; } methods.Add( @@ -60,8 +64,13 @@ public static HotReloadResponse Build( bool hasMethodFailure = hasFailure; // Why folded in here: the type rows are a failure section of their own, and a run - // whose only failure was a refused declaration would otherwise answer Success. - hasFailure = hasFailure || HotReloadIntroducedTypeResponseSection.HoldsFailure(result.IntroducedTypes); + // whose only failure was a refused declaration would otherwise answer Success. A + // refused declaration is one the reader changes in the source. + if (HotReloadIntroducedTypeResponseSection.HoldsFailure(result.IntroducedTypes)) + { + hasFailure = true; + failureKinds |= HotReloadFailureKinds.Declaration; + } HotReloadResponseWarnings warnings = HotReloadApplyWarningsAssembler.Assemble( result, @@ -128,11 +137,29 @@ public static HotReloadResponse Build( result.PatchedTotal, CountAddedOutcomes(result), HotReloadIntroducedTypeResponseSection.CountIntroducedTypes(result.IntroducedTypes), - allRequestedSkipped), - Timing = ToTimingResponse(result.Timing) + allRequestedSkipped, + failureKinds), + Timing = ToTimingResponse(result.Timing), + RetryAfterEditorReady = HotReloadEditorReadyRetry.Decide(failureKinds), + SelectedFiles = ToProjectRelativePaths(selectedFiles, toProjectRelativeScriptPath) }; } + // Why normalized here: --files keeps each entry as the caller typed it, so the response + // names the scripts the same way whether they were given or chosen as the changed files. + private static string[] ToProjectRelativePaths( + IReadOnlyList paths, + Func toProjectRelativeScriptPath) + { + string[] projectRelativePaths = new string[paths.Count]; + for (int index = 0; index < paths.Count; index++) + { + projectRelativePaths[index] = toProjectRelativeScriptPath(paths[index]); + } + + return projectRelativePaths; + } + private static HotReloadTimingResponse ToTimingResponse(HotReloadTimingBreakdown timing) { if (timing == null) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs index 18b234d96d..4d66c3d967 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs @@ -24,12 +24,21 @@ internal sealed class HotReloadCallSiteScanResult public List Hits; public List MissingScanAssemblyNames; + /// + /// Diagnostic count of the call sites this scan compared against its targets, summed over + /// the scanned assemblies. Nothing in a run reads it; it shows that a scan visits only the + /// call sites filed under its targets rather than every call site of an assembly. + /// + public int ExaminedCallSiteCount; + public HotReloadCallSiteScanResult( List hits, - List missingScanAssemblyNames) + List missingScanAssemblyNames, + int examinedCallSiteCount = 0) { Hits = hits; MissingScanAssemblyNames = missingScanAssemblyNames; + ExaminedCallSiteCount = examinedCallSiteCount; } } @@ -97,6 +106,7 @@ public static HotReloadCallSiteScanResult FindCallSites( } HashSet scanAssemblyNames = CollectScanAssemblyNames(targets); + int examinedCallSiteCount = 0; foreach (string assemblyName in scanAssemblyNames) { string dllPath = Path.Combine( @@ -113,10 +123,10 @@ public static HotReloadCallSiteScanResult FindCallSites( continue; } - CollectHitsFromAssembly(assemblyName, dllPath, targets, hits); + examinedCallSiteCount += CollectHitsFromAssembly(assemblyName, dllPath, targets, hits); } - return new HotReloadCallSiteScanResult(hits, missingScanAssemblyNames); + return new HotReloadCallSiteScanResult(hits, missingScanAssemblyNames, examinedCallSiteCount); } private static HashSet CollectScanAssemblyNames(CompiledMethodIdentity[] targets) @@ -217,7 +227,8 @@ private static bool ReferencesAnyTargetDll( return false; } - private static void CollectHitsFromAssembly( + // Returns how many call sites of the assembly it compared against the targets. + private static int CollectHitsFromAssembly( string assemblyName, string dllPath, CompiledMethodIdentity[] targets, @@ -226,10 +237,40 @@ private static void CollectHitsFromAssembly( // Why cache: the dll only changes on a compile, which also reloads the domain, so // across the runs in between the Cecil read and the instruction walk are pure repeat work. HotReloadCompiledCallSiteCache.Entry compiled = HotReloadCompiledCallSiteCache.Shared.GetOrLoad(dllPath); - foreach (HotReloadCompiledCallSiteCache.CompiledCallSite callSite in compiled.CallSites) + List positions = CollectCandidatePositions(compiled, targets); + foreach (int position in positions) { - CollectHitFromCallSite(assemblyName, compiled, callSite, targets, hits); + CollectHitFromCallSite(assemblyName, compiled, compiled.CallSites[position], targets, hits); } + + return positions.Count; + } + + // Narrows the walk to the call sites filed under a target's type and method name; the + // full identity match still decides each of them. + private static List CollectCandidatePositions( + HotReloadCompiledCallSiteCache.Entry compiled, + CompiledMethodIdentity[] targets) + { + // Why skip a repeated key: targets that share a type and a name (overloads, arities) + // share one bucket, and visiting it twice would report its call sites twice. + HashSet<(string TypeName, string MethodName)> visitedKeys = new HashSet<(string TypeName, string MethodName)>(); + List positions = new List(); + foreach (CompiledMethodIdentity target in targets) + { + string typeName = target.TypeMetadataName.Value; + if (!visitedKeys.Add((typeName, target.MethodName))) + { + continue; + } + + positions.AddRange(compiled.LookupCallSiteIndices(typeName, target.MethodName)); + } + + // Why sort: buckets arrive in target order; ascending positions keep the hits in the + // order of the call sites in the dll, as the walk over every call site reported them. + positions.Sort(); + return positions; } private static void CollectHitFromCallSite( @@ -314,7 +355,7 @@ private static bool MatchesIdentity( return false; } - TypeReference openDeclaringType = GetOpenDeclaringType(openMethod.DeclaringType); + TypeReference openDeclaringType = HotReloadCompiledCallSiteIndex.GetOpenDeclaringType(openMethod.DeclaringType); if (!DeclaringTypeScopeMatchesTarget( openDeclaringType, target.AssemblyName, @@ -347,17 +388,6 @@ private static bool MatchesIdentity( return ParametersMatch(openMethod, target.ParameterTypeFullNames); } - private static TypeReference GetOpenDeclaringType(TypeReference declaringType) - { - GenericInstanceType genericInstance = declaringType as GenericInstanceType; - if (genericInstance != null) - { - return genericInstance.GetElementType(); - } - - return declaringType; - } - private static bool DeclaringTypeScopeMatchesTarget( TypeReference declaringType, string targetAssemblyName, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs index 2800b4e323..ae717ee7f4 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs @@ -8,7 +8,8 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Finds compilation-assembly sources whose on-disk bytes differ from the last compile snapshot. + /// Compares compilation-assembly sources with the last compile snapshot: finds the sources whose + /// on-disk bytes differ from it, and tells whether one source still holds its bytes. /// internal static class HotReloadChangedSiblingSourceDetector { @@ -112,6 +113,70 @@ internal static HotReloadChangedSourceScanResult DetectAllChangedFromSnapshotDir excludedProjectRelativePaths: null); } + /// + /// Whether holds the bytes the last compile snapshot keeps for + /// . A missing DLL, PDB, snapshot or source answers + /// false, so a file is never taken to be back at its compiled source without its snapshot. + /// + internal static bool SourceMatchesSnapshot( + string projectRoot, + string assemblyName, + string targetDllPath, + string projectRelativePath, + string sourcePath) + { + Debug.Assert(!string.IsNullOrEmpty(assemblyName), "assemblyName must not be null or empty."); + + // Why the same guards as Detect: without the DLL and its PDB there is no snapshot + // directory to name. + if (string.IsNullOrEmpty(targetDllPath) || !File.Exists(targetDllPath)) + { + return false; + } + + if (!File.Exists(Path.ChangeExtension(targetDllPath, ".pdb"))) + { + return false; + } + + return SourceMatchesSnapshotDirectory( + projectRoot, + assemblyName + "-" + HotReloadSourceSnapshotter.ReadAssemblyMvid(targetDllPath), + projectRelativePath, + sourcePath); + } + + // Why a directory-name entry: EditMode tests plant a snapshot tree without a real DLL. + internal static bool SourceMatchesSnapshotDirectory( + string projectRoot, + string assemblySnapshotDirectoryName, + string projectRelativePath, + string sourcePath) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert( + !string.IsNullOrEmpty(assemblySnapshotDirectoryName), + "assemblySnapshotDirectoryName must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(projectRelativePath), "projectRelativePath must not be null or empty."); + + if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)) + { + return false; + } + + string snapshotPath = Path.Combine( + projectRoot, + HotReloadConstants.SourceSnapshotRelativeDirectory, + assemblySnapshotDirectoryName, + HotReloadSourceSnapshotter.HashProjectRelativePath(projectRelativePath.Replace('\\', '/')) + ".cs"); + if (!File.Exists(snapshotPath)) + { + return false; + } + + return BytesEqual(File.ReadAllBytes(sourcePath), File.ReadAllBytes(snapshotPath)); + } + private static HotReloadChangedSourceScanResult DetectChangedFromSnapshotDirectory( string projectRoot, string assemblySnapshotDirectoryName, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteCache.cs index 716a6c1166..2e1fb85a91 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteCache.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteCache.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; using Mono.Cecil; using Mono.Cecil.Cil; @@ -10,17 +11,21 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Keeps the Cecil view of a compiled assembly (module, state-machine owner index, and the - /// call / ldftn instructions) alive across hot reload runs while the dll on disk is unchanged. + /// Keeps the Cecil view of a compiled assembly (module, state-machine owner index, the call / + /// ldftn instructions, and an index of them by the method they reference) alive across hot + /// reload runs while the dll on disk is unchanged. /// Why: every hot reload run re-read and re-walked the whole ScriptAssemblies dll to find /// callers (~0.24 s on a large test assembly), although the dll only changes on a compile, /// which also reloads the domain and therefore empties this cache. /// internal sealed class HotReloadCompiledCallSiteCache { - // Why a small cap: each entry holds a full Cecil module in memory. A hot reload run - // scans the target assembly plus the assemblies that reference it, which is a handful. - internal const int DefaultCapacity = 8; + // Why 64: an entry keeps the dll's bytes (InMemory) and its metadata only, because the + // methods' instruction lists are released once the call sites are collected. In a large + // project one run scanned the target assembly plus the 20 assemblies that reference it, + // and a cap of 8 made every scan read all 21 again. Within a run nothing is evicted + // whatever the cap (HoldEntriesForRun); the cap bounds what stays cached between runs. + internal const int DefaultCapacity = 64; /// /// One compiled instruction that may reference a target method. @@ -41,9 +46,10 @@ public CompiledCallSite(MethodDefinition caller, MethodReference operand, bool i /// /// The reusable Cecil view of one dll file, valid while matches the file. - /// An entry is returned outside the cache lock and may be disposed by a later lookup that - /// replaces or evicts it, so its lifetime relies on hot reload runs being single-flight: - /// a caller must finish with an entry before the next run starts. + /// An entry is returned outside the cache lock. While a hold from + /// is open no entry is evicted, so an entry is disposed only + /// when a later lookup finds its file changed and replaces it. An entry kept after the hold + /// ends, or used without one, may also be disposed by a later lookup that evicts it. /// internal sealed class Entry : IDisposable { @@ -55,13 +61,15 @@ internal sealed class Entry : IDisposable public long LastAccess; private readonly AssemblyDefinition _assembly; + private readonly HotReloadCompiledCallSiteIndex _callSiteIndex; public Entry( string dllPath, DllFingerprint fingerprint, AssemblyDefinition assembly, Dictionary logicalOwners, - List callSites) + List callSites, + HotReloadCompiledCallSiteIndex callSiteIndex) { DllPath = dllPath; Fingerprint = fingerprint; @@ -69,6 +77,17 @@ public Entry( Module = assembly.MainModule; LogicalOwners = logicalOwners; CallSites = callSites; + _callSiteIndex = callSiteIndex; + } + + /// + /// Positions in , in ascending order, of the call sites whose + /// operand's open declaring type is named + /// and whose method is named ; empty when there are none. + /// + public IReadOnlyList LookupCallSiteIndices(string openDeclaringTypeFullName, string methodName) + { + return _callSiteIndex.Lookup(openDeclaringTypeFullName, methodName); } public void Dispose() @@ -140,6 +159,8 @@ internal sealed class LoadProbes private readonly Dictionary _entries = new Dictionary(StringComparer.Ordinal); private long _accessSequence; private int _loadCount; + // Number of open holds; touched only under _gate. + private int _holdDepth; public HotReloadCompiledCallSiteCache(int capacity, LoadProbes probes = null) { @@ -213,12 +234,33 @@ public Entry GetOrLoad(string dllPath) Entry loaded = LoadConsistent(fullPath, fingerprint); loaded.LastAccess = _accessSequence; - EvictLeastRecentlyUsedWhileOverCapacity(); + // Why not while held: a run scans the same dlls once per caller it looks up, so an + // eviction inside the run turns one read per dll into one read per scan. + if (_holdDepth == 0) + { + EvictLeastRecentlyUsedWhileOverCapacity(); + } + _entries[fullPath] = loaded; return loaded; } } + /// + /// Keeps every entry until the returned hold is disposed, however many dlls are read in the + /// meantime; when the outermost hold ends, the least recently used entries are evicted down + /// to the capacity. Holds nest, and disposing one hold twice releases it once. + /// + public IDisposable HoldEntriesForRun() + { + lock (_gate) + { + _holdDepth++; + } + + return new Hold(this); + } + /// /// Drops every entry. Intended for tests and for callers that know the dlls changed. /// @@ -235,26 +277,85 @@ public void Clear() } } + // One open hold. Not tied to a thread: a run's using block may end on another thread + // after an await, so the state it changes lives in the cache, under _gate. + private sealed class Hold : IDisposable + { + private HotReloadCompiledCallSiteCache _owner; + + public Hold(HotReloadCompiledCallSiteCache owner) + { + _owner = owner; + } + + public void Dispose() + { + // Why Exchange: a second Dispose, even a concurrent one, must find no owner, or it + // would end an outer hold that is still open. + HotReloadCompiledCallSiteCache owner = Interlocked.Exchange(ref _owner, null); + if (owner == null) + { + return; + } + + owner.ReleaseHold(); + } + } + + private void ReleaseHold() + { + lock (_gate) + { + Debug.Assert(_holdDepth > 0, "ReleaseHold without a matching hold."); + _holdDepth--; + if (_holdDepth > 0) + { + return; + } + + EvictLeastRecentlyUsedDownToCapacity(); + } + } + private void EvictLeastRecentlyUsedWhileOverCapacity() { // The new entry is added after this call, so make room for it. while (_entries.Count >= _capacity) { - string leastRecentPath = null; - long leastRecentAccess = long.MaxValue; - foreach (KeyValuePair pair in _entries) + EvictEntry(FindLeastRecentlyUsedPath()); + } + } + + private void EvictLeastRecentlyUsedDownToCapacity() + { + // Nothing is added after this call, so keep entries up to the capacity itself. + while (_entries.Count > _capacity) + { + EvictEntry(FindLeastRecentlyUsedPath()); + } + } + + private string FindLeastRecentlyUsedPath() + { + string leastRecentPath = null; + long leastRecentAccess = long.MaxValue; + foreach (KeyValuePair pair in _entries) + { + if (pair.Value.LastAccess < leastRecentAccess) { - if (pair.Value.LastAccess < leastRecentAccess) - { - leastRecentAccess = pair.Value.LastAccess; - leastRecentPath = pair.Key; - } + leastRecentAccess = pair.Value.LastAccess; + leastRecentPath = pair.Key; } - - Entry evicted = _entries[leastRecentPath]; - _entries.Remove(leastRecentPath); - evicted.Dispose(); } + + return leastRecentPath; + } + + private void EvictEntry(string path) + { + Entry evicted = _entries[path]; + _entries.Remove(path); + evicted.Dispose(); } private static DllFingerprint ReadFingerprint(string fullPath) @@ -327,10 +428,12 @@ private Entry Load(string fullPath, DllFingerprint fingerprint) { TryIndexStateMachineOwner(method, logicalOwners); CollectCallSites(method, callSites); + ReleaseMethodBody(method); } } - Entry entry = new Entry(fullPath, fingerprint, assembly, logicalOwners, callSites); + HotReloadCompiledCallSiteIndex callSiteIndex = HotReloadCompiledCallSiteIndex.Build(callSites); + Entry entry = new Entry(fullPath, fingerprint, assembly, logicalOwners, callSites, callSiteIndex); ownershipTransferred = true; return entry; } @@ -367,6 +470,20 @@ private static void CollectCallSites(MethodDefinition method, List index) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteIndex.cs new file mode 100644 index 0000000000..242d5f2704 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteIndex.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; + +using Mono.Cecil; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Groups one compiled assembly's call sites by the method they reference, keyed by the + /// open declaring type's full name and the method name. Why: a caller scan looks for a few + /// methods, and comparing each of them with every call site of a large dll cost seconds per + /// run; the scan now compares only the call sites filed under its targets' keys. + /// + internal sealed class HotReloadCompiledCallSiteIndex + { + private readonly Dictionary> _positionsByKey; + + private HotReloadCompiledCallSiteIndex(Dictionary> positionsByKey) + { + _positionsByKey = positionsByKey; + } + + /// + /// Files every call site under its operand's key; each key's positions ascend. + /// + internal static HotReloadCompiledCallSiteIndex Build( + List callSites) + { + Dictionary> positionsByKey = new Dictionary>(StringComparer.Ordinal); + for (int position = 0; position < callSites.Count; position++) + { + MethodReference openMethod = callSites[position].Operand.GetElementMethod(); + // Why leave it out: a scan never matches an operand without a declaring type. + if (openMethod.DeclaringType == null) + { + continue; + } + + string key = BuildKey(GetOpenDeclaringType(openMethod.DeclaringType).FullName, openMethod.Name); + if (!positionsByKey.TryGetValue(key, out List positions)) + { + positions = new List(); + positionsByKey.Add(key, positions); + } + + positions.Add(position); + } + + return new HotReloadCompiledCallSiteIndex(positionsByKey); + } + + /// + /// Ascending positions of the call sites filed under the type and method name; empty when none. + /// + internal IReadOnlyList Lookup(string openDeclaringTypeFullName, string methodName) + { + if (_positionsByKey.TryGetValue(BuildKey(openDeclaringTypeFullName, methodName), out List positions)) + { + return positions; + } + + return Array.Empty(); + } + + /// + /// The generic type definition behind a constructed declaring type, so a call through + /// Host<int> is filed and matched under Host`1. The scan's identity match uses this + /// too, so the key and the match open a type the same way. + /// + internal static TypeReference GetOpenDeclaringType(TypeReference declaringType) + { + GenericInstanceType genericInstance = declaringType as GenericInstanceType; + if (genericInstance != null) + { + return genericInstance.GetElementType(); + } + + return declaringType; + } + + private static string BuildKey(string openDeclaringTypeFullName, string methodName) + { + return openDeclaringTypeFullName + "::" + methodName; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteIndex.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteIndex.cs.meta new file mode 100644 index 0000000000..06118466b6 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledCallSiteIndex.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3855e8a858e8f4977bf18c873675e13a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs index 5d2aa12e87..5636fe9b2e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs @@ -99,7 +99,8 @@ internal static HotReloadServices CreateServices( HotReloadFileEntryApplier fileEntryApplier = new HotReloadFileEntryApplier(domain, patcher); HotReloadEntryApplier entryApplier = new HotReloadEntryApplier(domain, patcher, fileEntryApplier); - TransformWorkerClient transformWorkerClient = new TransformWorkerClient(transformWorkerHost); + TransformWorkerClient transformWorkerClient = + new TransformWorkerClient(transformWorkerHost, packageRootCapture); HotReloadGroupStageCollaborators collaborators = new HotReloadGroupStageCollaborators( domain, patcher, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorReadyRetry.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorReadyRetry.cs new file mode 100644 index 0000000000..5ba643b267 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorReadyRetry.cs @@ -0,0 +1,20 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Decides whether an apply response asks the CLI to wait for the Editor to settle and apply + /// the same request again. + /// + internal static class HotReloadEditorReadyRetry + { + /// + /// True only when every failure of the run is the Editor compiling or importing. + /// is the union over the run's failed rows, so a run with no + /// failure passes None. A failure of any other kind, alone or mixed in, gives the same result + /// after a wait, and a failure that lost its kind is treated as one the reader fixes. + /// + internal static bool Decide(HotReloadFailureKinds failureKinds) + { + return failureKinds == HotReloadFailureKinds.EditorNotReady; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorReadyRetry.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorReadyRetry.cs.meta new file mode 100644 index 0000000000..f67fa8983c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorReadyRetry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7375da355101346d2a53b7029f82c87a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStateSnapshotCapture.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStateSnapshotCapture.cs index 7211fbd494..791828b95c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStateSnapshotCapture.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStateSnapshotCapture.cs @@ -20,22 +20,51 @@ internal HotReloadEditorStateSnapshot(bool isCompiling, bool isUpdating, bool sc internal bool ScriptCompilationFailed { get; } - /// The reason new-source membership cannot be trusted, or null when it can. - internal string GetNotReadyReason() + /// + /// Why new-source membership cannot be trusted, or null when it can. A compile or an import + /// in progress ends on its own, so it is . + /// The errors a failed compile left stay until the reader fixes them, so that state is a + /// . + /// + internal HotReloadFailureDescription GetNotReadyFailure() { if (IsCompiling) { - return "The Editor is compiling, so new source membership is not ready. Compile the project first and retry hot reload."; + return HotReloadFailureDescription.EditorNotReady( + "The Editor is compiling, so new source membership is not ready. Compile the project first and retry hot reload."); } if (IsUpdating) { - return "The Editor is importing assets, so new source membership is not ready. Wait for import to finish, then retry hot reload."; + return HotReloadFailureDescription.EditorNotReady( + "The Editor is importing assets, so new source membership is not ready. Wait for import to finish, then retry hot reload."); } if (ScriptCompilationFailed) { - return "The last script compilation failed, so new source membership cannot be verified. Fix the compile errors, compile the project, and retry hot reload."; + return HotReloadFailureDescription.Declaration( + "The last script compilation failed, so new source membership cannot be verified. Fix the compile errors, compile the project, and retry hot reload."); + } + + return null; + } + + /// + /// Why a request that arrives now is refused before the transform, or null when it is not. + /// Only a compile or an import in progress counts: both end on their own, so the CLI waits + /// and applies again. A failed last compile is not busy, because the loaded assemblies are + /// the last good build and waiting does not clear the errors. + /// + internal HotReloadFailureDescription GetBusyFailure() + { + if (IsCompiling) + { + return HotReloadFailureDescription.EditorNotReady(HotReloadConstants.EditorCompilingBeforeTransformReason); + } + + if (IsUpdating) + { + return HotReloadFailureDescription.EditorNotReady(HotReloadConstants.EditorImportingBeforeTransformReason); } return null; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupCommitBoundary.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupCommitBoundary.cs index 2fa9adc009..61285d5fdb 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupCommitBoundary.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupCommitBoundary.cs @@ -19,35 +19,41 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal static class HotReloadGroupCommitBoundary { /// - /// The reason this group must not be committed, or null when every recheck passed. + /// Why this group must not be committed, or null when every recheck passed. /// - internal static string DescribeStaleReason( + internal static HotReloadFailureDescription DescribeStaleFailure( HotReloadGroupStageCollaborators collaborators, HotReloadApplyContext context) { Debug.Assert(collaborators != null, "collaborators must not be null."); // The Editor may have started compiling or importing while the run awaited its worker. - string notReadyReason = + HotReloadFailureDescription notReadyFailure = collaborators.EditorStateSnapshotCapture.CaptureCurrent() - .GetNotReadyReason(); - if (notReadyReason != null) + .GetNotReadyFailure(); + if (notReadyFailure != null) { - return "The Editor became busy before the reload could be applied: " + notReadyReason; + return notReadyFailure.WithMessagePrefix( + "The Editor became busy before the reload could be applied: "); } - string targetAssemblyDrift = DescribeTargetAssemblyDrift(collaborators, context); + HotReloadFailureDescription targetAssemblyDrift = DescribeTargetAssemblyDrift(collaborators, context); if (targetAssemblyDrift != null) { return targetAssemblyDrift; } + // Why the two source drifts are a Declaration: the reader edited the source while the + // run was underway, so the next run has to take that edit in. string preparationDrift = DescribePreparationDrift(context); if (preparationDrift != null) { - return preparationDrift; + return HotReloadFailureDescription.Declaration(preparationDrift); } - return DescribeRequestSourceDrift(context.Files); + string requestSourceDrift = DescribeRequestSourceDrift(context.Files); + return requestSourceDrift == null + ? null + : HotReloadFailureDescription.Declaration(requestSourceDrift); } /// @@ -58,9 +64,10 @@ internal static string DescribeStaleReason( /// Why only for a run that commits types: an ordinary patch of a rebuilt assembly is /// already refused by the guard the patcher applies per method, while an artifact /// assembly carries the module version id of the generation it was compiled for and - /// stays active for the rest of the domain's life. + /// stays active for the rest of the domain's life. Why EditorNotReady: a compile during + /// the run rebuilt the assembly, so nothing in the source needs a change. /// - private static string DescribeTargetAssemblyDrift( + private static HotReloadFailureDescription DescribeTargetAssemblyDrift( HotReloadGroupStageCollaborators collaborators, HotReloadApplyContext context) { @@ -74,7 +81,8 @@ private static string DescribeTargetAssemblyDrift( string currentMvid = TryReadTargetAssemblyMvid(context.TargetDllPath); if (currentMvid == null) { - return "The target assembly could not be read again before the reload was applied."; + return HotReloadFailureDescription.EditorNotReady( + "The target assembly could not be read again before the reload was applied."); } if (string.Equals( @@ -85,8 +93,9 @@ private static string DescribeTargetAssemblyDrift( return null; } - return "The target assembly was rebuilt after this run read it, so the types it" - + " prepared belong to a generation the domain no longer has."; + return HotReloadFailureDescription.EditorNotReady( + "The target assembly was rebuilt after this run read it, so the types it" + + " prepared belong to a generation the domain no longer has."); } // Why a failed read is drift rather than a throw: the same rebuild this check exists for diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupNotices.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupNotices.cs index e74edf5e19..29587c8a2d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupNotices.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupNotices.cs @@ -33,9 +33,10 @@ internal static bool AppendSignatureChangeCoverageNotices( HotReloadGroupOutcomeRouter.AppendGroupFailure( context.Files, "(signature-change-gate)", - string.Format( - HotReloadConstants.SignatureChangeCoverageLostFailureFormat, - string.Join(", ", lostReplacementKeys))); + HotReloadFailureDescription.Declaration( + string.Format( + HotReloadConstants.SignatureChangeCoverageLostFailureFormat, + string.Join(", ", lostReplacementKeys)))); return false; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupOutcomeRouter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupOutcomeRouter.cs index dd88752796..05b17d118f 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupOutcomeRouter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupOutcomeRouter.cs @@ -58,14 +58,14 @@ internal static void AppendByFilePath( internal static void AppendGroupFailure( IReadOnlyList files, string methodLabel, - string reason) + HotReloadFailureDescription failure) { Debug.Assert(files != null && files.Count > 0, "A group must hold a file."); foreach (HotReloadGroupFile file in files) { file.Sinks.Outcomes.Add( - HotReloadMethodOutcome.Failed(methodLabel, reason, file.AssemblyResolvePath)); + HotReloadMethodOutcome.FailedBecause(methodLabel, failure, file.AssemblyResolvePath)); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs index 70790f6eb2..ff92ac41ed 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs @@ -117,7 +117,10 @@ internal async Task> ProcessGroupAsync } else { - HotReloadGroupOutcomeRouter.AppendGroupFailure(files, "(file)", preparation.ErrorMessage); + HotReloadGroupOutcomeRouter.AppendGroupFailure( + files, + "(file)", + HotReloadFailureDescription.Declaration(preparation.ErrorMessage)); } return _fileEntryApplier.BuildUnappliedGroupResults(files); @@ -283,7 +286,10 @@ private async Task RunWorkerAsync( private List FailGroup(IReadOnlyList files, string errorMessage) { - HotReloadGroupOutcomeRouter.AppendGroupFailure(files, "(file)", errorMessage); + HotReloadGroupOutcomeRouter.AppendGroupFailure( + files, + "(file)", + HotReloadFailureDescription.Declaration(errorMessage)); return _fileEntryApplier.BuildUnappliedGroupResults(files); } @@ -436,7 +442,7 @@ internal static async Task GateAndCompileAsy HotReloadGroupOutcomeRouter.AppendGroupFailure( files, "(signature-change-gate)", - gateResult.FailureMessage); + HotReloadFailureDescription.Declaration(gateResult.FailureMessage)); return HotReloadGroupGateAndCompileResult.Failed(); } @@ -495,11 +501,11 @@ internal async Task> CompleteApplyAfte } ct.ThrowIfCancellationRequested(); - string staleReason = - HotReloadGroupCommitBoundary.DescribeStaleReason(_collaborators, context); - if (staleReason != null) + HotReloadFailureDescription staleFailure = + HotReloadGroupCommitBoundary.DescribeStaleFailure(_collaborators, context); + if (staleFailure != null) { - HotReloadGroupOutcomeRouter.AppendGroupFailure(context.Files, "(file)", staleReason); + HotReloadGroupOutcomeRouter.AppendGroupFailure(context.Files, "(file)", staleFailure); return _fileEntryApplier.BuildUnappliedGroupResults(context.Files); } @@ -531,7 +537,7 @@ internal static bool TryAppendNewSourceMembershipFailure( HotReloadGroupStageCollaborators collaborators, IReadOnlyList files) { - string failure = + HotReloadFailureDescription failure = HotReloadNewSourceMembershipValidator.TryRevalidateFiles(collaborators, files); if (failure == null) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadMethodOutcome.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadMethodOutcome.cs index 39769db491..dda10102cd 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadMethodOutcome.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadMethodOutcome.cs @@ -20,6 +20,10 @@ internal sealed class HotReloadMethodOutcome // InvocationCount. Null for every other row. public HotReloadAddedMemberInfo AddedMember { get; } + // What a Failed row's failure was, which chooses the response's next step. None for every + // other row. + public HotReloadFailureKinds FailureKinds { get; } + private HotReloadMethodOutcome( HotReloadMethodOutcomeKind kind, string method, @@ -27,7 +31,8 @@ private HotReloadMethodOutcome( string filePath, string lifecycleNote, HotReloadWorkerReasonFacts workerReason = null, - HotReloadAddedMemberInfo addedMember = null) + HotReloadAddedMemberInfo addedMember = null, + HotReloadFailureKinds failureKinds = HotReloadFailureKinds.None) { Kind = kind; Method = method; @@ -36,6 +41,7 @@ private HotReloadMethodOutcome( LifecycleNote = lifecycleNote ?? string.Empty; WorkerReason = workerReason; AddedMember = addedMember; + FailureKinds = failureKinds; } public static HotReloadMethodOutcome Patched( @@ -68,7 +74,29 @@ public static HotReloadMethodOutcome Failed(string method, string reason, string method, reason, filePath, - string.Empty); + string.Empty, + failureKinds: HotReloadFailureKinds.Declaration); + } + + // A failure whose kinds were decided where it happened, such as an Editor that started + // compiling during the run, which the next step must not report as something to fix. + public static HotReloadMethodOutcome FailedBecause( + string method, + HotReloadFailureDescription failure, + string filePath) + { + if (failure == null) + { + throw new ArgumentNullException(nameof(failure)); + } + + return new HotReloadMethodOutcome( + HotReloadMethodOutcomeKind.Failed, + method, + failure.Message, + filePath, + string.Empty, + failureKinds: failure.Kinds); } public static HotReloadMethodOutcome Added( @@ -136,17 +164,41 @@ public static HotReloadMethodOutcome Stale(string method, string filePath) public HotReloadMethodOutcome WithLifecycleNote(string lifecycleNote) { - return new HotReloadMethodOutcome(Kind, Method, Reason, FilePath, lifecycleNote, WorkerReason, AddedMember); + return new HotReloadMethodOutcome( + Kind, + Method, + Reason, + FilePath, + lifecycleNote, + WorkerReason, + AddedMember, + FailureKinds); } public HotReloadMethodOutcome WithReason(string reason) { - return new HotReloadMethodOutcome(Kind, Method, reason, FilePath, LifecycleNote, WorkerReason, AddedMember); + return new HotReloadMethodOutcome( + Kind, + Method, + reason, + FilePath, + LifecycleNote, + WorkerReason, + AddedMember, + FailureKinds); } public HotReloadMethodOutcome WithWorkerReason(HotReloadWorkerReasonFacts workerReason) { - return new HotReloadMethodOutcome(Kind, Method, Reason, FilePath, LifecycleNote, workerReason, AddedMember); + return new HotReloadMethodOutcome( + Kind, + Method, + Reason, + FilePath, + LifecycleNote, + workerReason, + AddedMember, + FailureKinds); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs index 49c1975c13..14a3bb9987 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs @@ -15,6 +15,9 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class HotReloadNewSourceMembershipValidator { + private const string CompiledAssemblyChangedFailure = + "The compiled assembly changed while hot reload was preparing. Compile the project and retry hot reload."; + [Serializable] private sealed class AssemblyDefinitionJson { @@ -27,7 +30,7 @@ private sealed class AssemblyReferenceJson public string reference; } - internal static string TryCapture( + internal static HotReloadFailureDescription TryCapture( IHotReloadEditorStateSnapshotCapture editorStateSnapshotCapture, string projectRoot, string projectRelativePath, @@ -38,19 +41,22 @@ internal static string TryCapture( { Debug.Assert(editorStateSnapshotCapture != null, "editorStateSnapshotCapture must not be null."); evidence = null; - string notReadyReason = editorStateSnapshotCapture.CaptureCurrent().GetNotReadyReason(); - if (notReadyReason != null) + HotReloadFailureDescription notReadyFailure = + editorStateSnapshotCapture.CaptureCurrent().GetNotReadyFailure(); + if (notReadyFailure != null) { - return notReadyReason; + return notReadyFailure; } + // Why the failures below are a Declaration: none of them clears by waiting for the + // Editor, so they keep the advice every failure had before the kinds existed. string captureFailure = HotReloadNewSourceMembershipBoundaryCollector.TryCapture( projectRoot, projectRelativePath, out HotReloadNewSourceMembershipBoundary[] boundaries); if (captureFailure != null) { - return captureFailure; + return HotReloadFailureDescription.Declaration(captureFailure); } string resolvedAssemblyDefinitionPath = @@ -63,7 +69,7 @@ internal static string TryCapture( resolvedAssemblyDefinitionPath); if (resolutionFailure != null) { - return resolutionFailure; + return HotReloadFailureDescription.Declaration(resolutionFailure); } evidence = new HotReloadNewSourceMembershipEvidence( @@ -76,39 +82,46 @@ internal static string TryCapture( return null; } - internal static string TryRevalidate( + internal static HotReloadFailureDescription TryRevalidate( IHotReloadEditorStateSnapshotCapture editorStateSnapshotCapture, HotReloadNewSourceMembershipEvidence evidence) { Debug.Assert(editorStateSnapshotCapture != null, "editorStateSnapshotCapture must not be null."); Debug.Assert(evidence != null, "evidence must not be null."); - string notReadyReason = editorStateSnapshotCapture.CaptureCurrent().GetNotReadyReason(); - if (notReadyReason != null) + HotReloadFailureDescription notReadyFailure = + editorStateSnapshotCapture.CaptureCurrent().GetNotReadyFailure(); + if (notReadyFailure != null) { - return notReadyReason; + return notReadyFailure; } + return DescribeMembershipChange(evidence); + } + + // The way the new source's membership differs from the evidence captured for it, or null + // when it still matches. Why most changes are a Declaration: none of them clears by waiting + // for the Editor, so they keep the advice every failure had before the kinds existed. + private static HotReloadFailureDescription DescribeMembershipChange( + HotReloadNewSourceMembershipEvidence evidence) + { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); UnityCompilationAssembly compilationAssembly = FindCompilationAssembly(evidence.AssemblyName); if (compilationAssembly == null) { - return "The resolved assembly is no longer present in the compilation pipeline. Compile the project and retry hot reload."; + return HotReloadFailureDescription.Declaration( + "The resolved assembly is no longer present in the compilation pipeline. Compile the project and retry hot reload."); } string targetDllPath = Path.Combine( projectRoot, HotReloadConstants.ScriptAssembliesRelativeDirectory, evidence.AssemblyName + HotReloadConstants.CompiledAssemblyExtension); - if (!string.Equals(Path.GetFullPath(targetDllPath), evidence.TargetDllPath, StringComparison.Ordinal) - || !File.Exists(targetDllPath) - || !string.Equals( - HotReloadSourceSnapshotter.ReadAssemblyMvid(targetDllPath), - evidence.TargetDllMvid, - StringComparison.Ordinal) - || HotReloadPatchTargetSupport.CheckMvidGuard( - HotReloadTypeHome.ScriptAssemblies(evidence.AssemblyName, targetDllPath)) != null) + HotReloadFailureDescription compiledAssemblyChange = DescribeCompiledAssemblyChange( + evidence, + targetDllPath); + if (compiledAssemblyChange != null) { - return "The compiled assembly changed while hot reload was preparing. Compile the project and retry hot reload."; + return compiledAssemblyChange; } string captureFailure = HotReloadNewSourceMembershipBoundaryCollector.TryCapture( @@ -117,7 +130,7 @@ internal static string TryRevalidate( out HotReloadNewSourceMembershipBoundary[] currentBoundaries); if (captureFailure != null) { - return captureFailure; + return HotReloadFailureDescription.Declaration(captureFailure); } string resolvedAssemblyDefinitionPath = @@ -130,7 +143,7 @@ internal static string TryRevalidate( resolvedAssemblyDefinitionPath); if (resolutionFailure != null) { - return resolutionFailure; + return HotReloadFailureDescription.Declaration(resolutionFailure); } HotReloadNewSourceMembershipEvidence currentEvidence = new HotReloadNewSourceMembershipEvidence( @@ -142,13 +155,50 @@ internal static string TryRevalidate( currentBoundaries); if (!EvidenceMatches(evidence, currentEvidence)) { - return "Assembly definition membership changed while hot reload was preparing. Compile the project and retry hot reload."; + return HotReloadFailureDescription.Declaration( + "Assembly definition membership changed while hot reload was preparing. Compile the project and retry hot reload."); } return null; } - internal static string TryRevalidateFiles( + // How the compiled assembly differs from the one the evidence was captured against, or null + // when it is the same image and still the loaded one. + private static HotReloadFailureDescription DescribeCompiledAssemblyChange( + HotReloadNewSourceMembershipEvidence evidence, + string targetDllPath) + { + if (!string.Equals(Path.GetFullPath(targetDllPath), evidence.TargetDllPath, StringComparison.Ordinal) + || !File.Exists(targetDllPath)) + { + return HotReloadFailureDescription.Declaration(CompiledAssemblyChangedFailure); + } + + // Why EditorNotReady: a new image means a compile rebuilt the assembly during the run, + // which the commit boundary and the target-assembly drift check report the same way. + if (!string.Equals( + HotReloadSourceSnapshotter.ReadAssemblyMvid(targetDllPath), + evidence.TargetDllMvid, + StringComparison.Ordinal)) + { + return HotReloadFailureDescription.EditorNotReady(CompiledAssemblyChangedFailure); + } + + // Why the guard's kinds are kept: it already tells a replaced assembly, which waiting + // clears, from one that is not loaded, which it does not. + HotReloadFailureDescription mvidGuardFailure = HotReloadPatchTargetSupport.CheckMvidGuard( + HotReloadTypeHome.ScriptAssemblies(evidence.AssemblyName, targetDllPath)); + if (mvidGuardFailure == null) + { + return null; + } + + return mvidGuardFailure.Kinds == HotReloadFailureKinds.EditorNotReady + ? HotReloadFailureDescription.EditorNotReady(CompiledAssemblyChangedFailure) + : HotReloadFailureDescription.Declaration(CompiledAssemblyChangedFailure); + } + + internal static HotReloadFailureDescription TryRevalidateFiles( HotReloadGroupStageCollaborators collaborators, IReadOnlyList files) { @@ -162,7 +212,8 @@ internal static string TryRevalidateFiles( continue; } - string failure = TryRevalidate(collaborators.EditorStateSnapshotCapture, evidence); + HotReloadFailureDescription failure = + TryRevalidate(collaborators.EditorStateSnapshotCapture, evidence); if (failure != null) { return failure; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs index 4a6befbb81..cb3217fa31 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.IO; using System.Threading; @@ -76,6 +77,10 @@ public async Task RunAsync( string correlationId = VibeLogger.GenerateCorrelationId(); Stopwatch total = Stopwatch.StartNew(); + // Why the whole run: the signature-change gate during analysis and the caller notes at + // the end both scan callers through the shared cache, once per method they check, so + // one hold keeps every dll they read until the run ends. + using IDisposable callSiteCacheHold = HotReloadCompiledCallSiteCache.Shared.HoldEntriesForRun(); HotReloadRunTiming timing = new HotReloadRunTiming(); // CompilationPipeline / Application.dataPath require the Unity main thread, and the diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPackageRoots.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPackageRoots.cs index 500c68142b..6a49e85563 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPackageRoots.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPackageRoots.cs @@ -1,58 +1,22 @@ using System; using System.Collections.Generic; -using UnityEngine; - -using UpmPackageInfo = UnityEditor.PackageManager.PackageInfo; - namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { - /// - /// One package's physical folder and the virtual path Unity exposes it under. - /// - internal sealed class HotReloadPackageRoot - { - internal HotReloadPackageRoot(string resolvedPath, string assetPath) - { - Debug.Assert(!string.IsNullOrEmpty(resolvedPath), "resolvedPath must not be empty."); - Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty."); - ResolvedPath = resolvedPath; - AssetPath = assetPath; - } - - /// Absolute folder the package's files actually live in. - internal string ResolvedPath { get; } - - /// Virtual folder Unity's script APIs expect, in the form Packages/<package-id>. - internal string AssetPath { get; } - } - - /// - /// Captures the package folder mapping on the Unity main thread so path normalization can run - /// on the background threads the hot-reload run switches to. - /// - internal interface IHotReloadPackageRootCapture - { - /// Refreshes the mapping. Must be called from the Unity main thread. - void CaptureCurrent(); - - IReadOnlyList Current { get; } - } - /// /// The production capture: reads the packages Unity has registered, and holds the last /// mapping it read. /// internal sealed class HotReloadPackageRootCapture : IHotReloadPackageRootCapture { - private IReadOnlyList _current; + private IReadOnlyList _current; public void CaptureCurrent() { - _current = Capture(); + _current = ScriptPackageRoots.ReadCurrent(); } - public IReadOnlyList Current + public IReadOnlyList Current { get { @@ -70,22 +34,5 @@ public IReadOnlyList Current return _current; } } - - private static IReadOnlyList Capture() - { - UpmPackageInfo[] packages = UpmPackageInfo.GetAllRegisteredPackages(); - List roots = new List(packages.Length); - foreach (UpmPackageInfo package in packages) - { - if (string.IsNullOrEmpty(package.resolvedPath) || string.IsNullOrEmpty(package.assetPath)) - { - continue; - } - - roots.Add(new HotReloadPackageRoot(package.resolvedPath, package.assetPath)); - } - - return roots; - } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs index 8d03315cb3..ddb491c099 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs @@ -87,11 +87,11 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( projectRelativePath); if (isNewSource) { - string notReadyReason = - editorStateSnapshotCapture.CaptureCurrent().GetNotReadyReason(); - if (notReadyReason != null) + HotReloadFailureDescription notReadyFailure = + editorStateSnapshotCapture.CaptureCurrent().GetNotReadyFailure(); + if (notReadyFailure != null) { - outcomes.Add(HotReloadMethodOutcome.Failed("(file)", notReadyReason, assemblyResolvePath)); + outcomes.Add(HotReloadMethodOutcome.FailedBecause("(file)", notReadyFailure, assemblyResolvePath)); return HotReloadPatchTargetResolution.EarlyExit( new HotReloadFileProcessResult(outcomes, warnings, 0)); } @@ -103,7 +103,7 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( if (!File.Exists(home.DllPath)) { outcomes.Add( - HotReloadMethodOutcome.Failed( + HotReloadMethodOutcome.FailedBecause( "(file)", HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(projectRoot, home.DllPath), assemblyResolvePath)); @@ -111,10 +111,10 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( new HotReloadFileProcessResult(outcomes, warnings, 0)); } - string mvidGuardError = CheckMvidGuard(home); - if (mvidGuardError != null) + HotReloadFailureDescription mvidGuardFailure = CheckMvidGuard(home); + if (mvidGuardFailure != null) { - outcomes.Add(HotReloadMethodOutcome.Failed("(file)", mvidGuardError, assemblyResolvePath)); + outcomes.Add(HotReloadMethodOutcome.FailedBecause("(file)", mvidGuardFailure, assemblyResolvePath)); return HotReloadPatchTargetResolution.EarlyExit( new HotReloadFileProcessResult(outcomes, warnings, 0)); } @@ -122,7 +122,7 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( HotReloadNewSourceMembershipEvidence newSourceMembershipEvidence = null; if (isNewSource) { - string membershipFailure = HotReloadNewSourceMembershipValidator.TryCapture( + HotReloadFailureDescription membershipFailure = HotReloadNewSourceMembershipValidator.TryCapture( editorStateSnapshotCapture, projectRoot, projectRelativePath, @@ -132,7 +132,7 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( out newSourceMembershipEvidence); if (membershipFailure != null) { - outcomes.Add(HotReloadMethodOutcome.Failed("(file)", membershipFailure, assemblyResolvePath)); + outcomes.Add(HotReloadMethodOutcome.FailedBecause("(file)", membershipFailure, assemblyResolvePath)); return HotReloadPatchTargetResolution.EarlyExit( new HotReloadFileProcessResult(outcomes, warnings, 0)); } @@ -161,6 +161,14 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( projectRelativePath)); } + HotReloadFailureDescription busyFailure = DescribeBusyRefusal(editorStateSnapshotCapture, unchangedDecision); + if (busyFailure != null) + { + outcomes.Add(HotReloadMethodOutcome.FailedBecause("(file)", busyFailure, assemblyResolvePath)); + return HotReloadPatchTargetResolution.EarlyExit( + new HotReloadFileProcessResult(outcomes, warnings, 0)); + } + return HotReloadPatchTargetResolution.Resolved( projectRelativePath, assemblyName, @@ -171,6 +179,23 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( newSourceMembershipEvidence); } + // Why last and not for a short-circuited file: every earlier exit (assembly resolution, + // missing DLL, MVID guard, new-source membership) keeps its own, more specific + // classification, and a file whose patches are already active and unchanged has nothing + // to apply, so a compile in flight does not stop it. Why before the transform: a compile + // that is already running ends in a domain reload that would discard this reload anyway. + private static HotReloadFailureDescription DescribeBusyRefusal( + IHotReloadEditorStateSnapshotCapture editorStateSnapshotCapture, + HotReloadUnchangedSourceDecision unchangedDecision) + { + if (unchangedDecision == HotReloadUnchangedSourceDecision.ShortCircuited) + { + return null; + } + + return editorStateSnapshotCapture.CaptureCurrent().GetBusyFailure(); + } + private static UnityCompilationAssembly FindCompilationAssembly(string assemblyName) { foreach (UnityCompilationAssembly assembly in CompilationPipeline.GetAssemblies()) @@ -207,7 +232,10 @@ internal static string[] BuildAssemblySourcePaths(string projectRoot, string[] s return paths; } - internal static string CheckMvidGuard(HotReloadTypeHome home) + // Why a stale assembly is EditorNotReady: a compile or a domain reload replaced it during + // the run, so nothing in the source needs a change. An assembly that is not loaded stays a + // Declaration: what loads it is the reader's code path, which waiting does not run. + internal static HotReloadFailureDescription CheckMvidGuard(HotReloadTypeHome home) { Debug.Assert(home != null, "home must not be null."); @@ -219,12 +247,12 @@ internal static string CheckMvidGuard(HotReloadTypeHome home) HotReloadLoadedAssemblyState state = home.ResolveLoadedAssembly(compiledMvid).State; if (state == HotReloadLoadedAssemblyState.Stale) { - return HotReloadConstants.StaleAssemblyHint; + return HotReloadFailureDescription.EditorNotReady(HotReloadConstants.StaleAssemblyHint); } if (state == HotReloadLoadedAssemblyState.NotLoaded) { - return HotReloadConstants.AssemblyNotLoadedHint; + return HotReloadFailureDescription.Declaration(HotReloadConstants.AssemblyNotLoadedHint); } return null; @@ -238,19 +266,9 @@ internal static string ToProjectRelativeScriptPath( Debug.Assert(!string.IsNullOrEmpty(path), "path must not be empty."); string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - // Path.GetFullPath resolves a relative path against the current directory (the project - // root in the Editor) and turns a virtual Packages//... path into the physical - // folder behind it, which resolves to the wrong assembly. The captured package roots - // map that physical folder back to the virtual path Unity's script APIs expect. - string fullPath = Path.GetFullPath(path.Replace('\\', '/')); - StringComparison comparison = Application.platform == RuntimePlatform.WindowsEditor - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - return HotReloadScriptPathNormalizer.ToProjectRelative( - fullPath, - projectRoot, - packageRootCapture.Current, - comparison); + // The captured package roots map a package's physical folder back to the virtual path + // Unity's script APIs expect; the physical path would resolve to the wrong assembly. + return ScriptPathNormalizer.ToAssetPath(path, projectRoot, packageRootCapture.Current); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRecommendedNextAction.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRecommendedNextAction.cs index eb1e231caa..e719d30c34 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRecommendedNextAction.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRecommendedNextAction.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Diagnostics; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -12,7 +13,8 @@ internal static string Resolve( int patchedTotal, int addedCount, int introducedTypeCount, - bool allRequestedSkipped) + bool allRequestedSkipped, + HotReloadFailureKinds failureKinds) { Debug.Assert(patchedTotal >= 0, "patchedTotal must not be negative."); Debug.Assert(addedCount >= 0, "addedCount must not be negative."); @@ -27,14 +29,74 @@ internal static string Resolve( : string.Empty; } + Debug.Assert( + failureKinds != HotReloadFailureKinds.None, + "A failed run must report what kinds of failure it had."); + // Why the types count toward a partial apply: a type this run introduced stays // loaded whatever the methods did, so the run applied part of what was asked. - if (patchedTotal + addedCount + introducedTypeCount > 0) + bool applied = patchedTotal + addedCount + introducedTypeCount > 0; + List sentences = new List(); + string leadingSentence = ChooseLeadingSentence(failureKinds, applied); + if (leadingSentence != null) + { + sentences.Add(leadingSentence); + } + + // Why said apart: next to the fix advice, the rows that only need a rerun would + // otherwise read as more to fix. + if (NeedsAFix(failureKinds) && Has(failureKinds, HotReloadFailureKinds.EditorNotReady)) + { + sentences.Add(HotReloadConstants.EditorNotReadyAppendedRecommendedNextAction); + } + + if (Has(failureKinds, HotReloadFailureKinds.VirtualPlayer)) + { + sentences.Add(HotReloadConstants.VirtualPlayerRecommendedNextAction); + } + else if (Has(failureKinds, HotReloadFailureKinds.CompiledAssemblyMissing)) + { + sentences.Add(HotReloadConstants.CompiledAssemblyMissingRecommendedNextAction); + } + + return string.Join(" ", sentences); + } + + // Why a failure to fix leads: fixing it and rerunning is what the reader does first, and a + // busy Editor or a missing assembly only adds to that. Without one, the busy Editor's + // advice replaces the fix advice, because there is nothing to fix. + private static string ChooseLeadingSentence(HotReloadFailureKinds failureKinds, bool applied) + { + if (NeedsAFix(failureKinds)) + { + return applied + ? HotReloadConstants.PartialApplyRecommendedNextAction + : HotReloadConstants.FailedWithNoApplyRecommendedNextAction; + } + + if (Has(failureKinds, HotReloadFailureKinds.EditorNotReady)) { - return HotReloadConstants.PartialApplyRecommendedNextAction; + return applied + ? HotReloadConstants.EditorNotReadyAfterPartialApplyRecommendedNextAction + : HotReloadConstants.EditorNotReadyRecommendedNextAction; } - return HotReloadConstants.FailedWithNoApplyRecommendedNextAction; + // Why only after an apply: a missing assembly's own sentence says all there is to + // do, and the partial-apply sentence only adds the revert the applied patches allow. + return applied ? HotReloadConstants.PartialApplyRecommendedNextAction : null; + } + + // Why a failure without a kind counts as one to fix: that is the advice every failure got + // before the kinds existed, so a failure that lost its kind still gets a next action. + private static bool NeedsAFix(HotReloadFailureKinds failureKinds) + { + return failureKinds == HotReloadFailureKinds.None + || Has(failureKinds, HotReloadFailureKinds.Declaration); + } + + private static bool Has(HotReloadFailureKinds failureKinds, HotReloadFailureKinds kind) + { + return (failureKinds & kind) != 0; } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunAccumulator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunAccumulator.cs index 60f4ca5013..fd0754421e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunAccumulator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunAccumulator.cs @@ -109,6 +109,12 @@ public void NoteChangedCompanion(string projectRelativePath) _siblingLedgerUpdates.NoteChangedCompanion(projectRelativePath); } + /// Remembers a skipped file left out because it went back to its compiled source, so the ledgers forget it. + public void NoteRevertedRetry(string projectRelativePath) + { + _siblingLedgerUpdates.NoteRevertedRetry(projectRelativePath); + } + public HotReloadSiblingInclusionReason SiblingInclusionReasonOf(string projectRelativePath) { return _siblingLedgerUpdates.ReasonOf(projectRelativePath); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunSiblingLedgerUpdates.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunSiblingLedgerUpdates.cs index d1f36df5bf..96462a8dce 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunSiblingLedgerUpdates.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunSiblingLedgerUpdates.cs @@ -8,7 +8,8 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// /// Collects, over one run, why each sibling came back and what the run changes in the domain's /// sibling records: the companion files it was given, the files that left the companion ledger - /// by applying a change, and the retried files that lose their applied-source record. + /// by applying a change, the retried files that lose their applied-source record, and the files + /// back at their compiled source that lose both records. /// internal sealed class HotReloadRunSiblingLedgerUpdates { @@ -22,6 +23,7 @@ internal sealed class HotReloadRunSiblingLedgerUpdates private readonly List _appliedPaths = new List(); private readonly List _unappliedRetryPaths = new List(); private readonly List _changedCompanionPaths = new List(); + private readonly List _revertedRetryPaths = new List(); private readonly Dictionary _observedHashByPath; private bool _appliedToDomain; @@ -46,6 +48,16 @@ internal void NoteChangedCompanion(string projectRelativePath) _changedCompanionPaths.Add(projectRelativePath); } + /// + /// Takes in a file left Skipped or Failed that the run did not retry because its source went + /// back to the compiled source. + /// + internal void NoteRevertedRetry(string projectRelativePath) + { + Debug.Assert(!string.IsNullOrEmpty(projectRelativePath), "projectRelativePath must not be empty."); + _revertedRetryPaths.Add(projectRelativePath); + } + /// Why a sibling came back; a file the run was passed reads as active changes. internal HotReloadSiblingInclusionReason ReasonOf(string projectRelativePath) { @@ -110,6 +122,16 @@ internal void ApplyTo(HotReloadDomain domain) domain.CompanionSources.Remove(_changedCompanionPaths[index]); } + // Why a reverted file loses both records: its source is the compiled source itself, so + // there is no edit to bring back. The applied-source record only makes it a retry + // candidate and the companion record only brings back the edited source, so neither has + // a use left. + for (int index = 0; index < _revertedRetryPaths.Count; index++) + { + domain.AppliedSources.ClearAppliedSource(_revertedRetryPaths[index]); + domain.CompanionSources.Remove(_revertedRetryPaths[index]); + } + RecordCompanions(domain); // Why set only after every write: a write that throws leaves the records half written, // and DescribeAfterApply must not answer from them. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadScriptPathNormalizer.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadScriptPathNormalizer.cs deleted file mode 100644 index 79b5520b6a..0000000000 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadScriptPathNormalizer.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.Collections.Generic; - -using UnityEngine; - -namespace io.github.hatayama.UnityCliLoop.FirstPartyTools -{ - /// - /// Turns an absolute script path into the project-relative form that - /// CompilationPipeline.GetAssemblyNameFromScriptPath accepts. - /// - internal static class HotReloadScriptPathNormalizer - { - internal static string ToProjectRelative( - string fullPath, - string projectRoot, - IReadOnlyList packageRoots, - StringComparison comparison) - { - Debug.Assert(!string.IsNullOrEmpty(fullPath), "fullPath must not be empty."); - Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be empty."); - Debug.Assert(packageRoots != null, "packageRoots must not be null."); - - string normalized = fullPath.Replace('\\', '/'); - // Package folders are matched before the project root: an embedded package lives under - // the project root, and stripping the root would yield the physical Packages/ - // path, which resolves to the wrong assembly. - string packageRelative = TryMapToPackagePath(normalized, packageRoots, comparison); - if (packageRelative != null) - { - return packageRelative; - } - - string root = WithTrailingSlash(projectRoot.Replace('\\', '/')); - // Assets has no virtual mapping of its own, so it stays absolute and needs the root removed. - if (normalized.StartsWith(root, comparison)) - { - return normalized.Substring(root.Length); - } - - // Outside the project: returned as is so GetAssemblyNameFromScriptPath yields an empty - // name and the caller reports the existing "not part of any compiled assembly" failure. - return normalized; - } - - private static string TryMapToPackagePath( - string normalized, - IReadOnlyList packageRoots, - StringComparison comparison) - { - foreach (HotReloadPackageRoot packageRoot in packageRoots) - { - string resolvedRoot = WithTrailingSlash(packageRoot.ResolvedPath.Replace('\\', '/')); - if (!normalized.StartsWith(resolvedRoot, comparison)) - { - continue; - } - - string assetRoot = WithTrailingSlash(packageRoot.AssetPath.Replace('\\', '/')); - return assetRoot + normalized.Substring(resolvedRoot.Length); - } - - return null; - } - - private static string WithTrailingSlash(string path) - { - return path.EndsWith("/", StringComparison.Ordinal) ? path : path + "/"; - } - } -} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadShimFirstCompile.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadShimFirstCompile.cs index a7b1c1fc9a..5354bac353 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadShimFirstCompile.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadShimFirstCompile.cs @@ -105,7 +105,7 @@ private static async Task CompileShimForGroupAsync( HotReloadGroupOutcomeRouter.AppendGroupFailure( context.Files, "(file)", - shimReferencePaths.ErrorMessage); + HotReloadFailureDescription.Declaration(shimReferencePaths.ErrorMessage)); return HotReloadGroupCompileResult.Failed(); } @@ -185,10 +185,11 @@ private static void AppendUnattributableCompileFailure( HotReloadGroupOutcomeRouter.AppendGroupFailure( context.Files, failureMethodLabel, - HotReloadSkippedMemberCompileNote.AppendNotes( - compileResult.ErrorMessage, - fallbackErrorMessages, - context.CompileFailureNoteSources)); + HotReloadFailureDescription.Declaration( + HotReloadSkippedMemberCompileNote.AppendNotes( + compileResult.ErrorMessage, + fallbackErrorMessages, + context.CompileFailureNoteSources))); } private static HotReloadGroupCompileResult ResolveIsolatedEntriesToPatch( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingRebindReporter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingRebindReporter.cs index 761c19e98a..d8ba7aa4ae 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingRebindReporter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingRebindReporter.cs @@ -37,7 +37,13 @@ internal void AppendActiveSiblingsToGroup( path => inputFileResolver.ResolveSiblingWorkerSourcePath( path, firstFile.ProjectRoot, - contentPathOverrideByFile)); + contentPathOverrideByFile), + (path, workerSourcePath) => HotReloadChangedSiblingSourceDetector.SourceMatchesSnapshot( + firstFile.ProjectRoot, + firstFile.AssemblyName, + firstFile.TargetDllPath, + path, + workerSourcePath)); IReadOnlyList filesToInclude = rebind.FilesToInclude; for (int index = 0; index < filesToInclude.Count; index++) { @@ -63,6 +69,14 @@ internal void AppendActiveSiblingsToGroup( run.NoteChangedCompanion(changedCompanionPaths[index]); } + // Why no warning for these: a file back at its compiled source has no edit to pass, so + // telling the reader to pass it would send them after nothing. + IReadOnlyList revertedSinceSkipPaths = rebind.RevertedSinceSkipPaths; + for (int index = 0; index < revertedSinceSkipPaths.Count; index++) + { + run.NoteRevertedRetry(revertedSinceSkipPaths[index]); + } + AddChangedSinceApplyWarnings(firstFile, rebind); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs index 2509b40844..54cd817ecc 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs @@ -217,6 +217,20 @@ public bool ShouldSerializeRecommendedNextAction() public string CompileFallback { get; set; } = HotReloadCompileFallbackDecision.NotNeeded.ToString(); + /// + /// True when every failure of this apply run is the Editor compiling or importing. The CLI + /// then waits for the Editor to settle and applies the same request again in the same + /// command; false on status and revert runs and on every other failure. + /// + public bool RetryAfterEditorReady { get; set; } + + /// + /// The project-relative asset paths of the scripts this apply run selected, whether given + /// as Files or chosen as the changed files; the CLI sends them as Files when it applies the + /// same request again. Empty on status and revert runs. + /// + public string[] SelectedFiles { get; set; } = Array.Empty(); + /// /// Milliseconds the apply run spent per phase. Written on apply runs only; --status, /// --revert-all, and requests refused before the run leave it out. @@ -364,7 +378,8 @@ protected override async Task ExecuteAsync( rewireFields, unrestoredWiredValues, isPlaying, - EditorApplication.isPaused); + EditorApplication.isPaused, + selection.Files); ApplyCompileFallbackDecision( response, result, @@ -413,7 +428,7 @@ internal static HotReloadValidationFailure ValidateApplyParameters(HotReloadSche // Why this reads the services itself: the apply path above passes the services it read // at its own entry, and this shim exists only for callers that hold a result but not the - // run that produced it. + // run that produced it, so it has no selected files to name either. internal static HotReloadResponse BuildApplyResponse( HotReloadOrchestratorResult result, IReadOnlyList additionalWarnings = null) @@ -425,7 +440,8 @@ internal static HotReloadResponse BuildApplyResponse( Array.Empty(), Array.Empty(), isPlaying: false, - isPaused: false); + isPaused: false, + selectedFiles: Array.Empty()); } // Records the compile-fallback decision on an apply response. isPlaying and diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWorkerNoticeAppender.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWorkerNoticeAppender.cs index 2244f10b41..c78b0b6255 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWorkerNoticeAppender.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWorkerNoticeAppender.cs @@ -103,7 +103,7 @@ private static void AppendBaselineNotices( { warnings.Add( string.Format( - ChooseMissingBaselineWarningFormat(kind), + ChooseMissingBaselineWarningFormat(kind, snapshotMissReason), Path.GetFileName(projectRelativePath), assemblyName)); } @@ -139,7 +139,9 @@ private static HotReloadMissingBaselineKind ChooseMissingBaselineKind( : HotReloadMissingBaselineKind.NoVerifiedSourceSnapshot; } - private static string ChooseMissingBaselineWarningFormat(HotReloadMissingBaselineKind kind) + private static string ChooseMissingBaselineWarningFormat( + HotReloadMissingBaselineKind kind, + HotReloadSnapshotMissReason snapshotMissReason) { if (kind == HotReloadMissingBaselineKind.IntroducedType) { @@ -148,7 +150,31 @@ private static string ChooseMissingBaselineWarningFormat(HotReloadMissingBaselin return kind == HotReloadMissingBaselineKind.NoCompiledMethodBody ? HotReloadConstants.NoCompiledMethodBodyBaselineWarningFormat - : HotReloadConstants.NoVerifiedSourceSnapshotWarningFormat; + : ChooseNoVerifiedSourceSnapshotWarningFormat(snapshotMissReason); + } + + // Why the reason picks only this file's wording: the kind, and the summary a re-applied + // sibling gets, stay one per kind. A file warned about on its own can say why its snapshot + // is unusable, which tells the reader whether a moment's wait or a compile clears it. + private static string ChooseNoVerifiedSourceSnapshotWarningFormat( + HotReloadSnapshotMissReason snapshotMissReason) + { + if (snapshotMissReason == HotReloadSnapshotMissReason.HashMismatch) + { + return HotReloadConstants.NoVerifiedSourceSnapshotMismatchWarningFormat; + } + + if (snapshotMissReason == HotReloadSnapshotMissReason.NoCompiledAssembly) + { + return HotReloadConstants.NoVerifiedSourceSnapshotNoCompiledAssemblyWarningFormat; + } + + // Why no other reason comes here: a file with a verified snapshot gets no warning, and + // one the PDB lists no document for gets the no-method-body kind. + Debug.Assert( + snapshotMissReason == HotReloadSnapshotMissReason.NoSnapshotFile, + "Only NoSnapshotFile, HashMismatch, or NoCompiledAssembly may reach the no-verified-snapshot wording."); + return HotReloadConstants.NoVerifiedSourceSnapshotForCurrentBuildWarningFormat; } private static void AppendSkippedOutcomes( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs index 19f0b7eefc..2fa9235e00 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs @@ -448,6 +448,8 @@ private static List BuildShimReferencePaths( } string fullTarget = Path.GetFullPath(targetHome.DllPath); + // The name another assembly's InternalsVisibleTo grant names the edited assembly by. + string shimTargetAssemblyName = Path.GetFileNameWithoutExtension(fullTarget); foreach (string reference in compilationAssembly.allReferences) { if (string.IsNullOrEmpty(reference) || !File.Exists(reference)) @@ -466,7 +468,8 @@ private static List BuildShimReferencePaths( PublicizeProjectReference( fullReference, scriptAssembliesDirectory, - resolverSearchDirectories)); + resolverSearchDirectories, + shimTargetAssemblyName)); } return references; @@ -475,12 +478,16 @@ private static List BuildShimReferencePaths( /// /// Returns the reference a shim compile binds against for one of Unity's compile /// references: a publicized copy when the reference is a project assembly the shim may - /// need private members of, and the reference itself otherwise. + /// need non-public members of, and the reference itself otherwise. Unless the reference + /// grants its internals to the edited assembly, the copy keeps the reference's internal + /// types and its private and internal members as they are and publicizes its protected + /// ones, so the shim compile sees what the edited assembly's own compile saw. /// private static string PublicizeProjectReference( string fullReference, string scriptAssembliesDirectory, - IReadOnlyCollection resolverSearchDirectories) + IReadOnlyCollection resolverSearchDirectories, + string shimTargetAssemblyName) { string referenceFileName = Path.GetFileNameWithoutExtension(fullReference); if (!IsUnderDirectory(fullReference, scriptAssembliesDirectory) @@ -489,9 +496,10 @@ private static string PublicizeProjectReference( return fullReference; } - return ReferencePublicizer.GetOrCreatePublicizedCopy( + return ReferencePublicizer.GetOrCreateShimReferenceCopy( HotReloadTypeHome.ScriptAssemblies(referenceFileName, fullReference), - resolverSearchDirectories); + resolverSearchDirectories, + shimTargetAssemblyName); } private static bool IsUnderDirectory(string fullPath, string directoryPath) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs index b8242b7bad..5a373a3d17 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs @@ -19,6 +19,9 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class ReferencePublicizer { + private const string InternalsVisibleToAttributeFullName = + "System.Runtime.CompilerServices.InternalsVisibleToAttribute"; + /// /// Collects distinct directory paths of existing DLL references for Cecil /// search. Null @@ -65,8 +68,57 @@ public static string GetOrCreatePublicizedCopy( return GetOrCreateRewrittenCopy( home, resolverSearchDirectories, - HotReloadConstants.PublicizedRefsRelativeDirectory, - PublicizeType); + assemblyDefinition => WriteOrReuseRewrittenCopy( + assemblyDefinition, + HotReloadConstants.PublicizedRefsRelativeDirectory, + PublicizeType)); + } + + /// + /// Returns the path of a cached copy of 's image for a shim compile + /// of an edit in the assembly named , writing it + /// on first use. When the image grants that assembly its internals, this is the publicized + /// copy of . Otherwise the image's internal top-level + /// types and its private, internal and private protected members stay as they are, because + /// the edited assembly's own compile never saw them. The same preconditions on + /// apply. + /// + public static string GetOrCreateShimReferenceCopy( + HotReloadTypeHome home, + IReadOnlyCollection resolverSearchDirectories, + string shimTargetAssemblyName) + { + Debug.Assert( + !string.IsNullOrEmpty(shimTargetAssemblyName), + "shimTargetAssemblyName must not be null or empty."); + + return GetOrCreateRewrittenCopy( + home, + resolverSearchDirectories, + assemblyDefinition => GrantsInternalsTo(assemblyDefinition, shimTargetAssemblyName) + ? WriteOrReuseRewrittenCopy( + assemblyDefinition, + HotReloadConstants.PublicizedRefsRelativeDirectory, + PublicizeType) + : WriteOrReuseRewrittenCopy( + assemblyDefinition, + HotReloadConstants.PublicizedExternalRefsRelativeDirectory, + PublicizeTypeKeepingHiddenMembers)); + } + + /// + /// Returns the assembly simple name an InternalsVisibleTo argument grants internals to: + /// the text before the first comma (which starts an optional public key), trimmed. + /// + internal static string ParseFriendAssemblyName(string friendAssemblyName) + { + Debug.Assert(friendAssemblyName != null, "friendAssemblyName must not be null."); + + int commaIndex = friendAssemblyName.IndexOf(','); + string simpleName = commaIndex < 0 + ? friendAssemblyName + : friendAssemblyName.Substring(0, commaIndex); + return simpleName.Trim(); } internal static string GetOrCreateInternalsExposedCopy( @@ -76,15 +128,19 @@ internal static string GetOrCreateInternalsExposedCopy( return GetOrCreateRewrittenCopy( home, resolverSearchDirectories, - HotReloadConstants.InternalsExposedRefsRelativeDirectory, - ExposeInternalsOfType); + assemblyDefinition => WriteOrReuseRewrittenCopy( + assemblyDefinition, + HotReloadConstants.InternalsExposedRefsRelativeDirectory, + ExposeInternalsOfType)); } + // Reads home's image and lets writeOrReuseCopy pick and return the copy. Why the variant is + // picked after reading: the cache path needs the image's Mvid, so the image is read before + // any cache lookup anyway, and its own attributes decide the variant at no extra read. private static string GetOrCreateRewrittenCopy( HotReloadTypeHome home, IReadOnlyCollection resolverSearchDirectories, - string outputRelativeDirectory, - Action rewriteType) + Func writeOrReuseCopy) { Debug.Assert(home != null, "home must not be null."); Debug.Assert(home.IsPublicizable, "home must be publicizable."); @@ -106,7 +162,14 @@ private static string GetOrCreateRewrittenCopy( AssemblyResolver = assemblyResolver }; using AssemblyDefinition assemblyDefinition = AssemblyDefinition.ReadAssembly(fullSourceDllPath, readerParameters); + return writeOrReuseCopy(assemblyDefinition); + } + private static string WriteOrReuseRewrittenCopy( + AssemblyDefinition assemblyDefinition, + string outputRelativeDirectory, + Action rewriteType) + { string assemblyName = assemblyDefinition.Name.Name; string mvid = assemblyDefinition.MainModule.Mvid.ToString("N"); string outputDirectory = ResolveOutputDirectory(outputRelativeDirectory); @@ -321,6 +384,30 @@ private static void ExposeInternalsOfType(TypeDefinition type) } private static void PublicizeType(TypeDefinition type) + { + PublicizeTypeVisibility(type); + PublicizeMembers(type, keepHiddenMembers: false); + } + + // Why top-level internal types and hidden members stay as they are: the edited assembly's own + // compile never saw them, and a shim compile that does can find a call ambiguous, as when an + // internal type declares an extension method with the signature of a public one elsewhere, + // or bind it to a more specific private overload the compiled method never called. Nested + // types are still publicized: they are reached only through their enclosing type and cannot + // declare extension methods. + private static void PublicizeTypeKeepingHiddenMembers(TypeDefinition type) + { + if (!type.IsNested + && (type.Attributes & CecilTypeAttributes.VisibilityMask) == CecilTypeAttributes.NotPublic) + { + return; + } + + PublicizeTypeVisibility(type); + PublicizeMembers(type, keepHiddenMembers: true); + } + + private static void PublicizeTypeVisibility(TypeDefinition type) { // Preserve non-visibility flags (abstract, sealed, interface, …); only swap the // visibility bits so the rewrite stays a pure accessibility change. @@ -332,7 +419,13 @@ private static void PublicizeType(TypeDefinition type) { type.Attributes = (type.Attributes & ~CecilTypeAttributes.VisibilityMask) | CecilTypeAttributes.Public; } + } + // keepHiddenMembers leaves private, internal and private protected members as they are. + // Protected members are publicized either way: a shim calls a base type's protected members + // from outside the type hierarchy. + private static void PublicizeMembers(TypeDefinition type, bool keepHiddenMembers) + { foreach (FieldDefinition field in type.Fields) { // A field-like event's compiler-generated backing field shares the event's name. @@ -345,16 +438,69 @@ private static void PublicizeType(TypeDefinition type) continue; } + CecilFieldAttributes access = field.Attributes & CecilFieldAttributes.FieldAccessMask; + if (keepHiddenMembers && FieldAccessIsHiddenFromOtherAssemblies(access)) + { + continue; + } + field.Attributes = (field.Attributes & ~CecilFieldAttributes.FieldAccessMask) | CecilFieldAttributes.Public; } // Property/event accessors are MethodDefinitions on the type, so this loop covers them. foreach (MethodDefinition method in type.Methods) { + CecilMethodAttributes access = method.Attributes & CecilMethodAttributes.MemberAccessMask; + if (keepHiddenMembers && MethodAccessIsHiddenFromOtherAssemblies(access)) + { + continue; + } + method.Attributes = (method.Attributes & ~CecilMethodAttributes.MemberAccessMask) | CecilMethodAttributes.Public; } } + // Another assembly never reaches a private member, and reaches an internal or private + // protected member only through an InternalsVisibleTo grant. + private static bool FieldAccessIsHiddenFromOtherAssemblies(CecilFieldAttributes access) + { + return access == CecilFieldAttributes.Private + || access == CecilFieldAttributes.Assembly + || access == CecilFieldAttributes.FamANDAssem; + } + + private static bool MethodAccessIsHiddenFromOtherAssemblies(CecilMethodAttributes access) + { + return access == CecilMethodAttributes.Private + || access == CecilMethodAttributes.Assembly + || access == CecilMethodAttributes.FamANDAssem; + } + + // Why ignore case: the compiler matches assembly simple names without case when it honors + // InternalsVisibleTo. + private static bool GrantsInternalsTo(AssemblyDefinition assemblyDefinition, string targetAssemblyName) + { + foreach (CustomAttribute attribute in assemblyDefinition.CustomAttributes) + { + if (attribute.AttributeType.FullName != InternalsVisibleToAttributeFullName + || attribute.ConstructorArguments.Count == 0 + || !(attribute.ConstructorArguments[0].Value is string friendAssemblyName)) + { + continue; + } + + if (string.Equals( + ParseFriendAssemblyName(friendAssemblyName), + targetAssemblyName, + StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + private static bool HasEventNamed(TypeDefinition type, string fieldName) { foreach (EventDefinition eventDefinition in type.Events) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 8fd655d591..e277cf713f 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -29,6 +29,12 @@ internal static class HotReloadConstants // assembly happens to recompile. public const string PublicizedRefsRelativeDirectory = "Library/UloopHotReload/PublicizedRefs/fmt2"; + // Shim reference copies of an assembly that grants the edited assembly no internals. They + // keep the assembly's internal types and members internal, so they must not share a cache + // directory with the fully publicized copies of the same assembly name and Mvid. + // "fmt1" = generation of that rewrite rule, bumped the same way as PublicizedRefs. + public const string PublicizedExternalRefsRelativeDirectory = "Library/UloopHotReload/PublicizedExternalRefs/fmt1"; + // Artifact compilation must not inherit the shim cache's private-member visibility. public const string InternalsExposedRefsRelativeDirectory = "Library/UloopHotReload/InternalsExposedRefs/fmt1"; @@ -414,7 +420,7 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE "The target assembly is not currently loaded in this AppDomain. Ensure the code path " + "that loads it has run, then retry."; - // Format: file name, assembly name. Used instead of the warning below when the file + // Format: file name, assembly name. Used instead of the warnings below when the file // declares a type hot reload introduced: such a type is only in a byte-loaded artifact, // never in a compiled assembly, so no compile of this project could have produced the // baseline the other wording asks the reader to establish. Why the last sentence: the @@ -425,12 +431,32 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE + "type from its own recorded declaration. Any other type in this file has no baseline " + "either and is patched in full."; - // Format: file name, assembly name. Emitted per file when PDB-validated snapshot is absent. - public const string NoVerifiedSourceSnapshotWarningFormat = - "No verified source snapshot for {0} (assembly {1}); patching all methods. " - + "Run uloop compile to establish a baseline for edited-method detection."; - - // Format: file name, assembly name. Used instead of the warning above when the compiled + // Format: file name, assembly name. Emitted per file when no snapshot was written for the + // assembly generation the file was compiled into. Why both causes: the capture runs once + // after each compile, so a reload that arrives before it finds none, and so does a file + // created since the compile. A moment's wait clears the first, and a compile clears both. + public const string NoVerifiedSourceSnapshotForCurrentBuildWarningFormat = + "No verified source snapshot for {0} (assembly {1}) matches the current build; patching " + + "all methods. Hot reload captures the snapshot after each compile, so either the capture " + + "had not finished when this reload arrived or the file is new: run 'uloop compile', or " + + "rerun in a moment."; + + // Format: file name, assembly name. Used instead of the warning above when the snapshot's + // bytes do not match the checksum the compiled PDB recorded. Why "most often": a PDB + // document with no checksum, or with an algorithm the check does not support, reports the + // same reason, but a file saved between the compile and the capture is the usual cause. + public const string NoVerifiedSourceSnapshotMismatchWarningFormat = + "No verified source snapshot for {0} (assembly {1}): the snapshot does not match the " + + "compiled file (most often the file changed between the compile and the snapshot " + + "capture); patching all methods. Run 'uloop compile' to re-establish the baseline."; + + // Format: file name, assembly name. Used instead of the warnings above when the compiled + // assembly or its PDB is not on disk, so there is nothing to verify a snapshot against. + public const string NoVerifiedSourceSnapshotNoCompiledAssemblyWarningFormat = + "No verified source snapshot for {0} (assembly {1}): the compiled assembly or its PDB is " + + "missing; patching all methods. Run 'uloop compile'."; + + // Format: file name, assembly name. Used instead of the warnings above when the compiled // PDB lists no document for the file: a file whose code compiles to no method body (an // enum, an interface, fields only) is never recorded there, so no compile can produce the // baseline the other wording asks the reader to establish. @@ -440,7 +466,7 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE + "declare types without bodies."; // Format: file count, comma-separated project-relative paths. The per-kind summaries of the - // three warnings above for files a run only re-applied; see HotReloadSiblingBaselineNotices. + // warnings above for files a run only re-applied; see HotReloadSiblingBaselineNotices. public const string SiblingIntroducedTypeNoBaselineWarningFormat = "{0} re-applied sibling file(s) declare a type hot reload introduced, so they have no " + "compiled baseline until 'uloop compile': {1}. This is expected: hot reload tracks " @@ -665,6 +691,37 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string FailedWithNoApplyRecommendedNextAction = "Fix the failed declarations or methods and rerun, or run 'uloop compile'."; + // Why these replace the fix advice when every failure is of their kind: the failed rows + // name a compile or an import the Editor ran during the reload, and "Fix the failed + // declarations or methods" sends the reader looking for a mistake that is not there. + public const string EditorNotReadyRecommendedNextAction = + "The Editor compiled or imported while this reload ran, so there is nothing to fix in the source. Wait until 'uloop status' no longer reports compiling or importing, then rerun; or run 'uloop compile' to apply every edit."; + + // Why a sentence apart from the new-source ones: this refusal happens for any file the + // moment the request arrives, before the transform, and the CLI retries once the Editor + // settles. The ending matches the other Editor-state reasons. + public const string EditorCompilingBeforeTransformReason = + "The Editor is compiling, so the domain reload that follows would discard this reload. Wait for the compile to finish, then retry hot reload."; + + public const string EditorImportingBeforeTransformReason = + "The Editor is importing assets, so this reload cannot be applied until the import finishes. Wait for it, then retry hot reload."; + + public const string EditorNotReadyAfterPartialApplyRecommendedNextAction = + "Partially applied. The remaining files were not patched because the Editor compiled or imported while this reload ran: wait until 'uloop status' no longer reports that, then rerun; or run 'uloop compile' to apply every edit; or run 'uloop hot-reload --revert-all' to discard the applied patches."; + + // Appended after the fix advice when the run also failed for a reason the reader has to + // fix, so the rows that only need a rerun are not read as more to fix. + public const string EditorNotReadyAppendedRecommendedNextAction = + "Some files were not patched only because the Editor compiled or imported while this reload ran; those need no change, just a rerun."; + + // Why the main Editor's project: a Virtual Player loads the script assemblies of the main + // project, so only a compile there reaches it. + public const string VirtualPlayerRecommendedNextAction = + "This Editor is a Multiplayer Play Mode Virtual Player, which hot reload cannot patch. Run 'uloop compile' against the main Editor's project; the compiled result reaches this player. A patch applied in the main Editor does not."; + + public const string CompiledAssemblyMissingRecommendedNextAction = + "The compiled assembly for the file is missing, so there is nothing to fix in the source: run 'uloop compile'."; + // Why this replaces whatever next action the run chose: those all read "run 'uloop // compile'", and during play that is the one thing --compile-on-skip auto declined to do, // so the reader has to be told the choice was theirs and how to make it. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadFailureDescription.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadFailureDescription.cs new file mode 100644 index 0000000000..119f1e9bc0 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadFailureDescription.cs @@ -0,0 +1,55 @@ +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// A hot-reload failure's message together with the kinds of failure it reports, so the + /// response chooses its next step from what failed rather than from the message text. + /// + internal sealed class HotReloadFailureDescription + { + private HotReloadFailureDescription(string message, HotReloadFailureKinds kinds) + { + Debug.Assert(!string.IsNullOrEmpty(message), "message must not be null or empty."); + Debug.Assert(kinds != HotReloadFailureKinds.None, "A failure must have at least one kind."); + Message = message; + Kinds = kinds; + } + + internal string Message { get; } + + internal HotReloadFailureKinds Kinds { get; } + + /// A failure the reader fixes in the source or the request. + internal static HotReloadFailureDescription Declaration(string message) + { + return new HotReloadFailureDescription(message, HotReloadFailureKinds.Declaration); + } + + /// A failure caused only by the Editor compiling or importing during the run. + internal static HotReloadFailureDescription EditorNotReady(string message) + { + return new HotReloadFailureDescription(message, HotReloadFailureKinds.EditorNotReady); + } + + /// + /// A failure caused by a missing compiled assembly. A Virtual Player also gets + /// , because a compile in its own project + /// cannot produce the assembly. + /// + internal static HotReloadFailureDescription CompiledAssemblyMissing(string message, bool isVirtualPlayer) + { + HotReloadFailureKinds kinds = isVirtualPlayer + ? HotReloadFailureKinds.CompiledAssemblyMissing | HotReloadFailureKinds.VirtualPlayer + : HotReloadFailureKinds.CompiledAssemblyMissing; + return new HotReloadFailureDescription(message, kinds); + } + + /// The same failure, with put before its message. + internal HotReloadFailureDescription WithMessagePrefix(string prefix) + { + Debug.Assert(!string.IsNullOrEmpty(prefix), "prefix must not be null or empty."); + return new HotReloadFailureDescription(prefix + Message, Kinds); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadFailureDescription.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadFailureDescription.cs.meta new file mode 100644 index 0000000000..02ba5ba015 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadFailureDescription.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 777b5b88711b74632a812c069dc81252 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadFailureKinds.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadFailureKinds.cs new file mode 100644 index 0000000000..6861c90010 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadFailureKinds.cs @@ -0,0 +1,35 @@ +using System; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The kinds of failure a hot-reload run reported, so the next step it recommends can tell a + /// failure the reader has to fix from one that passes once the Editor settles or a compile runs. + /// + [Flags] + internal enum HotReloadFailureKinds + { + None = 0, + + /// + /// Something in the source or the request needs a change, or the failure has no more + /// specific kind. Every failure reported before these kinds existed is this one. + /// + Declaration = 1, + + /// + /// The Editor compiled or imported while the run was underway, so nothing in the source + /// needs a change. + /// + EditorNotReady = 2, + + /// The compiled assembly for the file is missing, and a compile produces it. + CompiledAssemblyMissing = 4, + + /// + /// The Editor is a Multiplayer Play Mode Virtual Player, which has no compiled assembly of + /// its own. Always set together with . + /// + VirtualPlayer = 8 + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadFailureKinds.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadFailureKinds.cs.meta new file mode 100644 index 0000000000..6695beed20 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadFailureKinds.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 637c14e290b154d10aa4e4d1380df0b5 +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 85e5985842..bcdafe33a7 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs @@ -1,4 +1,3 @@ -using System; using System.IO; using System.Linq; using System.Security.Cryptography; @@ -121,11 +120,20 @@ 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); + // Why the physical path here and the asset path for the snapshot file: the snapshot is keyed + // by the asset path Unity reports for the file, but the PDB records the path the compiler was + // given, which for an embedded or local package is the folder behind the virtual + // Packages/ path. Why the Package Manager is asked rather than the package roots a + // run captures: those live in the main hot-reload assembly, which this one cannot see, and + // every caller of the loader already runs on the Unity main thread the Package Manager + // requires: a run's group step, and the pause-point port, which the pause-point tools and a + // run's patch step call. + string pdbLookupPath = ScriptPackageRoots.ToPhysicalPath(projectRoot, slashNormalizedRelativePath); if (!documentIndex.TryFindDocument( targetDllPath, pdbPath, mvid, - slashNormalizedRelativePath, + pdbLookupPath, out HotReloadPdbDocument document)) { return HotReloadSnapshotMissReason.NoDocumentInPdb; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs index 6b9655ead5..53fd63a77e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs @@ -51,22 +51,29 @@ internal static bool IsVirtualPlayerProjectRoot(string projectRoot) return string.Equals(Path.GetFileName(libraryDirectory), LibraryDirectoryName, StringComparison.Ordinal); } - internal static string DescribeMissingCompiledAssembly(string projectRoot, string dllPath) + // Why the text and the kinds come from one decision: a reason that names a Virtual Player + // has to arrive with VirtualPlayer set, or the next step would tell the player to compile + // its own project, which has no assemblies to compile. + internal static HotReloadFailureDescription DescribeMissingCompiledAssembly(string projectRoot, string dllPath) { 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."; + return HotReloadFailureDescription.CompiledAssemblyMissing( + "Compiled assembly not found at '" + dllPath + "'. Compile the project first.", + isVirtualPlayer: false); } // Why a different reason: a Virtual Player has no compiled assemblies under its own // root, so "compile first" gives the same answer however many times it is followed. - return "Compiled assembly not found at '" + dllPath + "'. This Editor is a Multiplayer Play Mode " + return HotReloadFailureDescription.CompiledAssemblyMissing( + "Compiled assembly not found at '" + dllPath + "'. This Editor is a Multiplayer Play Mode " + "Virtual Player: it loads the script assemblies of the main Editor's project, so hot reload " + "cannot patch it yet. The edit reaches this player through a compile; a patch applied to the " - + "main Editor does not reach it."; + + "main Editor does not reach it.", + isVirtualPlayer: true); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs index df6cec0dd1..99ad93bd00 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs @@ -144,6 +144,8 @@ internal enum HotReloadWorkerReasonCode IntroducedTypeArtifactUnusable, IntroducedTypeInputsUnreadable, IntroducedTypeIdentityMismatch, - EditorIsolatedAddedMethodCaller + EditorIsolatedAddedMethodCaller, + MethodTransformUnpassedInternalMemberBareName, + MethodTransformUnpassedInternalMemberInsideClosure } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs index 2d77bcc13d..630ced6741 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs @@ -4,6 +4,18 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { internal static partial class HotReloadWorkerReasonText { + // Format: diagnostic, quoted type name, quoted member name. Why the member and its type lead: + // which member is out of reach is what the reader acts on, and the diagnostic follows as the + // evidence for it. + private const string UnpassedInternalMemberSentence = + "{2} is internal to {1}, whose source this reload was not given ({0}). 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."; + /// /// Adds the reasons an existing method body could not be transformed. /// @@ -55,18 +67,19 @@ private static void AddMethodTransformTemplates( + "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)); + // Why the three share every sentence but the last: the member, its type and the rule are + // the same whatever the use, and only the change that brings the use within reach differs. 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)); + Plain(UnpassedInternalMemberSentence, 3).EndingWith(CompileCallToAction)); + templates.Add( + HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberBareName, + Plain(UnpassedInternalMemberSentence, 3).EndingWith( + "Qualify the bare name with 'this.' or the type name, or run 'uloop compile'.")); + templates.Add( + HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberInsideClosure, + Plain(UnpassedInternalMemberSentence, 3).EndingWith( + "Move the use out of the lambda, local function or query, or run 'uloop compile'.")); templates.Add( HotReloadWorkerReasonCode.MethodTransformStructHost, Plain( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/IHotReloadPackageRootCapture.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/IHotReloadPackageRootCapture.cs new file mode 100644 index 0000000000..8659d0c49d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/IHotReloadPackageRootCapture.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Captures the package folder mapping on the Unity main thread so path normalization can run + /// on the background threads the hot-reload run switches to. + /// + internal interface IHotReloadPackageRootCapture + { + /// Refreshes the mapping. Must be called from the Unity main thread. + void CaptureCurrent(); + + IReadOnlyList Current { get; } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/IHotReloadPackageRootCapture.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/IHotReloadPackageRootCapture.cs.meta new file mode 100644 index 0000000000..baae9ec0c1 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/IHotReloadPackageRootCapture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 39d52bb50d6d646d3894fff984314d71 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerClient.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerClient.cs index 193e4018f1..3366284a51 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerClient.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerClient.cs @@ -16,11 +16,14 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal sealed class TransformWorkerClient { private readonly TransformWorkerHost _host; + private readonly IHotReloadPackageRootCapture _packageRootCapture; - internal TransformWorkerClient(TransformWorkerHost host) + internal TransformWorkerClient(TransformWorkerHost host, IHotReloadPackageRootCapture packageRootCapture) { Debug.Assert(host != null, "host must not be null."); + Debug.Assert(packageRootCapture != null, "packageRootCapture must not be null."); _host = host; + _packageRootCapture = packageRootCapture; } /// @@ -152,11 +155,11 @@ private async Task RunOneShotAsync( } } - private static TransformWorkerOutputInterpreter CreateOutputInterpreter() + private TransformWorkerOutputInterpreter CreateOutputInterpreter() { return new TransformWorkerOutputInterpreter( new TransformWorkerOutputValidator(), - new TransformWorkerCompiledTypeFileCompleter()); + new TransformWorkerCompiledTypeFileCompleter(_packageRootCapture)); } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerCompiledTypeFileCompleter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerCompiledTypeFileCompleter.cs index b60bae2e33..bbc5112e7e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerCompiledTypeFileCompleter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerCompiledTypeFileCompleter.cs @@ -5,6 +5,8 @@ using Mono.Cecil; using Mono.Cecil.Cil; +using Debug = UnityEngine.Debug; + namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// @@ -15,6 +17,14 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools // reads its PDB, while the reader needs a file it can pass to the next reload. internal sealed class TransformWorkerCompiledTypeFileCompleter { + private readonly IHotReloadPackageRootCapture _packageRootCapture; + + internal TransformWorkerCompiledTypeFileCompleter(IHotReloadPackageRootCapture packageRootCapture) + { + Debug.Assert(packageRootCapture != null, "packageRootCapture must not be null."); + _packageRootCapture = packageRootCapture; + } + internal void Complete(TransformWorkerInputDto input, TransformWorkerOutputDto output) { Dictionary> filesByType = null; @@ -38,7 +48,9 @@ internal void Complete(TransformWorkerInputDto input, TransformWorkerOutputDto o continue; } - filesByType ??= ReadDeclaringFiles(input.targetTypesAssemblyPath); + // Why the roots are read here: the read below swallows InvalidOperationException, + // which would hide a run that never captured them. + filesByType ??= ReadDeclaringFiles(input.targetTypesAssemblyPath, _packageRootCapture.Current); List resolvedFiles = new List(); List targets = CollectPassTargets(reason.typeMetadataNames, filesByType, resolvedFiles); reason.declaringFiles = resolvedFiles.ToArray(); @@ -101,7 +113,9 @@ private static string[] AppendPassTarget(string[] args, List targets) // Keyed by the metadata name Cecil reports, which is the form the worker sends, so a nested // type needs no conversion here. A type is missing when the assembly or its PDB is. - private static Dictionary> ReadDeclaringFiles(string dllPath) + private static Dictionary> ReadDeclaringFiles( + string dllPath, + IReadOnlyList packageRoots) { Dictionary> filesByType = new Dictionary>(StringComparer.Ordinal); string pdbPath = string.IsNullOrEmpty(dllPath) ? null : Path.ChangeExtension(dllPath, ".pdb"); @@ -114,7 +128,7 @@ private static Dictionary> ReadDeclaringFiles(string dllPat // types alone still tells the reader what to pass, so the reload must not fail on it. try { - ReadDocumentsInto(dllPath, pdbPath, filesByType); + ReadDocumentsInto(dllPath, pdbPath, packageRoots, filesByType); } // InvalidOperationException covers Cecil's SymbolsNotMatchingException, thrown when the // PDB beside the assembly belongs to another build of it. @@ -128,7 +142,11 @@ private static Dictionary> ReadDeclaringFiles(string dllPat return filesByType; } - private static void ReadDocumentsInto(string dllPath, string pdbPath, Dictionary> filesByType) + private static void ReadDocumentsInto( + string dllPath, + string pdbPath, + IReadOnlyList packageRoots, + Dictionary> filesByType) { using FileStream dllStream = File.Open(dllPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using FileStream pdbStream = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); @@ -142,11 +160,11 @@ private static void ReadDocumentsInto(string dllPath, string pdbPath, Dictionary using AssemblyDefinition assembly = AssemblyDefinition.ReadAssembly(dllStream, readerParameters); foreach (TypeDefinition type in assembly.MainModule.GetTypes()) { - filesByType[type.FullName] = CollectDocumentPaths(type); + filesByType[type.FullName] = CollectDocumentPaths(type, packageRoots); } } - private static List CollectDocumentPaths(TypeDefinition type) + private static List CollectDocumentPaths(TypeDefinition type, IReadOnlyList packageRoots) { List paths = new List(); foreach (MethodDefinition method in type.Methods) @@ -163,7 +181,7 @@ private static List CollectDocumentPaths(TypeDefinition type) continue; } - string path = ToProjectRelativePath(sequencePoint.Document.Url); + string path = ToProjectRelativePath(sequencePoint.Document.Url, packageRoots); if (!paths.Contains(path)) { paths.Add(path); @@ -176,19 +194,11 @@ private static List CollectDocumentPaths(TypeDefinition type) // Why the current directory: the Editor runs with the project root as its working // directory, and a rooted document path outside it is still worth showing as it is. - private static string ToProjectRelativePath(string documentUrl) + // Why the package roots: the PDB records a package script by the folder behind it, as + // ./Packages//... for an embedded package, while a reload takes its asset path. + private static string ToProjectRelativePath(string documentUrl, IReadOnlyList packageRoots) { - string path = HotReloadSourcePathNormalizer.ToForwardSlashes(documentUrl); - if (!Path.IsPathRooted(path)) - { - return path; - } - - string root = HotReloadSourcePathNormalizer.ToForwardSlashes(Directory.GetCurrentDirectory()).TrimEnd('/') + "/"; - StringComparison comparison = Path.DirectorySeparatorChar == '\\' - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - return path.StartsWith(root, comparison) ? path.Substring(root.Length) : path; + return ScriptPathNormalizer.ToAssetPath(documentUrl, Directory.GetCurrentDirectory(), packageRoots); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/UnityCLILoop.FirstPartyTools.HotReload.Shared.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/UnityCLILoop.FirstPartyTools.HotReload.Shared.Editor.asmdef index 67c760d6d3..78ec4fc964 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/UnityCLILoop.FirstPartyTools.HotReload.Shared.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/UnityCLILoop.FirstPartyTools.HotReload.Shared.Editor.asmdef @@ -3,7 +3,8 @@ "rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools", "references": [ "GUID:afe86dd49995e46baa33e099a4d2ee1d", - "GUID:fc3fd32eddbee40e39c2d76dc184957b" + "GUID:fc3fd32eddbee40e39c2d76dc184957b", + "GUID:24d448abf7c10486d9578018b3a3c54b" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md index 586599bc4e..249f1eb1b8 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md @@ -29,7 +29,7 @@ Returns JSON with: - `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`. +- `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. When it waited for the Editor — for another uloop command to finish before the first apply, or for the Editor to settle, whether or not a second apply followed — it adds `EditorReadyWaitMs`, the wall time of all such waits in this command; the Editor's phases are then the last apply's. 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) @@ -38,7 +38,10 @@ Returns JSON with: - `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`. 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. +- `RecommendedNextAction` (string): Present in three cases. (1) Any method or introduced-type outcome is `Failed`. A failure that needs a change in the source keeps the fix advice: a partial apply (some methods patched or added, or some types left active) says to fix and rerun, run `uloop compile`, or `uloop hot-reload --revert-all`; a failure with nothing applied says to fix and rerun or compile. When every failure came from the Editor compiling or importing while the reload ran, it says there is nothing to fix: wait until `uloop status` no longer reports that, then rerun, or run `uloop compile` (after a partial apply, `uloop hot-reload --revert-all` is offered as well); beside failures that need a fix, the fix advice comes first and adds that those rows need only a rerun. A missing compiled assembly adds that there is nothing to fix in the source and that `uloop compile` is the step; in a Multiplayer Play Mode Virtual Player, that compile has to run against the main Editor's project, because a patch applied in the main Editor does not reach the player. (2) Every method of the requested files was `Skipped`, which still answers `Success`: it points first at the fix each Skipped row's `Methods[].Reason` names and offers `uloop compile` as the alternative. (3) `CompileFallback` is `HeldForPlayMode` or `BlockedByPlayModeSetting`, whatever the outcomes: the reason no compile ran is appended after any advice from (1) or (2), and it opens by saying to do any fix a `Reason` names that needs no compile before compiling. Omitted otherwise. - `CompileFallback` (string, always present): whether the CLI should run a compile after this run — `NotNeeded`, `Requested`, `HeldForPlayMode` (edits stayed unapplied but the Editor is in Play Mode and `--compile-on-skip` is `auto`), `BlockedByPlayModeSetting` (`--compile-on-skip on` during Play Mode while Unity's "Script Changes While Playing" is "Recompile After Finished Playing", which refuses the compile; `RecommendedNextAction` says to stop Play Mode first), or `Disabled` (`--compile-on-skip off`). `--status`, `--revert-all` and validation failures answer `NotNeeded`. `Skipped` rows of a sibling pulled in to re-bind its active patches do not count as unapplied edits: they are not this run's edits, and their earlier patches stay active. Its `Failed` rows do count, because a failed reload reverts those patches. A sibling retried after an earlier Skip, or brought in as a companion, still counts. +- `RetryAfterEditorReady` (bool, apply runs only): `true` when every failure of the run is the Editor compiling or importing, whether the request arrived during the compile or the compile started before the reload could be applied. The CLI then waits for the Editor to settle (up to 10 minutes) and applies the same files again in the same command, so the response you read is the second apply's. `false` on every other failure, and on `--status` and `--revert-all`. +- `SelectedFiles` (string[], apply runs only): the project-relative asset paths of the scripts the run selected, whether given as `--files` or chosen as the changed files. The retry sends exactly these, as an explicit list, so a file the default selection would leave out is not left out on the second apply. Empty on `--status` and `--revert-all`. - `Compile` (object, present only when the CLI ran the fallback compile): the full `uloop compile` response; the top-level `Success` is then the compile's, and the command's exit code is the compile's. A successful compile drops `RecommendedNextAction`, sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, removes the hold sentence from `Message`, and ends `Message` with a sentence saying the compile succeeded; a failed one leaves `Outcome`, `AutoRefreshHeld`, and `Message` as the reload reported them, and sets `RecommendedNextAction` to the compile's own `NextActions` when it reports any, and otherwise to fixing `Compile.Errors`. - `CompileFallbackNote` (string, present only with `Compile`): why the compile ran and how it ended. When it succeeded, `Outcome` is `ReplacedByCompile`, `AutoRefreshHeld` is `false`, and `Message` no longer carries the hold sentence. +- `EditorReadyRetryNote` (string, added by the CLI): present when the command waited for the Editor before sending: because another uloop command held the Editor when the request arrived (the note names that command; `--status` and `--revert-all` wait the same way and carry the note, without `Timing`), or because `RetryAfterEditorReady` was `true`. It says what the command waited for and that every other field describes the answer that followed; or that the Editor did not settle within the wait, so the fields are the earlier apply's and no compile ran. When the command waited more than once, the note carries one sentence per wait in order. A second apply that is refused again is not retried a third time: its `CompileFallback` decides the compile as usual. 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 be4772af36..25e9f5d6b0 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 @@ -431,6 +431,8 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | Resolved assembly name is missing from CompilationPipeline | Per-file entry with `Method` = `(file)`; Unity may have mapped a not-yet-imported `.asmdef` onto a predefined assembly. Run `uloop compile` first | | Script is not in the last compiled assembly's source list and its assembly membership cannot be confirmed | Per-file entry with `Method` = `(file)`; a new file passed with `--files` is hot-reloadable when its membership in an existing, unchanged compiled assembly is confirmed (`.asmdef` / `.asmref` boundaries are checked when present; a predefined assembly with none also passes), but fails when the Editor is not ready or an `.asmdef` / `.asmref` on its path was added, deleted, or changed since the last import — run `uloop compile` first | | The Editor is a Multiplayer Play Mode Virtual Player | Per-file entry with `Method` = `(file)`; a Virtual Player has no compiled assemblies under its own project root, so hot reload cannot patch it yet. The edit reaches that player only through a compile: the CLI's compile fallback brings it in when `--compile-on-skip` lets the compile run, and `auto` holds it while that player is in Play Mode (`CompileFallback` is `HeldForPlayMode`). A patch applied to the main Editor does not reach Virtual Players (each is a separate Editor process) | +| The Editor is compiling or importing when the request arrives, or starts to before the reload is applied | Per-file entry with `Method` = `(file)`; nothing in the source needs a change. The response sets `RetryAfterEditorReady`, and the CLI waits for the Editor to settle (up to 10 minutes) and applies the same request again once; if Unity's own compile already took the edit in, the second apply reports `NothingToApply`. A second refusal falls through to `--compile-on-skip`. | +| Another uloop command still holds the Editor when the request arrives (for example a `uloop compile` sent a moment earlier) | The request is not run; the CLI does not retry against the busy Editor and does not bring it to the front. It waits for that command to finish and the Editor to be ready (up to 10 minutes), then sends the same request once. `EditorReadyRetryNote` names the command it waited for and `Timing.EditorReadyWaitMs` is the wait. A busy answer to that one request is reported as `UNITY_SERVER_BUSY` without another wait. While a cancelled `execute-dynamic-code` request still holds the Editor, the wait sends the request again every 5 seconds, because the Editor takes such a request's slot back only when another request arrives. When the command that ran was a compile that already took the edit in, give `--files`: with no files the Editor selects changed files anew, finds none, and fails validation instead of reporting `NothingToApply` | | Loaded assembly differs from the one on disk (pending compile) | Run `uloop compile` first, then retry | | 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~/UnpassedInternalMemberUse.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUse.cs index f32ae221f2..b1fa2a3f1a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUse.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUse.cs @@ -24,11 +24,18 @@ internal sealed class UnpassedInternalMemberUse private UnpassedInternalMemberUse( INamedTypeSymbol declaringType, + string memberName, + UnpassedInternalMemberUseForm form, + bool isOfAPatchableKind, bool canBePatchedInPlace, bool mayRunOutsideThePatchedMethod, bool isSimpleNameLookup) { + Debug.Assert(!string.IsNullOrEmpty(memberName), "memberName must not be null or empty."); DeclaringType = declaringType; + MemberName = memberName; + Form = form; + IsOfAPatchableKind = isOfAPatchableKind; CanBePatchedInPlace = canBePatchedInPlace; MayRunOutsideThePatchedMethod = mayRunOutsideThePatchedMethod; IsSimpleNameLookup = isSimpleNameLookup; @@ -37,6 +44,18 @@ private UnpassedInternalMemberUse( /// The compiled type that declares the member, read with every member visible. internal INamedTypeSymbol DeclaringType { get; } + /// The name of the internal member the use names. + internal string MemberName { get; } + + /// Where the use runs and how it is named. + internal UnpassedInternalMemberUseForm Form { get; } + + /// + /// True when the use is a field, a property or an invoked method, the kinds a partial type + /// patches in place once they have their receiver. + /// + internal bool IsOfAPatchableKind { get; } + /// True when the patched method itself runs the use, so a guard may let the body through. internal bool CanBePatchedInPlace { get; } @@ -102,21 +121,46 @@ internal static UnpassedInternalMemberUse FindOrNull( return null; } - bool mayRunOutsideThePatchedMethod = - RunsOutsideThePatchedMethod(name, bodyNode, methodDeclarationOrNull, decision) - || HasAClosureOverAnUnresolvedValue(bodyNode, semanticModel); + UseRunPlace runPlace = FindWhereTheUseMayRun(name, bodyNode, methodDeclarationOrNull, decision, semanticModel); + bool mayRunOutsideThePatchedMethod = runPlace != UseRunPlace.PatchedMethod; + bool isSimpleNameLookup = error.Id == SimpleNameLookupDiagnosticId; // 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 isOfAPatchableKind = IsPatchableKind(member, name); bool canBePatchedInPlace = hasReceiver - && IsPatchableKind(member, name) + && isOfAPatchableKind && !mayRunOutsideThePatchedMethod; return new UnpassedInternalMemberUse( member.ContainingType, + member.Name, + ChooseForm(runPlace, isSimpleNameLookup), + isOfAPatchableKind, canBePatchedInPlace, mayRunOutsideThePatchedMethod, - error.Id == SimpleNameLookupDiagnosticId); + isSimpleNameLookup); + } + + // Why this order: in a state machine or a delegating shim, neither moving the use nor + // qualifying it brings it within reach. Inside a closure, a qualified name still runs outside + // the patched method, so moving the use out is the change that helps. Qualifying a bare name + // helps only when nothing else keeps the use out. + private static UnpassedInternalMemberUseForm ChooseForm(UseRunPlace runPlace, bool isSimpleNameLookup) + { + if (runPlace == UseRunPlace.StateMachineOrShim) + { + return UnpassedInternalMemberUseForm.OutOfReach; + } + + if (runPlace == UseRunPlace.Closure) + { + return UnpassedInternalMemberUseForm.InsideClosure; + } + + return isSimpleNameLookup + ? UnpassedInternalMemberUseForm.BareName + : UnpassedInternalMemberUseForm.OutOfReach; } private static SimpleNameSyntax FindReportedNameOrNull(Diagnostic error, SyntaxNode bodyNode) @@ -267,23 +311,31 @@ private static bool IsInvoked(SimpleNameSyntax name) // 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( + // reported success. Only the statements copied into the patched method skip that check. Why a + // state machine or a shim is answered first: it keeps every use in the body out, closure or + // not, and the scan for a closure over an unresolved value then has nothing left to decide. + private static UseRunPlace FindWhereTheUseMayRun( SyntaxNode name, SyntaxNode bodyNode, MethodDeclarationSyntax methodDeclarationOrNull, - MethodTransformDecision decision) + MethodTransformDecision decision, + SemanticModel semanticModel) { - if (decision.UsesDelegation) + if (decision.UsesDelegation || MethodTransformDecider.IsAsyncOrIterator(methodDeclarationOrNull, bodyNode)) { - return true; + return UseRunPlace.StateMachineOrShim; } - if (MethodTransformDecider.IsAsyncOrIterator(methodDeclarationOrNull, bodyNode)) + if (IsInsideAClosure(name, bodyNode) || HasAClosureOverAnUnresolvedValue(bodyNode, semanticModel)) { - return true; + return UseRunPlace.Closure; } + return UseRunPlace.PatchedMethod; + } + + private static bool IsInsideAClosure(SyntaxNode name, SyntaxNode bodyNode) + { foreach (SyntaxNode closureBody in MethodTransformDecider.FindClosureBodies(bodyNode)) { if (closureBody.Span.Contains(name.Span)) @@ -317,4 +369,14 @@ private static bool HasAClosureOverAnUnresolvedValue(SyntaxNode bodyNode, Semant return false; } + + // Where a use may run: in the patched method's own statements, in a state machine or a + // delegating shim, or in a closure, counting a closure that works with a value the worker could + // not resolve and so may reach the member through it. + private enum UseRunPlace + { + PatchedMethod, + StateMachineOrShim, + Closure + } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUseForm.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUseForm.cs new file mode 100644 index 0000000000..d5786e43e6 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUseForm.cs @@ -0,0 +1,21 @@ +/// +/// Where a use of an internal member of an unpassed type runs and how it is named. The guard reads it, +/// with its own reach rules, to choose the change the skip reason ends with. +/// +internal enum UnpassedInternalMemberUseForm +{ + /// + /// A use in an async or iterator state machine or a delegating shim, where neither moving it nor + /// qualifying it brings it in, or a use named with its receiver in the method's own statements. + /// + OutOfReach, + + /// A bare name in the method's own statements. + BareName, + + /// + /// A use inside a lambda, local function or query, or next to one that works with a value the + /// worker could not resolve, which runs outside the patched method however it is qualified. + /// + InsideClosure +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnresolvedBodyNameGuard.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnresolvedBodyNameGuard.cs index 9dee9ab115..70e9af4f05 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnresolvedBodyNameGuard.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnresolvedBodyNameGuard.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -87,14 +88,37 @@ internal static WorkerReason DescribeSkipOrNull( } return WorkerReason.Of( - HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberOutOfReach, + ChooseReasonCode(use, isPartial), diagnosticText, - "'" + use.DeclaringType.Name + "'"); + "'" + use.DeclaringType.Name + "'", + "'" + use.MemberName + "'"); } return null; } + // The reason whose last sentence names the change that brings this use within reach. Why only a + // partial type checks the kind of a bare name: qualifying brings a partial type's use in only for + // a field, a property or an invoked method, while a plain type emits a qualified method passed as + // a delegate or a qualified event subscription as well, as IsWithinReach lets them through. + private static HotReloadWorkerReasonCode ChooseReasonCode(UnpassedInternalMemberUse use, bool isPartial) + { + switch (use.Form) + { + case UnpassedInternalMemberUseForm.BareName: + return !isPartial || use.IsOfAPatchableKind + ? HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberBareName + : HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberOutOfReach; + case UnpassedInternalMemberUseForm.InsideClosure: + return HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberInsideClosure; + case UnpassedInternalMemberUseForm.OutOfReach: + return HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberOutOfReach; + default: + Debug.Assert(false, "Unknown internal-member use form: " + use.Form); + return HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberOutOfReach; + } + } + // 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, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnsupportedMemberSkipCollector.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnsupportedMemberSkipCollector.cs index 849a68637d..2efd486ebc 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnsupportedMemberSkipCollector.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnsupportedMemberSkipCollector.cs @@ -21,7 +21,9 @@ internal static class UnsupportedMemberSkipCollector // What: reports each property/indexer accessor that has an explicit body as Skipped. // Auto-properties ({ get; set; }) have no body and are not listed. // When a verified snapshot declares an equivalent property/indexer, skip rows are omitted - // (unchanged accessors must not appear as Skipped noise). + // (unchanged accessors must not appear as Skipped noise). A property is also compared one + // accessor at a time, so a set or init body equal to the snapshot's is omitted even when + // another part of the property changed. internal static void AppendExplicitAccessorSkips( string sourceProjectRelativePath, TypeDeclarationSyntax typeDeclaration, @@ -49,14 +51,12 @@ internal static void AppendExplicitAccessorSkips( string propertyKey = WorkerSyntaxIndex.BuildSyntaxPropertyKey(typeMetadataNameFromSyntax, propertyDeclaration); // Why plainCurrentPropertyMap: annotated property nodes break AreEquivalent the // same way annotated method bodies do; compare unannotated peers only. + PropertyDeclarationSyntax snapshotProperty = null; + PropertyDeclarationSyntax plainProperty = null; if (snapshotPropertyMap != null && plainCurrentPropertyMap != null - && snapshotPropertyMap.TryGetValue( - propertyKey, - out PropertyDeclarationSyntax snapshotProperty) - && plainCurrentPropertyMap.TryGetValue( - propertyKey, - out PropertyDeclarationSyntax plainProperty) + && snapshotPropertyMap.TryGetValue(propertyKey, out snapshotProperty) + && plainCurrentPropertyMap.TryGetValue(propertyKey, out plainProperty) && SyntaxFactory.AreEquivalent(snapshotProperty, plainProperty, topLevel: false)) { continue; @@ -68,7 +68,9 @@ internal static void AppendExplicitAccessorSkips( skipped, typeMetadataNameFromSyntax, snapshotPropertyMap, - addedMethodCatalog); + addedMethodCatalog, + snapshotProperty, + plainProperty); continue; } @@ -94,20 +96,27 @@ internal static void AppendExplicitAccessorSkips( skipped, typeMetadataNameFromSyntax, snapshotPropertyMap, - addedMethodCatalog); + addedMethodCatalog, + snapshotProperty: null, + plainCurrentProperty: null); } } StampSourceProjectRelativePath(skipped, firstAppendedIndex, sourceProjectRelativePath); } + // snapshotProperty and plainCurrentProperty are the snapshot's and the unannotated current + // tree's declarations of this property, or null when either side has none; an accessor is + // compared against the snapshot only when both are present. internal static void AppendExplicitAccessorSkipsForProperty( BasePropertyDeclarationSyntax propertyDeclaration, IPropertySymbol propertySymbol, List skipped, string typeMetadataNameFromSyntax, Dictionary snapshotPropertyMap, - AddedMethodCatalog addedMethodCatalog) + AddedMethodCatalog addedMethodCatalog, + PropertyDeclarationSyntax snapshotProperty, + PropertyDeclarationSyntax plainCurrentProperty) { if (propertySymbol == null) { @@ -127,8 +136,40 @@ internal static void AppendExplicitAccessorSkipsForProperty( return; } + bool emittedSkip = AppendChangedSetOrInitAccessorSkips( + propertyDeclaration.AccessorList, + propertySymbol, + skipped, + snapshotProperty, + plainCurrentProperty); + + PropertyDeclarationSyntax namedProperty = propertyDeclaration as PropertyDeclarationSyntax; + if (!emittedSkip + || namedProperty == null + || snapshotPropertyMap == null + || addedMethodCatalog == null) + { + return; + } + + string propertyKey = WorkerSyntaxIndex.BuildSyntaxPropertyKey(typeMetadataNameFromSyntax, namedProperty); + if (!snapshotPropertyMap.ContainsKey(propertyKey)) + { + addedMethodCatalog.AddAddedPropertySyntaxKey(propertyKey); + } + } + + // Reports each set or init accessor that has a body and differs from the snapshot, and returns + // whether it reported any. + private static bool AppendChangedSetOrInitAccessorSkips( + AccessorListSyntax accessorList, + IPropertySymbol propertySymbol, + List skipped, + PropertyDeclarationSyntax snapshotProperty, + PropertyDeclarationSyntax plainCurrentProperty) + { bool emittedSkip = false; - foreach (AccessorDeclarationSyntax accessor in propertyDeclaration.AccessorList.Accessors) + foreach (AccessorDeclarationSyntax accessor in accessorList.Accessors) { if (accessor.IsKind(SyntaxKind.GetAccessorDeclaration)) { @@ -141,6 +182,15 @@ internal static void AppendExplicitAccessorSkipsForProperty( continue; } + // Why per accessor: a getter-only edit used to skip the setter as well, because the + // caller compared the whole property declaration. + if (snapshotProperty != null + && plainCurrentProperty != null + && PropertyAccessorUnchanged(snapshotProperty, plainCurrentProperty, accessor.Kind())) + { + continue; + } + IMethodSymbol accessorMethod = ResolveAccessorMethodSymbol(propertySymbol, accessor.Kind()); if (accessorMethod == null) { @@ -155,20 +205,41 @@ internal static void AppendExplicitAccessorSkipsForProperty( emittedSkip = true; } - PropertyDeclarationSyntax namedProperty = propertyDeclaration as PropertyDeclarationSyntax; - if (!emittedSkip - || namedProperty == null - || snapshotPropertyMap == null - || addedMethodCatalog == null) + return emittedSkip; + } + + // Why match by kind: a set accessor that became init changed its declaration, so it finds no + // peer of its kind in the snapshot and stays Skipped. + private static bool PropertyAccessorUnchanged( + PropertyDeclarationSyntax snapshotProperty, + PropertyDeclarationSyntax currentProperty, + SyntaxKind accessorKind) + { + AccessorDeclarationSyntax snapshotAccessor = FindPropertyAccessor(snapshotProperty, accessorKind); + AccessorDeclarationSyntax currentAccessor = FindPropertyAccessor(currentProperty, accessorKind); + return snapshotAccessor != null + && currentAccessor != null + && SyntaxFactory.AreEquivalent(snapshotAccessor, currentAccessor, topLevel: false); + } + + private static AccessorDeclarationSyntax FindPropertyAccessor( + PropertyDeclarationSyntax propertyDeclaration, + SyntaxKind accessorKind) + { + if (propertyDeclaration.AccessorList == null) { - return; + return null; } - string propertyKey = WorkerSyntaxIndex.BuildSyntaxPropertyKey(typeMetadataNameFromSyntax, namedProperty); - if (!snapshotPropertyMap.ContainsKey(propertyKey)) + foreach (AccessorDeclarationSyntax accessor in propertyDeclaration.AccessorList.Accessors) { - addedMethodCatalog.AddAddedPropertySyntaxKey(propertyKey); + if (accessor.Kind() == accessorKind) + { + return accessor; + } } + + return null; } internal static void AppendIndexerExplicitAccessorSkips( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/UnityCLILoop.FirstPartyTools.HotReload.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/HotReload/UnityCLILoop.FirstPartyTools.HotReload.Editor.asmdef index f0fe7635f1..641c99af21 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/UnityCLILoop.FirstPartyTools.HotReload.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/HotReload/UnityCLILoop.FirstPartyTools.HotReload.Editor.asmdef @@ -7,7 +7,8 @@ "GUID:527f26a36b5043c2bd4d4036d04cd76d", "GUID:0b83f8a313f54fdea6fcf945d7fd0615", "GUID:384dc39e13234d8aae3b1b1c44c9507e", - "GUID:1e3293e9639242278dcb27724b34beb4" + "GUID:1e3293e9639242278dcb27724b34beb4", + "GUID:24d448abf7c10486d9578018b3a3c54b" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineResolver.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineResolver.cs index 2606357806..7d513969e6 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineResolver.cs @@ -221,6 +221,8 @@ private static PausePointEditedLineMap BuildMapOrNull(string normalizedFile) return null; } + // Read by the asset path even for a package script: the Editor resolves a registered package's + // Packages//... path to its folder in file IO, as the source snapshot capture does. string editedFilePath = Path.Combine(UnityCliLoopPathResolver.GetProjectRoot(), normalizedFile); if (!File.Exists(editedFilePath)) { diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointLineTextReader.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointLineTextReader.cs index bffccc6f37..98816440ff 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointLineTextReader.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointLineTextReader.cs @@ -18,6 +18,8 @@ public static string ReadResolvedLineText(string requestedFile, int resolvedLine } string normalizedFile = SourcePausePointPathNormalizer.ToForwardSlashes(requestedFile); + // Read by the asset path even for a package script: the Editor resolves a registered package's + // Packages//... path to its folder in file IO, as the source snapshot capture does. string absoluteFilePath = Path.Combine(UnityCliLoopPathResolver.GetProjectRoot(), normalizedFile); return SourcePausePointSourceLineReader.ReadLineText(absoluteFilePath, resolvedLine, resolvedEndLine); } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointResolveFailureResponse.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointResolveFailureResponse.cs index 2617f61003..8f7f4525d2 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointResolveFailureResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointResolveFailureResponse.cs @@ -24,7 +24,7 @@ internal static PausePointResponse Create( bool declaresIntroducedType = HotReloadPausePointCoordination.HotReloadSide?.IsIntroducedTypeSourceFile(normalizedFile) == true; if (declaresIntroducedType - && SourcePausePointResolver.FindNamedCompiledMethodSpansInFile(parameters.File).Count == 0) + && SourcePausePointResolver.FindNamedCompiledMethodSpansInFile(normalizedFile).Count == 0) { return CreateIntroducedTypeResolveFailure(parameters); } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs index 1c7d16c5a6..ff068cdc38 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs @@ -165,8 +165,14 @@ public PausePointResponse Clear(ClearPausePointSchema parameters) "Pass --id with the id returned by enable-pause-point, or use --all to clear every marker."); } + // The CLI turns --file/--line into an id built from the path as typed. + string id = SourcePausePointId.ToMarkerId( + parameters.Id, + UnityCliLoopPathResolver.GetProjectRoot(), + UloopPausePointRegistry.Contains, + ScriptPackageRoots.ReadCurrent); (UloopPausePointSnapshot snapshot, bool resumedFromPause, int clearedCount) = - UloopPausePointRegistry.Clear(parameters.Id); + UloopPausePointRegistry.Clear(id); PausePointUseCaseLogger.LogCleared(snapshot.Id, snapshot.StatusBeforeClear); if (snapshot.StatusBeforeClear == UloopPausePointStatus.Expired) { @@ -214,8 +220,17 @@ private static PausePointResponse EnableBySourceLocation( SourcePausePointConstants.ReleaseCodeOptimizationRecommendedNextAction); } - string normalizedFile = SourcePausePointPathNormalizer.ToForwardSlashes(parameters.File); - string id = BuildSourcePausePointId(parameters.File, parameters.Line); + // Normalized once, to the asset path every later step looks the file up by: the compiled + // assembly, the hot-reload ledger, and the source snapshot know a package script only by + // its Packages//... path, and CompilationPipeline places an absolute or + // ./-prefixed Assets path in the wrong assembly. + string normalizedFile = ScriptPathNormalizer.ToAssetPath( + parameters.File, + UnityCliLoopPathResolver.GetProjectRoot(), + ScriptPackageRoots.ReadCurrent()); + // The requested line rather than the resolved one, so repeated calls at the same + // requested location stay idempotent whichever path form names the file. + string id = SourcePausePointId.Build(normalizedFile, parameters.Line); SourcePausePointSnapshotTiming snapshotTiming = ParseSnapshotTiming(parameters.SnapshotTiming); PausePointHotReloadFileState fileState = PausePointHotReloadFileState.Read(normalizedFile); @@ -267,6 +282,7 @@ private static PausePointResponse EnableBySourceLocation( return FinishEnableBySourceLocation( id, + normalizedFile, parameters, hitWhen, hitWhenCondition, @@ -332,6 +348,7 @@ private static PausePointResponse EnableOnHotReloadShimOrNull( // end line distinct from the hit line. Edited method span is passed separately. return FinishEnableBySourceLocation( id, + normalizedFile, parameters, hitWhen, hitWhenCondition, @@ -436,6 +453,7 @@ private static void ApplyPersistRequest(string registryId, EnablePausePointSchem private static PausePointResponse FinishEnableBySourceLocation( string id, + string normalizedFile, EnablePausePointSchema parameters, string hitWhen, UloopPausePointHitWhenCondition hitWhenCondition, @@ -474,7 +492,7 @@ private static PausePointResponse FinishEnableBySourceLocation( // file shows whatever statement drifted onto it. The disk read spans // resolvedLine..resolvedEndLine so a rounded-forward multi-line statement keeps its full text. string resolvedLineText = lineBasis == "EditedFile" - ? PausePointLineTextReader.ReadResolvedLineText(parameters.File, resolvedLine, resolvedEndLine) + ? PausePointLineTextReader.ReadResolvedLineText(normalizedFile, resolvedLine, resolvedEndLine) : string.Empty; UloopPausePointRegistry.SetResolvedLine(id, resolvedLine, resolvedLineText); UloopPausePointRegistry.SetNotCapturableVariables(id, notCapturableVariables); @@ -537,12 +555,5 @@ private static PausePointResponse FinishEnableBySourceLocation( return response; } - - // The derived id must use the originally requested file/line (not the resolved/rounded - // line) so repeated calls at the same requested location stay idempotent. - private static string BuildSourcePausePointId(string file, int line) - { - return SourcePausePointPathNormalizer.ToForwardSlashes(file) + ":" + line; - } } } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledAssemblyLocator.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledAssemblyLocator.cs index e4cdfdf53b..c4510adec9 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledAssemblyLocator.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledAssemblyLocator.cs @@ -12,10 +12,14 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal sealed class SourcePausePointCompiledAssemblyLocation { - // All three are empty unless Found. + // All four are empty unless Found. internal string AssemblyName { get; } internal string AssemblyPath { get; } internal string SymbolsPath { get; } + // The script's path in the form the PDB records it: for a package script, the file behind + // its virtual path, project-relative or absolute when the package lives outside the project; + // for an Assets script, the located path itself. + internal string PhysicalPath { get; } internal SourcePausePointResolveFailureReason FailureReason { get; } internal string FailureMessage { get; } internal bool Found => FailureReason == SourcePausePointResolveFailureReason.None; @@ -24,12 +28,14 @@ private SourcePausePointCompiledAssemblyLocation( string assemblyName, string assemblyPath, string symbolsPath, + string physicalPath, SourcePausePointResolveFailureReason failureReason, string failureMessage) { AssemblyName = assemblyName; AssemblyPath = assemblyPath; SymbolsPath = symbolsPath; + PhysicalPath = physicalPath; FailureReason = failureReason; FailureMessage = failureMessage; } @@ -37,15 +43,18 @@ private SourcePausePointCompiledAssemblyLocation( internal static SourcePausePointCompiledAssemblyLocation FoundAt( string assemblyName, string assemblyPath, - string symbolsPath) + string symbolsPath, + string physicalPath) { Debug.Assert( !string.IsNullOrEmpty(assemblyName) && !string.IsNullOrEmpty(assemblyPath) && !string.IsNullOrEmpty(symbolsPath), "a found location must carry the assembly name and both paths."); + Debug.Assert(!string.IsNullOrEmpty(physicalPath), "a found location must carry the script's physical path."); return new SourcePausePointCompiledAssemblyLocation( assemblyName, assemblyPath, symbolsPath, + physicalPath, SourcePausePointResolveFailureReason.None, string.Empty); } @@ -61,6 +70,7 @@ internal static SourcePausePointCompiledAssemblyLocation NotFound( string.Empty, string.Empty, string.Empty, + string.Empty, failureReason, failureMessage); } @@ -72,6 +82,12 @@ internal static SourcePausePointCompiledAssemblyLocation NotFound( /// internal static class SourcePausePointCompiledAssemblyLocator { + /// + /// Locates a script named by its asset path (Assets/... or Packages/<package-id>/...), + /// the only form CompilationPipeline maps to an assembly. Callers normalize any other form + /// first; the folder path behind a package is reported as belonging to no assembly. + /// Must be called on the Unity main thread. + /// internal static SourcePausePointCompiledAssemblyLocation Locate(string projectRelativeFilePath) { Debug.Assert(!string.IsNullOrEmpty(projectRelativeFilePath), "projectRelativeFilePath must not be null or empty."); @@ -113,7 +129,10 @@ internal static SourcePausePointCompiledAssemblyLocation Locate(string projectRe $"Debug symbols not found at '{pdbPath}'. Ensure the project uses Debug code optimization."); } - return SourcePausePointCompiledAssemblyLocation.FoundAt(assemblyName, dllPath, pdbPath); + // Asked only once the assembly is found, so a script that is not compiled never reaches + // the Package Manager. + string physicalPath = ScriptPackageRoots.ToPhysicalPath(projectRoot, normalizedInputPath); + return SourcePausePointCompiledAssemblyLocation.FoundAt(assemblyName, dllPath, pdbPath, physicalPath); } } } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs index 1a2f3fb460..92d1429c1d 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs @@ -26,8 +26,6 @@ public static SourcePausePointResolveResult Resolve( Debug.Assert(!string.IsNullOrEmpty(projectRelativeFilePath), "projectRelativeFilePath must not be null or empty."); Debug.Assert(line > 0, "line must be a positive 1-based line number."); - string normalizedInputPath = SourcePausePointPathNormalizer.ToForwardSlashes(projectRelativeFilePath); - SourcePausePointCompiledAssemblyLocation location = SourcePausePointCompiledAssemblyLocator.Locate(projectRelativeFilePath); if (!location.Found) @@ -35,11 +33,13 @@ public static SourcePausePointResolveResult Resolve( return SourcePausePointResolveResult.Failure(location.FailureReason, location.FailureMessage); } + // Matched by the path the PDB records, which for a package script is the file behind its + // asset path rather than the asset path itself. return ResolveFromCompiledAssembly( location.AssemblyName, location.AssemblyPath, location.SymbolsPath, - normalizedInputPath, + location.PhysicalPath, projectRelativeFilePath, line, methodFilter, @@ -335,7 +335,6 @@ private static TResult WithCompiledModuleOrDefault( { Debug.Assert(read != null, "read must not be null."); - string normalizedInputPath = SourcePausePointPathNormalizer.ToForwardSlashes(projectRelativeFilePath); SourcePausePointCompiledAssemblyLocation location = SourcePausePointCompiledAssemblyLocator.Locate(projectRelativeFilePath); if (!location.Found) @@ -354,7 +353,8 @@ private static TResult WithCompiledModuleOrDefault( }; using AssemblyDefinition assemblyDefinition = AssemblyDefinition.ReadAssembly(dllStream, readerParameters); - return read(assemblyDefinition.MainModule, normalizedInputPath); + // The same PDB-recorded path as Resolve matches by. + return read(assemblyDefinition.MainModule, location.PhysicalPath); } // Why a separate walk from FindClosestSequencePointOnOrAfterLine: that search only diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef index 7586068b4a..d515b5c54a 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef @@ -3,7 +3,8 @@ "rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools", "references": [ "GUID:fc3fd32eddbee40e39c2d76dc184957b", - "GUID:527f26a36b5043c2bd4d4036d04cd76d" + "GUID:527f26a36b5043c2bd4d4036d04cd76d", + "GUID:24d448abf7c10486d9578018b3a3c54b" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs index c33f567ddc..ded8388e27 100644 --- a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using io.github.hatayama.UnityCliLoop.FirstPartyTools; using io.github.hatayama.UnityCliLoop.Runtime; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -25,7 +26,7 @@ internal static class PausePointStatusBridgeCommand public static PausePointStatusResponse Execute(JToken paramsToken) { - string id = ReadId(paramsToken); + string id = ReadMarkerId(paramsToken); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus(id); return PausePointStatusResponse.FromSnapshot(snapshot); } @@ -64,7 +65,7 @@ private static IReadOnlyList BuildDomainReloadRearmReportOrNull() // slow multi-step CLI round trip does not expire before the await itself observes a hit. public static PausePointStatusResponse Extend(JToken paramsToken) { - string id = ReadId(paramsToken); + string id = ReadMarkerId(paramsToken); int minimumRemainingSeconds = ReadMinimumRemainingSeconds(paramsToken); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.ExtendExpiryForAwait(id, minimumRemainingSeconds); return PausePointStatusResponse.FromSnapshot(snapshot); @@ -72,7 +73,7 @@ public static PausePointStatusResponse Extend(JToken paramsToken) public static PausePointStatusResponse Clear(JToken paramsToken) { - string id = ReadId(paramsToken); + string id = ReadMarkerId(paramsToken); string reason = ReadReason(paramsToken); if (string.IsNullOrEmpty(reason)) { @@ -120,6 +121,17 @@ private static void LogExpired(string id, long elapsedSinceEnabledMilliseconds) new { Id = id, ElapsedSinceEnabledMilliseconds = elapsedSinceEnabledMilliseconds }); } + // Why here: the CLI builds a --file/--line query id from the path as typed, while the marker + // carries the asset path. This runs on the main thread, after the router switches to it. + private static string ReadMarkerId(JToken paramsToken) + { + return SourcePausePointId.ToMarkerId( + ReadId(paramsToken), + UnityCliLoopPathResolver.GetProjectRoot(), + UloopPausePointRegistry.Contains, + ScriptPackageRoots.ReadCurrent); + } + private static string ReadId(JToken paramsToken) { if (paramsToken is not JObject paramsObject) diff --git a/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef b/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef index 440af34805..a6de6a5cf8 100644 --- a/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef +++ b/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef @@ -7,7 +7,8 @@ "GUID:fc3fd32eddbee40e39c2d76dc184957b", "GUID:5079a8d3a72924a81aa1cbc25f65ed1b", "GUID:527f26a36b5043c2bd4d4036d04cd76d", - "GUID:e5952ef560e1641f68b2687e6045bf5b" + "GUID:e5952ef560e1641f68b2687e6045bf5b", + "GUID:24d448abf7c10486d9578018b3a3c54b" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index cc6c8da327..da35661bbd 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -192,6 +192,12 @@ public static UloopPausePointClearAllResult ClearAll( clearedIds.Count, now, editorState, clearedIds.ToArray(), resumedFromPause); } + public static bool Contains(string id) + { + Debug.Assert(!string.IsNullOrWhiteSpace(id), "id must not be null or empty"); + return Entries.ContainsKey(id); + } + public static UloopPausePointSnapshot GetStatus(string id) { Debug.Assert(!string.IsNullOrWhiteSpace(id), "id must not be null or empty"); diff --git a/Packages/uloop-hotreload-package-fixture/Runtime.meta b/Packages/uloop-hotreload-package-fixture/Runtime.meta new file mode 100644 index 0000000000..8dd6a2e508 --- /dev/null +++ b/Packages/uloop-hotreload-package-fixture/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5e11140f7cbf44bca8e73d469e8dd56b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/uloop-hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs b/Packages/uloop-hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs new file mode 100644 index 0000000000..de2a542ef1 --- /dev/null +++ b/Packages/uloop-hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs @@ -0,0 +1,24 @@ +namespace io.github.hatayama.UnityCliLoop.Tests.PackageFixture +{ + /// + /// Hot-reload target in an embedded package whose folder name differs from its package name, + /// so the asset path Unity reports for this file and the path of the file on disk differ. + /// + public class HotReloadPackageFixture + { + public int First() + { + return 1; + } + + public int Second() + { + return 2; + } + + public int Third() + { + return 3; + } + } +} diff --git a/Packages/uloop-hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs.meta b/Packages/uloop-hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs.meta new file mode 100644 index 0000000000..da9d6cfd40 --- /dev/null +++ b/Packages/uloop-hotreload-package-fixture/Runtime/HotReloadPackageFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 967facf4e36f64151b97a775efdd28e5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/uloop-hotreload-package-fixture/Runtime/UnityCLILoop.Tests.HotReloadPackageFixture.asmdef b/Packages/uloop-hotreload-package-fixture/Runtime/UnityCLILoop.Tests.HotReloadPackageFixture.asmdef new file mode 100644 index 0000000000..296c5cf1f6 --- /dev/null +++ b/Packages/uloop-hotreload-package-fixture/Runtime/UnityCLILoop.Tests.HotReloadPackageFixture.asmdef @@ -0,0 +1,14 @@ +{ + "name": "UnityCLILoop.Tests.HotReloadPackageFixture", + "rootNamespace": "io.github.hatayama.UnityCliLoop.Tests.PackageFixture", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/uloop-hotreload-package-fixture/Runtime/UnityCLILoop.Tests.HotReloadPackageFixture.asmdef.meta b/Packages/uloop-hotreload-package-fixture/Runtime/UnityCLILoop.Tests.HotReloadPackageFixture.asmdef.meta new file mode 100644 index 0000000000..15bfd31f3b --- /dev/null +++ b/Packages/uloop-hotreload-package-fixture/Runtime/UnityCLILoop.Tests.HotReloadPackageFixture.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a9aeba35b4609431abb3291e59648000 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/uloop-hotreload-package-fixture/package.json b/Packages/uloop-hotreload-package-fixture/package.json new file mode 100644 index 0000000000..b1299f1f8e --- /dev/null +++ b/Packages/uloop-hotreload-package-fixture/package.json @@ -0,0 +1,6 @@ +{ + "name": "io.github.hatayama.uloop.hotreload-package-fixture", + "version": "0.0.1", + "displayName": "Unity CLI Loop Hot Reload Package Fixture", + "description": "Test-only embedded package. Its folder name differs from its package name, so hot reload tests see a script whose asset path and file path differ." +} diff --git a/Packages/uloop-hotreload-package-fixture/package.json.meta b/Packages/uloop-hotreload-package-fixture/package.json.meta new file mode 100644 index 0000000000..91b3c6a024 --- /dev/null +++ b/Packages/uloop-hotreload-package-fixture/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4924d8b749f824aa7908c60619bee3fd +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index 219eeba1ff..2612aa8262 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -390,7 +390,7 @@ }, "File": { "type": "string", - "description": "Project-relative source file path to patch a pause point into. Requires Line; mutually exclusive with Id" + "description": "Source file to patch a pause point into: Assets/..., Packages//..., a package folder path, or an absolute path. Requires Line; mutually exclusive with Id" }, "Line": { "type": "integer", diff --git a/cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go b/cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go index 937ee396ef..97f231395d 100644 --- a/cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go +++ b/cli/project-runner/internal/projectrunner/compile_fresh_recovery_test.go @@ -506,8 +506,8 @@ func TestFreshCompileRecoveryStopsDetectingOnTheLastAttempt(t *testing.T) { } } -// 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. +// Verifies the single-attempt entry that never resends 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() diff --git a/cli/project-runner/internal/projectrunner/compile_wait_interim.go b/cli/project-runner/internal/projectrunner/compile_wait_interim.go index 2530df9f77..36fa2791c6 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait_interim.go +++ b/cli/project-runner/internal/projectrunner/compile_wait_interim.go @@ -46,7 +46,7 @@ func (state *compileWaitInterimState) lineIfDue(now time.Time, interval time.Dur state.hasReported = true state.lastReportAt = now if useSilent { - return formatCompileWaitSilentLine(silentFor), true + return formatCompileWaitSilentLine(silentFor, state.lastSuccessStatus), true } return formatCompileWaitProgressLine(now.Sub(state.waitStartedAt), state.lastSuccessStatus), true } @@ -85,7 +85,18 @@ func formatCompileWaitProgressLine(elapsed time.Duration, status compileStatusRe ) } -func formatCompileWaitSilentLine(silentFor time.Duration) string { +func formatCompileWaitSilentLine(silentFor time.Duration, lastStatus compileStatusResponse) string { + // A large compile or domain reload on a loaded machine can keep the Editor from answering this long by itself, + // so leading with the dialog would send the reader looking for one that is not there. A wait with no answered + // poll has the zero status and keeps the dialog-first wording. + if lastStatus.IsCompiling || lastStatus.IsDomainReloadInProgress { + return fmt.Sprintf( + "compile: Unity has not answered status polls for %ds while compiling (last status: is_compiling=%t, is_domain_reload_in_progress=%t). A large compile or a loaded machine can keep the Editor from answering this long. If it stays silent after the compile should have finished, check the Editor for a modal dialog.", + int(silentFor/time.Second), + lastStatus.IsCompiling, + lastStatus.IsDomainReloadInProgress, + ) + } return fmt.Sprintf( "compile: Unity has not answered status polls for %ds. The Editor may be blocked by a modal dialog — commonly Unity's 'Script Updating Consent' or 'API Update Required' dialog, which uloop cannot click — or stuck. Ask the user to answer the dialog in the Unity window. Restarting with 'uloop launch -r' helps only when no dialog is shown: the dialog reappears on every compile until the obsolete-API code is fixed or a person answers it.", int(silentFor/time.Second), diff --git a/cli/project-runner/internal/projectrunner/compile_wait_interim_test.go b/cli/project-runner/internal/projectrunner/compile_wait_interim_test.go index c63a6f42dc..725e55d7d3 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait_interim_test.go +++ b/cli/project-runner/internal/projectrunner/compile_wait_interim_test.go @@ -42,10 +42,10 @@ func TestCompileWaitInterimReportsProgressLineAtSixtySeconds(t *testing.T) { } func TestCompileWaitInterimSwitchesToSilentLineAfterThirtySecondsWithoutSuccess(t *testing.T) { - // Verifies a successful poll followed by 30s of silence switches to the silent line. + // Verifies an idle successful poll followed by 30s of silence switches to the dialog-first silent line. start := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC) state := newCompileWaitInterimState(start) - state.noteSuccessfulPoll(start, compileStatusResponse{IsCompiling: true}) + state.noteSuccessfulPoll(start, compileStatusResponse{Ready: true}) line, due := state.lineIfDue(start.Add(29*time.Second), compileWaitInterimIntervalDefault) if due { @@ -82,6 +82,96 @@ func TestCompileWaitInterimReportsSilentLineWhenNoSuccessfulPolls(t *testing.T) } } +func TestCompileWaitInterimSilentLineSaysCompilingWhenTheLastAnswerWasCompiling(t *testing.T) { + // Verifies silence right after an answer that saw a compile or a domain reload leads with the compile and keeps the dialog hint for last. + testCases := []struct { + name string + status compileStatusResponse + lastStatus string + }{ + { + name: "compiling", + status: compileStatusResponse{IsCompiling: true}, + lastStatus: "(last status: is_compiling=true, is_domain_reload_in_progress=false)", + }, + { + name: "domain reload", + status: compileStatusResponse{IsDomainReloadInProgress: true}, + lastStatus: "(last status: is_compiling=false, is_domain_reload_in_progress=true)", + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + start := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC) + state := newCompileWaitInterimState(start) + lastAnswerAt := start.Add(time.Second) + state.noteSuccessfulPoll(lastAnswerAt, testCase.status) + + line, due := state.lineIfDue(lastAnswerAt.Add(31*time.Second), compileWaitInterimIntervalDefault) + if !due { + t.Fatal("expected a silent line after 31s without a successful poll") + } + if !strings.HasPrefix(line, "compile: Unity has not answered status polls for 31s") { + t.Fatalf("silent line must keep the headline: %q", line) + } + if !strings.Contains(line, "while compiling") { + t.Fatalf("silent line must say the Editor was compiling: %q", line) + } + if !strings.Contains(line, testCase.lastStatus) { + t.Fatalf("silent line must report the last status it saw, %s: %q", testCase.lastStatus, line) + } + firstSentenceEnd := strings.Index(line, ".") + if firstSentenceEnd < 0 { + t.Fatalf("silent line has no sentence end: %q", line) + } + if strings.Contains(line[:firstSentenceEnd+1], "modal dialog") { + t.Fatalf("the first sentence must not lead with the dialog: %q", line) + } + if !strings.Contains(line, "modal dialog") { + t.Fatalf("silent line must still point at a possible dialog: %q", line) + } + }) + } +} + +func TestCompileWaitInterimSilentLineKeepsTheDialogHintWhenNothingWasObserved(t *testing.T) { + // Verifies silence with no answered poll at all still leads with the dialog hint. + start := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC) + state := newCompileWaitInterimState(start) + + line, due := state.lineIfDue(start.Add(31*time.Second), compileWaitInterimIntervalDefault) + if !due { + t.Fatal("expected a silent line after 31s with zero successful polls") + } + dialogFirst := "compile: Unity has not answered status polls for 31s. The Editor may be blocked by a modal dialog" + if !strings.HasPrefix(line, dialogFirst) { + t.Fatalf("silent line must lead with the dialog hint:\n got: %q\nwant prefix: %q", line, dialogFirst) + } + if strings.Contains(line, "while compiling") { + t.Fatalf("silent line must not claim a compile it never saw: %q", line) + } +} + +func TestCompileWaitInterimSilentLineKeepsTheDialogHintWhenTheLastAnswerWasIdle(t *testing.T) { + // Verifies silence after an answer that saw neither a compile nor a domain reload still leads with the dialog hint. + start := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC) + state := newCompileWaitInterimState(start) + lastAnswerAt := start.Add(time.Second) + state.noteSuccessfulPoll(lastAnswerAt, compileStatusResponse{Ready: true}) + + line, due := state.lineIfDue(lastAnswerAt.Add(31*time.Second), compileWaitInterimIntervalDefault) + if !due { + t.Fatal("expected a silent line after 31s without a successful poll") + } + dialogFirst := "compile: Unity has not answered status polls for 31s. The Editor may be blocked by a modal dialog" + if !strings.HasPrefix(line, dialogFirst) { + t.Fatalf("silent line must lead with the dialog hint:\n got: %q\nwant prefix: %q", line, dialogFirst) + } + if strings.Contains(line, "while compiling") { + t.Fatalf("silent line must not claim a compile it never saw: %q", line) + } +} + func TestWaitForCompileCompletionReportsInterimProgressLine(t *testing.T) { // Verifies the fresh wait loop emits the progress line after injected 60s. connection := compileWaitTestConnection(t) diff --git a/cli/project-runner/internal/projectrunner/connection_retry.go b/cli/project-runner/internal/projectrunner/connection_retry.go index b1e3a4bcef..494c68f808 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry.go +++ b/cli/project-runner/internal/projectrunner/connection_retry.go @@ -27,6 +27,11 @@ type connectionRetryDeps struct { retryTimeout time.Duration retryPoll time.Duration busyFocusStallThreshold time.Duration + // returnBusyWithoutRetry hands the first busy answer back instead of resending every + // retryPoll. Hot reload sets it because it waits for the busy slot to free on the Editor + // status: resending here would only delay that wait, and after busyFocusStallThreshold the + // busy-stall focus would bring the Editor to the front. + returnBusyWithoutRetry bool } func defaultConnectionRetryDeps() connectionRetryDeps { @@ -264,6 +269,9 @@ func sendWithTransientConnectionRetryWithDeps( outcome, err := client.SendWithProgressOutcomeAcceptContext(ctx, attemptContext, method, params, progress) cancelAttempt() if isUnityServerBusyRPCError(err) { + if deps.returnBusyWithoutRetry { + return outcome, err + } // Busy means the request was never executed, so a bounded retry is safe and // usually absorbs back-to-back tool calls without bothering the caller. if busySequenceStartedAt.IsZero() { diff --git a/cli/project-runner/internal/projectrunner/connection_retry_test.go b/cli/project-runner/internal/projectrunner/connection_retry_test.go index fa120a499d..91923a63ee 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_test.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_test.go @@ -1080,6 +1080,169 @@ func TestSendWithTransientConnectionRetryRetriesBusyResponses(t *testing.T) { } } +// Verifies that returnBusyWithoutRetry hands the first busy answer back at once: no resend +// and no busy-stall focus, because hot reload waits for the Editor on its status instead. +func TestSendWithTransientConnectionRetryReturnsTheFirstBusyAnswerWhenAsked(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("TCP endpoint injection is only used by this non-Windows client test") + } + + deps := defaultConnectionRetryDeps() + deps.returnBusyWithoutRetry = true + deps.retryPoll = 5 * time.Millisecond + deps.busyFocusStallThreshold = time.Nanosecond + processLookups := 0 + focusCalls := 0 + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + processLookups++ + return &clicore.UnityProcess{Pid: 123}, nil + } + deps.focusUnityProcess = func(context.Context, int) (clicore.RestoreFocusFunc, error) { + focusCalls++ + return func(context.Context) error { return nil }, nil + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer func() { + _ = listener.Close() + }() + + busy := `{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Unity is busy running 'compile'.","data":{"type":"server_busy","runningToolName":"compile","requestedToolName":"hot-reload","message":"busy"}}}` + serverErr := make(chan error, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + if _, readErr := unityipc.Read(bufio.NewReader(conn)); readErr != nil { + _ = conn.Close() + return + } + _ = unityipc.Write(conn, []byte(busy)) + _ = conn.Close() + extra, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + _ = extra.Close() + serverErr <- errors.New("a second request arrived after the busy answer") + }() + + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: "tcp", + Address: listener.Addr().String(), + }, + ProjectRoot: t.TempDir(), + } + + _, err = sendWithTransientConnectionRetryWithDeps( + context.Background(), + connection, + "hot-reload", + map[string]any{}, + nil, + 0, + deps) + if !isUnityServerBusyRPCError(err) { + t.Fatalf("expected the busy answer back, got %v", err) + } + _ = listener.Close() + select { + case extraErr := <-serverErr: + t.Fatal(extraErr) + default: + } + if focusCalls != 0 { + t.Fatalf("expected no focus attempt, got %d (process lookups %d)", focusCalls, processLookups) + } +} + +// Verifies that returnBusyWithoutRetry leaves the undispatched-connection retry alone: a dial +// that fails before the server is up is still retried until the request gets through. +func TestSendWithTransientConnectionRetryStillRetriesAnUndispatchedFailureWhenBusyReturnsAtOnce(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("TCP endpoint injection is only used by this non-Windows client test") + } + + deps := defaultConnectionRetryDeps() + deps.returnBusyWithoutRetry = true + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + return &clicore.UnityProcess{Pid: 123}, nil + } + deps.focusUnityProcess = func(context.Context, int) (clicore.RestoreFocusFunc, error) { + return func(context.Context) error { return nil }, nil + } + deps.retryTimeout = 2 * time.Second + deps.retryPoll = 10 * time.Millisecond + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + address := listener.Addr().String() + // Nothing accepts until the server comes back at 100ms, so the first dials fail undispatched. + _ = listener.Close() + + serverReady := make(chan net.Listener, 1) + go func() { + time.Sleep(100 * time.Millisecond) + lateListener, listenErr := net.Listen("tcp", address) + if listenErr != nil { + serverReady <- nil + return + } + serverReady <- lateListener + success := `{"jsonrpc":"2.0","id":1,"result":{"ok":true}}` + for { + conn, acceptErr := lateListener.Accept() + if acceptErr != nil { + return + } + go func() { + defer func() { + _ = conn.Close() + }() + if _, readErr := unityipc.Read(bufio.NewReader(conn)); readErr != nil { + return + } + _ = unityipc.Write(conn, []byte(success)) + }() + } + }() + t.Cleanup(func() { + if lateListener := <-serverReady; lateListener != nil { + _ = lateListener.Close() + } + }) + + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: "tcp", + Address: address, + }, + ProjectRoot: t.TempDir(), + } + + outcome, err := sendWithTransientConnectionRetryWithDeps( + context.Background(), + connection, + "hot-reload", + map[string]any{}, + nil, + 0, + deps) + if err != nil { + t.Fatalf("expected the undispatched dial to be retried to success, got %v", err) + } + if string(outcome.Result) != `{"ok":true}` { + t.Fatalf("final result mismatch: %s", outcome.Result) + } +} + // Verifies a persistently busy Unity still surfaces the busy error after the retry window. func TestSendWithTransientConnectionRetryReturnsBusyAfterRetryWindow(t *testing.T) { if runtime.GOOS == "windows" { diff --git a/cli/project-runner/internal/projectrunner/dynamic_code_wait.go b/cli/project-runner/internal/projectrunner/dynamic_code_wait.go index d0eec58049..e3211a607a 100644 --- a/cli/project-runner/internal/projectrunner/dynamic_code_wait.go +++ b/cli/project-runner/internal/projectrunner/dynamic_code_wait.go @@ -1,9 +1,13 @@ package projectrunner import ( + "context" "encoding/json" + "io" + "time" clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/common/ui" "github.com/hatayama/unity-cli-loop/common/clicore" "github.com/hatayama/unity-cli-loop/common/unityipc" @@ -15,6 +19,58 @@ const ( legacyDynamicCodeDomainReloadWaitRequiredField = "domainReloadWaitRequired" ) +func runExecuteDynamicCodeWithDomainReloadWait(ctx context.Context, connection unityipc.Connection, params map[string]any, stdout io.Writer, stderr io.Writer) int { + applyDebugTimingParams(clicore.ExecuteDynamicCodeCommandName, params) + startedAt := time.Now() + spinner := clicore.NewToolSpinner(stderr, clicore.ExecuteDynamicCodeCommandName) + outcome, err := sendWithTransientConnectionRetry( + ctx, + connection, + clicore.ExecuteDynamicCodeCommandName, + params, + ui.NewSpinnerProgressFunc(spinner, "Executing execute-dynamic-code..."), + ) + if err != nil { + if shouldWaitForExecuteDynamicCodeDisconnect(err, outcome) { + spinner.Update("Connection lost during execute-dynamic-code. Waiting for domain reload to complete...") + if waitErr := clicore.WaitForToolReadiness(ctx, connection.ProjectRoot); waitErr != nil { + spinner.Stop() + clierrors.WriteClassifiedError(stderr, waitErr, clierrors.ErrorContext{ + ProjectRoot: connection.ProjectRoot, + Command: clicore.ExecuteDynamicCodeCommandName, + }) + return 1 + } + } + spinner.Stop() + writeDebugTiming(stderr, clicore.ExecuteDynamicCodeCommandName, time.Since(startedAt), outcome) + clierrors.WriteToolFailure(stderr, err, outcome, clierrors.ErrorContext{ + ProjectRoot: connection.ProjectRoot, + Command: clicore.ExecuteDynamicCodeCommandName, + }) + return 1 + } + + if executeDynamicCodeDomainReloadWaitRequired(outcome.Result) { + spinner.Update("Waiting for domain reload to complete...") + if err := clicore.WaitForToolReadiness(ctx, connection.ProjectRoot); err != nil { + spinner.Stop() + clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ + ProjectRoot: connection.ProjectRoot, + Command: clicore.ExecuteDynamicCodeCommandName, + }) + return 1 + } + } + + spinner.Stop() + result := stripExecuteDynamicCodeControlResult(outcome.Result) + result = stripDebugTimingResult(clicore.ExecuteDynamicCodeCommandName, result) + clicore.WriteJSON(stdout, result) + writeDebugTiming(stderr, clicore.ExecuteDynamicCodeCommandName, time.Since(startedAt), outcome) + return toolEnvelopeExitCode(result) +} + func shouldWaitForExecuteDynamicCodeDomainReload(command string, params map[string]any) bool { if command != clicore.ExecuteDynamicCodeCommandName { return false diff --git a/cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go b/cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go new file mode 100644 index 0000000000..4f3f6b490d --- /dev/null +++ b/cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go @@ -0,0 +1,278 @@ +package projectrunner + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "time" + + "github.com/hatayama/unity-cli-loop/common/clicore" + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/common/unityipc" + "github.com/hatayama/unity-cli-loop/common/vibelog" +) + +const ( + hotReloadBusyWaitDecidedOperation = "cli_hot_reload_busy_wait_decided" + hotReloadBusyWaitCompleteOperation = "cli_hot_reload_busy_wait_complete" + hotReloadBusyUnknownToolName = "another uloop command" +) + +const ( + // %s is the running tool's name, %d the whole seconds this command waited. + hotReloadBusyWaitNoteFormat = "The Editor was busy running '%s' when this request arrived, so this command waited %ds for it to finish; every other field describes the answer that followed." + hotReloadBusyWaitNotReadyNoteFormat = "The Editor was busy running '%s' when this request arrived; this command waited %ds, the Editor did not report ready in that time, and the request was sent again anyway; every other field describes the answer that followed." + hotReloadBusyWaitingLineFormat = "hot-reload: the Editor is busy running '%s'; waiting for it to finish, then applying..." +) + +// hotReloadBusyWaitSendDeps sends without the bounded busy retry. Why: hot reload waits for the +// Editor on its status instead of resending every second, which would also bring the Editor to the +// front after the busy-stall threshold. +func hotReloadBusyWaitSendDeps() connectionRetryDeps { + deps := defaultConnectionRetryDeps() + deps.returnBusyWithoutRetry = true + return deps +} + +// hotReloadBusyWaitOutcome is what the busy wait leaves for the rest of the command. +type hotReloadBusyWaitOutcome struct { + // result is the answer the command continues with. + result toolExecutionResult + // finished is true when stderr already carries the failure and exitCode is the command's. + finished bool + exitCode int +} + +// sendHotReloadWaitingForBusyEditor sends the request once and, when another uloop command holds +// the Editor, waits for the Editor to be ready and sends the same request once more. It never +// writes stdout. +func sendHotReloadWaitingForBusyEditor( + ctx context.Context, + connection unityipc.Connection, + params map[string]any, + stderr io.Writer, +) hotReloadBusyWaitOutcome { + deps := hotReloadBusyWaitSendDeps() + first, outcome, err := sendPlainTool(ctx, connection, hotReloadCommandName, params, stderr, deps) + if err == nil { + return hotReloadBusyWaitOutcome{result: first} + } + if !isUnityServerBusyRPCError(err) { + writeHotReloadToolFailure(stderr, connection, err, outcome) + return hotReloadBusyWaitOutcome{finished: true, exitCode: 1} + } + + runningToolName := hotReloadBusyRunningToolName(err) + logHotReloadBusyWaitDecided(connection, first.correlationID, err) + // A plain line for the same reason as the settle wait's: the spinner's Stop erases its own line. + _, _ = fmt.Fprintf(stderr, hotReloadBusyWaitingLineFormat+"\n", runningToolName) + wait, waitErr := waitForBusyEditor(ctx, connection, params, stderr, deps, hotReloadEditorReadyWaitDefaults) + if waitErr != nil { + writeHotReloadClassifiedError(stderr, connection, waitErr) + logHotReloadBusyWaitComplete(connection, first.correlationID, wait, nil) + return hotReloadBusyWaitOutcome{finished: true, exitCode: 1} + } + second, secondOutcome, sendErr := wait.answer, wait.outcome, wait.sendErr + if !wait.answered { + // Why send even when the budget ran out: the Editor's answer now is truer than busy data + // from the start of the wait, and a second busy answer is reported without another wait. + second, secondOutcome, sendErr = sendPlainTool(ctx, connection, hotReloadCommandName, params, stderr, deps) + } + logHotReloadBusyWaitComplete(connection, first.correlationID, wait, &second) + if sendErr != nil { + writeHotReloadToolFailure(stderr, connection, sendErr, secondOutcome) + return hotReloadBusyWaitOutcome{finished: true, exitCode: 1} + } + merged, err := injectHotReloadEditorReadyNote(second.result, hotReloadBusyWaitNote(runningToolName, wait.waited, wait.ready)) + if err == nil && hasHotReloadTiming(merged) { + // Why only then: Timing exists on apply answers alone; --status and --revert-all stay without it. + merged, err = addHotReloadTimingMs(merged, hotReloadEditorReadyWaitMsField, wait.waited) + } + if err != nil { + writeHotReloadClassifiedError(stderr, connection, err) + return hotReloadBusyWaitOutcome{finished: true, exitCode: 1} + } + return hotReloadBusyWaitOutcome{result: toolExecutionResult{ + result: merged, + exitCode: second.exitCode, + correlationID: second.correlationID, + }} +} + +// hotReloadBusyWait is how the wait for a busy Editor ended. +type hotReloadBusyWait struct { + waited time.Duration + // ready is true when the Editor reported ready, or a resent request got in. + ready bool + // answered is true when a request resent during the wait got an answer other than busy, or + // failed; answer, outcome and sendErr are then that request's. + answered bool + answer toolExecutionResult + outcome unityipc.UnitySendOutcome + sendErr error + resends int +} + +// waitForBusyEditor polls the Editor status until it is ready, the budget runs out, or ctx is +// cancelled. While a cancelled execute-dynamic-code request holds the Editor, it also sends the +// request again every busyResendInterval: the Editor takes such a request's slot back only when a +// tool request arrives, never on a status answer, so status polling alone would wait the whole +// budget for a slot the first attempt already started to free. +func waitForBusyEditor( + ctx context.Context, + connection unityipc.Connection, + params map[string]any, + stderr io.Writer, + deps connectionRetryDeps, + options hotReloadEditorReadyWaitOptions, +) (hotReloadBusyWait, error) { + startedAt := time.Now() + deadline := startedAt.Add(options.budget) + // The first attempt counts: its arrival started the Editor's grace period for the slot. + lastResend := startedAt + wait := hotReloadBusyWait{} + for { + status, answered := probeHotReloadEditorStatus(ctx, connection, options.probeTimeout) + if answered && classifyEditorState(status) == statusStateReady { + wait.waited, wait.ready = time.Since(startedAt), true + return wait, nil + } + if answered && isHeldByExecuteDynamicCode(status) && time.Since(lastResend) >= options.busyResendInterval { + answer, outcome, err := sendPlainTool(ctx, connection, hotReloadCommandName, params, stderr, deps) + lastResend = time.Now() + wait.resends++ + if !isUnityServerBusyRPCError(err) { + wait.waited, wait.ready, wait.answered = time.Since(startedAt), err == nil, true + wait.answer, wait.outcome, wait.sendErr = answer, outcome, err + return wait, nil + } + } + if !time.Now().Before(deadline) { + wait.waited = time.Since(startedAt) + return wait, nil + } + select { + case <-ctx.Done(): + wait.waited = time.Since(startedAt) + return wait, ctx.Err() + case <-time.After(options.pollInterval): + } + } +} + +// Why only execute-dynamic-code: it is the one tool whose cancelled request the Editor revokes on +// the next tool request; every other tool gives the slot back when it finishes, which the status +// shows. +func isHeldByExecuteDynamicCode(status editorStatusResponse) bool { + return status.IsBusy && status.RunningToolName == clicore.ExecuteDynamicCodeCommandName +} + +// hotReloadBusyRunningToolName names the command that held the Editor, from the busy answer's data. +func hotReloadBusyRunningToolName(err error) string { + var rpcErr *unityipc.RPCError + if !errors.As(err, &rpcErr) { + return hotReloadBusyUnknownToolName + } + data := struct { + RunningToolName string `json:"runningToolName"` + }{} + if json.Unmarshal(rpcErr.Data, &data) != nil || data.RunningToolName == "" { + return hotReloadBusyUnknownToolName + } + return data.RunningToolName +} + +func hotReloadBusyWaitNote(runningToolName string, waited time.Duration, ready bool) string { + if ready { + return fmt.Sprintf(hotReloadBusyWaitNoteFormat, runningToolName, wholeSeconds(waited)) + } + return fmt.Sprintf(hotReloadBusyWaitNotReadyNoteFormat, runningToolName, wholeSeconds(waited)) +} + +// hasHotReloadTiming is true when the answer carries a Timing object, which only apply answers do. +func hasHotReloadTiming(raw []byte) bool { + fields := map[string]json.RawMessage{} + if json.Unmarshal(raw, &fields) != nil { + return false + } + existing := fields[hotReloadTimingField] + return len(existing) > 0 && existing[0] == '{' +} + +func writeHotReloadToolFailure(stderr io.Writer, connection unityipc.Connection, err error, outcome unityipc.UnitySendOutcome) { + clierrors.WriteToolFailure(stderr, err, outcome, clierrors.ErrorContext{ + ProjectRoot: connection.ProjectRoot, + Command: hotReloadCommandName, + }) +} + +// Written only when the first answer is busy: an answer that is not busy is already shown by +// cli_tool_response_received. +func logHotReloadBusyWaitDecided(connection unityipc.Connection, correlationID string, err error) { + writePlainToolVibeLog(connection.ProjectRoot, func() vibelog.CLIVibeLogEntry { + busy := struct { + RunningToolName string `json:"runningToolName"` + RunningToolPhase string `json:"runningToolPhase"` + RunningToolElapsedSeconds *float64 `json:"runningToolElapsedSeconds"` + }{} + var rpcErr *unityipc.RPCError + if errors.As(err, &rpcErr) { + _ = json.Unmarshal(rpcErr.Data, &busy) + } + var elapsedSeconds any + if busy.RunningToolElapsedSeconds != nil { + elapsedSeconds = *busy.RunningToolElapsedSeconds + } + return vibelog.CLIVibeLogEntry{ + Level: "INFO", + Operation: hotReloadBusyWaitDecidedOperation, + Message: "Hot reload found the Editor busy and waits for the running command.", + Context: map[string]any{ + "correlation_id": correlationID, + "running_tool_name": busy.RunningToolName, + "running_tool_phase": busy.RunningToolPhase, + "running_tool_elapsed_seconds": elapsedSeconds, + "budget_ms": hotReloadEditorReadyWaitDefaults.budget.Milliseconds(), + "resend_interval_ms": hotReloadEditorReadyWaitDefaults.busyResendInterval.Milliseconds(), + }, + CorrelationID: correlationID, + } + }) +} + +// Written once on every way out of a wait that started. second is nil when no request was sent +// after the wait. +func logHotReloadBusyWaitComplete( + connection unityipc.Connection, + correlationID string, + wait hotReloadBusyWait, + second *toolExecutionResult, +) { + writePlainToolVibeLog(connection.ProjectRoot, func() vibelog.CLIVibeLogEntry { + entryContext := map[string]any{ + "correlation_id": correlationID, + "second_correlation_id": "", + "waited_ms": wait.waited.Milliseconds(), + "ready": wait.ready, + "resends": wait.resends, + "second_result": false, + } + if second != nil { + entryContext["second_correlation_id"] = second.correlationID + addHotReloadSecondApplyContext(entryContext, second.result) + } + level := "WARN" + if wait.ready && entryContext["second_result"] == true { + level = "INFO" + } + return vibelog.CLIVibeLogEntry{ + Level: level, + Operation: hotReloadBusyWaitCompleteOperation, + Message: "Finished waiting for the busy Editor and sending hot reload again.", + Context: entryContext, + CorrelationID: correlationID, + } + }) +} diff --git a/cli/project-runner/internal/projectrunner/hot_reload_busy_wait_test.go b/cli/project-runner/internal/projectrunner/hot_reload_busy_wait_test.go new file mode 100644 index 0000000000..94b7080513 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/hot_reload_busy_wait_test.go @@ -0,0 +1,587 @@ +package projectrunner + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +const ( + busyWaitRunningCompile = `{"code":-32603,"message":"Unity is busy running 'compile'.","data":{"type":"server_busy",` + + `"runningToolName":"compile","requestedToolName":"hot-reload","message":"busy",` + + `"runningToolPhase":"WaitingForMainThread","runningToolElapsedSeconds":11}}` + busyWaitRunningUnnamed = `{"code":-32603,"message":"Unity is busy.","data":{"type":"server_busy",` + + `"requestedToolName":"hot-reload","message":"busy"}}` + busyWaitRunningDynamicCode = `{"code":-32603,"message":"Unity is busy running 'execute-dynamic-code'.","data":{"type":"server_busy",` + + `"runningToolName":"execute-dynamic-code","requestedToolName":"hot-reload","message":"busy",` + + `"runningToolPhase":"Executing","runningToolElapsedSeconds":3}}` + busyWaitStatusBusyCompile = `{"IsBusy":true,"RunningToolName":"compile","RunningToolElapsedSeconds":11,` + + `"RunningToolPhase":"WaitingForMainThread","HasEditorState":true,"IsCompiling":false,"IsUpdating":false,` + + `"SecondsSinceLastMainThreadTick":0.1}` + busyWaitStatusBusyDynamicCode = `{"IsBusy":true,"RunningToolName":"execute-dynamic-code","RunningToolElapsedSeconds":3,` + + `"RunningToolPhase":"Executing","HasEditorState":true,"IsCompiling":false,"IsUpdating":false,` + + `"SecondsSinceLastMainThreadTick":0.1}` + busyWaitApplied = `{"Success":true,"Outcome":"Applied","RetryAfterEditorReady":false,` + + `"CompileFallback":"NotNeeded","Message":"applied","Timing":{"TotalMs":34}}` + busyWaitFallbackRequested = `{"Success":false,"Outcome":"Failed","RetryAfterEditorReady":false,` + + `"CompileFallback":"Requested","Message":"applied","Timing":{"TotalMs":34}}` + busyWaitWaitingLine = "the Editor is busy running 'compile'; waiting for it to finish" +) + +func busyWaitParams() map[string]any { + return withFiles(map[string]any{"CompileOnSkip": "off"}, []any{"Assets/A.cs"}) +} + +// busyWaitT1Steps is a compile holding the Editor, the domain reload dropping the server, the +// Editor compiling and then ready, and the request sent after the wait being applied. +func busyWaitT1Steps() []scriptedIPCStep { + return []scriptedIPCStep{ + {method: hotReloadCommandName, rpcError: busyWaitRunningCompile}, + {method: editorStatusBridgeCommandName, result: busyWaitStatusBusyCompile}, + {method: editorStatusBridgeCommandName, drop: true}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusCompiling}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: busyWaitApplied}, + } +} + +func (run editorReadyRetryRun) statusRequests() int { + count := 0 + for _, request := range run.requests { + if request.method == editorStatusBridgeCommandName { + count++ + } + } + return count +} + +func assertHotReloadRequestsUnchanged(t *testing.T, run editorReadyRetryRun, want int) { + t.Helper() + applies := run.hotReloadRequests() + if len(applies) != want { + t.Fatalf("hot-reload requests = %d, want %d\nstdout=%s\nstderr=%s", len(applies), want, run.stdout, run.stderr) + } + for index, apply := range applies[1:] { + if !reflect.DeepEqual(apply.params, applies[0].params) { + t.Fatalf("hot-reload request %d params = %#v, want the first's %#v", index+1, apply.params, applies[0].params) + } + } +} + +func assertBusyWaitNote(t *testing.T, fields map[string]any, fragments ...string) { + t.Helper() + note, _ := fields["EditorReadyRetryNote"].(string) + for _, fragment := range fragments { + if !strings.Contains(note, fragment) { + t.Fatalf("EditorReadyRetryNote = %q, want it to contain %q", note, fragment) + } + } +} + +func editorReadyWaitMs(t *testing.T, fields map[string]any) float64 { + t.Helper() + timing, _ := fields["Timing"].(map[string]any) + waited, isNumber := timing["EditorReadyWaitMs"].(float64) + if !isNumber { + t.Fatalf("Timing.EditorReadyWaitMs = %#v, want a number", timing["EditorReadyWaitMs"]) + } + return waited +} + +func assertFailedWithEmptyStdout(t *testing.T, run editorReadyRetryRun, stderrFragment string) { + t.Helper() + if run.code != 1 { + t.Fatalf("exit code = %d, want 1\nstderr=%s", run.code, run.stderr) + } + if run.stdout != "" { + t.Fatalf("stdout must be empty: %q", run.stdout) + } + if !strings.Contains(run.stderr, stderrFragment) { + t.Fatalf("stderr must contain %q: %q", stderrFragment, run.stderr) + } +} + +// Verifies a request refused because another uloop command holds the Editor waits for the Editor +// to be ready through a domain reload, then is sent once more, unchanged, and that answer is reported. +func TestRunHotReloadWaitsForTheRunningCommandAndAppliesOnce(t *testing.T) { + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + run := runScriptedHotReload(t, context.Background(), t.TempDir(), busyWaitParams(), busyWaitT1Steps(), + editorReadyRetryNoCompile()) + + assertHotReloadRequestsUnchanged(t, run, 2) + if run.code != 0 || run.compileCalls != 0 { + t.Fatalf("exit code = %d, compile calls = %d, want 0 and 0\nstderr=%s", run.code, run.compileCalls, run.stderr) + } + fields := run.stdoutFields(t) + if fields["Outcome"] != "Applied" { + t.Fatalf("Outcome = %#v, want Applied", fields["Outcome"]) + } + assertBusyWaitNote(t, fields, "'compile'") + editorReadyWaitMs(t, fields) + if timing, _ := fields["Timing"].(map[string]any); timing["TotalMs"] != float64(34) { + t.Fatalf("Timing.TotalMs = %#v, want the answer's 34", timing["TotalMs"]) + } + if !strings.Contains(run.stderr, busyWaitWaitingLine) { + t.Fatalf("stderr must carry the waiting line: %q", run.stderr) + } +} + +// Verifies a busy answer to the request sent after the wait is reported as UNITY_SERVER_BUSY +// without a second wait, and the wait's end entry says no answer came back. +func TestRunHotReloadReportsASecondBusyAnswerWithoutWaitingAgain(t *testing.T) { + enableCliVibeLog(t) + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + projectRoot := t.TempDir() + run := runScriptedHotReload(t, context.Background(), projectRoot, busyWaitParams(), + []scriptedIPCStep{ + {method: hotReloadCommandName, rpcError: busyWaitRunningCompile}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, rpcError: busyWaitRunningCompile}, + }, + editorReadyRetryNoCompile()) + + assertHotReloadRequestsUnchanged(t, run, 2) + if statuses := run.statusRequests(); statuses != 1 { + t.Fatalf("status requests = %d, want 1", statuses) + } + assertFailedWithEmptyStdout(t, run, "UNITY_SERVER_BUSY") + complete := cliVibeEntryContext(t, singleCliVibeEntry(t, readOnlyCliVibeLog(t, projectRoot), hotReloadBusyWaitCompleteOperation)) + assertCliVibeContextValues(t, complete, map[string]any{"ready": true, "second_result": false}) + if second, _ := complete["second_correlation_id"].(string); second == "" { + t.Fatalf("second_correlation_id must name the request sent after the wait: %#v", complete) + } +} + +// Verifies a cancel during the wait sends nothing more, reports the cancel, and still writes the +// wait's end entry with no second request. +func TestRunHotReloadReportsACancelWhileWaitingForTheBusyEditor(t *testing.T) { + enableCliVibeLog(t) + options := fastEditorReadyWaitOptions() + options.pollInterval = 50 * time.Millisecond + useFastEditorReadyWait(t, options) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + steps := []scriptedIPCStep{ + {method: hotReloadCommandName, rpcError: busyWaitRunningCompile}, + {method: editorStatusBridgeCommandName, result: busyWaitStatusBusyCompile, onServed: cancel}, + } + for range 50 { + steps = append(steps, scriptedIPCStep{method: editorStatusBridgeCommandName, result: busyWaitStatusBusyCompile}) + } + projectRoot := t.TempDir() + run := runScriptedHotReload(t, ctx, projectRoot, busyWaitParams(), steps, editorReadyRetryNoCompile()) + + assertHotReloadRequestsUnchanged(t, run, 1) + assertFailedWithEmptyStdout(t, run, context.Canceled.Error()) + if !strings.Contains(run.stderr, busyWaitWaitingLine) { + t.Fatalf("stderr must carry the waiting line: %q", run.stderr) + } + // Why the log and not the request count: a request sent on the cancelled ctx fails at the dial, + // so stderr and the server's count look the same whether or not it was sent. + logContent := readOnlyCliVibeLog(t, projectRoot) + if sent := cliVibeEntriesForOperation(t, logContent, "cli_tool_request_sent"); len(sent) != 1 { + t.Fatalf("cli_tool_request_sent entries = %d, want 1", len(sent)) + } + complete := cliVibeEntryContext(t, singleCliVibeEntry(t, logContent, hotReloadBusyWaitCompleteOperation)) + assertCliVibeContextValues(t, complete, map[string]any{ + "second_correlation_id": "", + "ready": false, + "second_result": false, + }) +} + +// Verifies the request is sent once more when the budget ends before the Editor reports ready, +// and the note says the Editor did not report ready. +func TestRunHotReloadAppliesOnceMoreWhenTheBudgetEndsBeforeTheEditorIsReady(t *testing.T) { + options := fastEditorReadyWaitOptions() + options.budget = 0 + useFastEditorReadyWait(t, options) + run := runScriptedHotReload(t, context.Background(), t.TempDir(), busyWaitParams(), + []scriptedIPCStep{ + {method: hotReloadCommandName, rpcError: busyWaitRunningCompile}, + {method: editorStatusBridgeCommandName, result: busyWaitStatusBusyCompile}, + {method: hotReloadCommandName, result: busyWaitApplied}, + }, + editorReadyRetryNoCompile()) + + assertHotReloadRequestsUnchanged(t, run, 2) + if statuses := run.statusRequests(); statuses != 1 { + t.Fatalf("status requests = %d, want 1", statuses) + } + if run.code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr=%s", run.code, run.stderr) + } + fields := run.stdoutFields(t) + if fields["Outcome"] != "Applied" { + t.Fatalf("Outcome = %#v, want Applied", fields["Outcome"]) + } + assertBusyWaitNote(t, fields, "did not report ready") + editorReadyWaitMs(t, fields) +} + +// Verifies an apply after the busy wait that is refused for compiling runs the settle wait too, +// and the note and EditorReadyWaitMs cover both waits instead of the last one alone. +func TestRunHotReloadKeepsBothNotesWhenTheApplyAfterTheBusyWaitIsRefusedForCompiling(t *testing.T) { + options := fastEditorReadyWaitOptions() + options.pollInterval = 20 * time.Millisecond + useFastEditorReadyWait(t, options) + run := runScriptedHotReload(t, context.Background(), t.TempDir(), busyWaitParams(), + []scriptedIPCStep{ + {method: hotReloadCommandName, rpcError: busyWaitRunningCompile}, + {method: editorStatusBridgeCommandName, result: busyWaitStatusBusyCompile}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: editorReadyRetryFirstRefused}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: editorReadyRetrySecondApplied}, + }, + editorReadyRetryNoCompile()) + + applies := run.hotReloadRequests() + if len(applies) != 3 { + t.Fatalf("hot-reload requests = %d, want 3\nstderr=%s", len(applies), run.stderr) + } + if files := applies[2].params["Files"]; !reflect.DeepEqual(files, []any{"Assets/A.cs", "Assets/B.cs"}) { + t.Fatalf("third request Files = %#v, want the selected files", files) + } + if run.code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr=%s", run.code, run.stderr) + } + fields := run.stdoutFields(t) + note, _ := fields["EditorReadyRetryNote"].(string) + busyAt := strings.Index(note, "'compile'") + settleAt := strings.Index(note, "applied the same request again") + if busyAt < 0 || settleAt < busyAt { + t.Fatalf("EditorReadyRetryNote = %q, want the busy sentence followed by the settle sentence", note) + } + if waited := editorReadyWaitMs(t, fields); waited < 20 { + t.Fatalf("Timing.EditorReadyWaitMs = %v, want both waits (at least 20)", waited) + } +} + +// Verifies an apply after the busy wait that is refused for compiling, followed by a settle wait +// that runs out, keeps both sentences in order and both waits in EditorReadyWaitMs. +func TestRunHotReloadKeepsBothNotesWhenTheEditorDoesNotSettleAfterTheBusyWait(t *testing.T) { + enableCliVibeLog(t) + options := fastEditorReadyWaitOptions() + options.pollInterval = 20 * time.Millisecond + // Why the busy wait ends on Ready: the budget is shared, and only the settle wait may run it out. + // It is large enough that a slow machine's busy wait still ends on Ready. + options.budget = 400 * time.Millisecond + useFastEditorReadyWait(t, options) + steps := []scriptedIPCStep{{method: hotReloadCommandName, rpcError: busyWaitRunningCompile}} + for range 4 { + steps = append(steps, scriptedIPCStep{method: editorStatusBridgeCommandName, result: busyWaitStatusBusyCompile}) + } + steps = append(steps, + scriptedIPCStep{method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + scriptedIPCStep{method: hotReloadCommandName, result: editorReadyRetryFirstRefused}) + for range 50 { + steps = append(steps, scriptedIPCStep{method: editorStatusBridgeCommandName, result: editorReadyRetryStatusCompiling}) + } + projectRoot := t.TempDir() + run := runScriptedHotReload(t, context.Background(), projectRoot, busyWaitParams(), steps, editorReadyRetryNoCompile()) + + assertHotReloadRequestsUnchanged(t, run, 2) + if run.code != 1 || run.compileCalls != 0 { + t.Fatalf("exit code = %d, compile calls = %d, want 1 and 0\nstderr=%s", run.code, run.compileCalls, run.stderr) + } + fields := run.stdoutFields(t) + if fields["Outcome"] != "Failed" { + t.Fatalf("Outcome = %#v, want Failed", fields["Outcome"]) + } + note, _ := fields["EditorReadyRetryNote"].(string) + busyAt := strings.Index(note, "'compile'") + gaveUpAt := strings.Index(note, "did not") + if busyAt < 0 || gaveUpAt < busyAt { + t.Fatalf("EditorReadyRetryNote = %q, want the busy sentence followed by the gave-up sentence", note) + } + // Each wait logs the same whole milliseconds it adds to Timing, so the field is exactly their + // sum: keeping only one wait leaves out the other, and neither is zero. + logContent := readOnlyCliVibeLog(t, projectRoot) + busyWaited, _ := cliVibeEntryContext(t, singleCliVibeEntry(t, logContent, hotReloadBusyWaitCompleteOperation))["waited_ms"].(float64) + settleWaited, _ := cliVibeEntryContext(t, singleCliVibeEntry(t, logContent, hotReloadEditorReadyRetryCompleteOperation))["waited_ms"].(float64) + if waited := editorReadyWaitMs(t, fields); waited != busyWaited+settleWaited { + t.Fatalf("Timing.EditorReadyWaitMs = %v, want the busy wait %v plus the settle wait %v", waited, busyWaited, settleWaited) + } +} + +// Verifies the busy wait writes its decision and end entries under the first request's ID, and the +// later entries follow the request sent after the wait. +func TestRunHotReloadWritesBusyWaitVibeLogs(t *testing.T) { + enableCliVibeLog(t) + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + projectRoot := t.TempDir() + run := runScriptedHotReload(t, context.Background(), projectRoot, busyWaitParams(), busyWaitT1Steps(), + editorReadyRetryNoCompile()) + if run.code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr=%s", run.code, run.stderr) + } + + logContent := readOnlyCliVibeLog(t, projectRoot) + sent := cliVibeEntriesForOperation(t, logContent, "cli_tool_request_sent") + if len(sent) != 2 { + t.Fatalf("cli_tool_request_sent entries = %d, want 2\n%s", len(sent), logContent) + } + failed := cliVibeEntriesForOperation(t, logContent, "cli_tool_request_failed") + if len(failed) == 0 { + t.Fatalf("cli_tool_request_failed entries = 0, want the busy answer\n%s", logContent) + } + assertCliVibeContextValues(t, cliVibeEntryContext(t, failed[0]), map[string]any{"error_kind": "rpc:server_busy"}) + + decided := singleCliVibeEntry(t, logContent, hotReloadBusyWaitDecidedOperation) + assertSharedCliVibeCorrelationID(t, sent[0], decided) + assertCliVibeContextValues(t, cliVibeEntryContext(t, decided), map[string]any{ + "running_tool_name": "compile", + "running_tool_phase": "WaitingForMainThread", + "running_tool_elapsed_seconds": float64(11), + "resend_interval_ms": float64(0), + }) + + complete := singleCliVibeEntry(t, logContent, hotReloadBusyWaitCompleteOperation) + if complete["level"] != "INFO" { + t.Fatalf("complete entry level must be INFO: %#v", complete) + } + assertSharedCliVibeCorrelationID(t, sent[0], complete) + completeContext := cliVibeEntryContext(t, complete) + assertCliVibeContextValues(t, completeContext, map[string]any{ + "second_correlation_id": vibeLogContextString(t, sent[1], "correlation_id"), + "ready": true, + "resends": float64(0), + "second_result": true, + "second_success": true, + "second_outcome": "Applied", + }) + if _, isNumber := completeContext["waited_ms"].(float64); !isNumber { + t.Fatalf("waited_ms = %#v, want a number", completeContext["waited_ms"]) + } + assertSharedCliVibeCorrelationID(t, sent[1], singleCliVibeEntry(t, logContent, "cli_hot_reload_editor_ready_retry_decided")) + assertSharedCliVibeCorrelationID(t, sent[1], singleCliVibeEntry(t, logContent, "cli_hot_reload_compile_fallback_decided")) +} + +// Verifies an error other than busy is reported as before, with no wait. +func TestRunHotReloadWritesANonBusyErrorWithoutWaiting(t *testing.T) { + enableCliVibeLog(t) + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + projectRoot := t.TempDir() + run := runScriptedHotReload(t, context.Background(), projectRoot, busyWaitParams(), + []scriptedIPCStep{ + {method: hotReloadCommandName, rpcError: `{"code":-32603,"message":"boom","data":{"type":"internal_error","message":"boom"}}`}, + }, + editorReadyRetryNoCompile()) + + if len(run.requests) != 1 { + t.Fatalf("requests = %d, want 1", len(run.requests)) + } + assertFailedWithEmptyStdout(t, run, "boom") + if strings.Contains(run.stderr, "waiting for it to finish") { + t.Fatalf("stderr must not carry the waiting line: %q", run.stderr) + } + assertNoCliVibeEntry(t, readOnlyCliVibeLog(t, projectRoot), hotReloadBusyWaitDecidedOperation) +} + +// Verifies the running tool's name is read from the busy data, and anything unreadable falls back +// to a generic name. +func TestHotReloadBusyRunningToolNameFallsBackWhenTheBusyDataNamesNone(t *testing.T) { + busyError := func(rpcError string) error { + parsed := struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + }{} + if err := json.Unmarshal([]byte(rpcError), &parsed); err != nil { + t.Fatal(err) + } + return &unityipc.RPCError{Code: parsed.Code, Message: parsed.Message, Data: parsed.Data} + } + cases := map[string]struct { + err error + want string + }{ + "named": {err: busyError(busyWaitRunningCompile), want: "compile"}, + "unnamed": {err: busyError(busyWaitRunningUnnamed), want: hotReloadBusyUnknownToolName}, + "not rpc": {err: errors.New("x"), want: hotReloadBusyUnknownToolName}, + } + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + if got := hotReloadBusyRunningToolName(testCase.err); got != testCase.want { + t.Fatalf("hotReloadBusyRunningToolName = %q, want %q", got, testCase.want) + } + }) + } +} + +// Verifies --status waits the same way and carries the note, without creating a Timing the +// status answer never has. +func TestRunHotReloadStatusWaitsForTheRunningCommandToo(t *testing.T) { + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + steps := busyWaitT1Steps() + steps[len(steps)-1] = scriptedIPCStep{method: hotReloadCommandName, result: `{"Success":true,"Outcome":"Status","ActivePatchTotal":0}`} + run := runScriptedHotReload(t, context.Background(), t.TempDir(), map[string]any{"Status": true}, steps, + editorReadyRetryNoCompile()) + + assertHotReloadRequestsUnchanged(t, run, 2) + if status := run.hotReloadRequests()[1].params["Status"]; status != true { + t.Fatalf("second request Status = %#v, want true", status) + } + if run.code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr=%s", run.code, run.stderr) + } + fields := run.stdoutFields(t) + assertBusyWaitNote(t, fields, "'compile'") + if timing, present := fields["Timing"]; present { + t.Fatalf("Timing must stay absent on a status answer: %#v", timing) + } +} + +// Verifies an answer after the busy wait that is not a JSON object fails the command. +func TestRunHotReloadFailsWhenTheApplyAfterTheBusyWaitIsNotAnObject(t *testing.T) { + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + run := runScriptedHotReload(t, context.Background(), t.TempDir(), busyWaitParams(), + []scriptedIPCStep{ + {method: hotReloadCommandName, rpcError: busyWaitRunningCompile}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: `null`}, + }, + editorReadyRetryNoCompile()) + + assertFailedWithEmptyStdout(t, run, "must be a JSON object") +} + +// Verifies a transport failure of the request sent after the busy wait is reported as such. +func TestRunHotReloadReportsATransportFailureOfTheApplyAfterTheBusyWait(t *testing.T) { + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + run := runScriptedHotReload(t, context.Background(), t.TempDir(), busyWaitParams(), + []scriptedIPCStep{ + {method: hotReloadCommandName, rpcError: busyWaitRunningCompile}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, drop: true}, + }, + editorReadyRetryNoCompile()) + + assertFailedWithEmptyStdout(t, run, "UNITY_DISCONNECTED_AFTER_DISPATCH") +} + +// Verifies a request with no files is sent again as it was, and the Editor's failure after the +// wait is passed through with the note. +func TestRunHotReloadPassesTheEditorAnswerThroughAfterTheBusyWaitWhenNoFilesWereGiven(t *testing.T) { + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + run := runScriptedHotReload(t, context.Background(), t.TempDir(), map[string]any{}, + []scriptedIPCStep{ + {method: hotReloadCommandName, rpcError: busyWaitRunningCompile}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: `{"Success":false,"Outcome":"Failed","RetryAfterEditorReady":false,` + + `"CompileFallback":"NotNeeded","Message":"no changed files"}`}, + }, + editorReadyRetryNoCompile()) + + assertHotReloadRequestsUnchanged(t, run, 2) + if _, present := run.hotReloadRequests()[1].params["Files"]; present { + t.Fatalf("second request must not carry Files: %#v", run.hotReloadRequests()[1].params) + } + if run.code != 1 { + t.Fatalf("exit code = %d, want 1", run.code) + } + fields := run.stdoutFields(t) + if fields["Success"] != false { + t.Fatalf("Success = %#v, want false", fields["Success"]) + } + assertBusyWaitNote(t, fields, "'compile'") +} + +// Verifies the fallback compile still runs when the answer after the busy wait asks for it, and +// the busy wait's note and wait time survive the merge. +func TestRunHotReloadRunsTheFallbackCompileAfterTheBusyWait(t *testing.T) { + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + run := runScriptedHotReload(t, context.Background(), t.TempDir(), busyWaitParams(), + []scriptedIPCStep{ + {method: hotReloadCommandName, rpcError: busyWaitRunningCompile}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: busyWaitFallbackRequested}, + }, + compileExecutionResult{result: json.RawMessage(`{"Success":true,"Message":"ok"}`), exitCode: 0}) + + if run.compileCalls != 1 { + t.Fatalf("compile calls = %d, want 1\nstderr=%s", run.compileCalls, run.stderr) + } + fields := run.stdoutFields(t) + if fields["Outcome"] != "ReplacedByCompile" { + t.Fatalf("Outcome = %#v, want ReplacedByCompile", fields["Outcome"]) + } + assertBusyWaitNote(t, fields, "'compile'") + editorReadyWaitMs(t, fields) + if timing, _ := fields["Timing"].(map[string]any); timing["FallbackCompileMs"] == nil { + t.Fatalf("Timing.FallbackCompileMs missing: %#v", fields["Timing"]) + } +} + +// Verifies the request is sent again while a cancelled execute-dynamic-code holds the Editor, since +// the Editor takes that slot back only when a tool request arrives, and the resend that gets in is +// the answer. +func TestRunHotReloadSendsAgainWhileACancelledExecuteDynamicCodeHoldsTheEditor(t *testing.T) { + enableCliVibeLog(t) + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + projectRoot := t.TempDir() + run := runScriptedHotReload(t, context.Background(), projectRoot, busyWaitParams(), + []scriptedIPCStep{ + {method: hotReloadCommandName, rpcError: busyWaitRunningDynamicCode}, + {method: editorStatusBridgeCommandName, result: busyWaitStatusBusyDynamicCode}, + {method: hotReloadCommandName, rpcError: busyWaitRunningDynamicCode}, + {method: editorStatusBridgeCommandName, result: busyWaitStatusBusyDynamicCode}, + {method: hotReloadCommandName, result: busyWaitApplied}, + }, + editorReadyRetryNoCompile()) + + assertHotReloadRequestsUnchanged(t, run, 3) + if statuses := run.statusRequests(); statuses != 2 { + t.Fatalf("status requests = %d, want 2", statuses) + } + if run.code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr=%s", run.code, run.stderr) + } + fields := run.stdoutFields(t) + if fields["Outcome"] != "Applied" { + t.Fatalf("Outcome = %#v, want Applied", fields["Outcome"]) + } + assertBusyWaitNote(t, fields, "'execute-dynamic-code'", "for it to finish") + editorReadyWaitMs(t, fields) + + logContent := readOnlyCliVibeLog(t, projectRoot) + sent := cliVibeEntriesForOperation(t, logContent, "cli_tool_request_sent") + if len(sent) != 3 { + t.Fatalf("cli_tool_request_sent entries = %d, want 3", len(sent)) + } + complete := cliVibeEntryContext(t, singleCliVibeEntry(t, logContent, hotReloadBusyWaitCompleteOperation)) + assertCliVibeContextValues(t, complete, map[string]any{ + "resends": float64(2), + "ready": true, + "second_correlation_id": vibeLogContextString(t, sent[2], "correlation_id"), + }) +} + +// Verifies no request is sent again before the resend interval has passed, even while +// execute-dynamic-code holds the Editor. +func TestRunHotReloadDoesNotSendAgainBeforeTheResendInterval(t *testing.T) { + enableCliVibeLog(t) + options := fastEditorReadyWaitOptions() + options.busyResendInterval = time.Hour + useFastEditorReadyWait(t, options) + projectRoot := t.TempDir() + run := runScriptedHotReload(t, context.Background(), projectRoot, busyWaitParams(), + []scriptedIPCStep{ + {method: hotReloadCommandName, rpcError: busyWaitRunningDynamicCode}, + {method: editorStatusBridgeCommandName, result: busyWaitStatusBusyDynamicCode}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: busyWaitApplied}, + }, + editorReadyRetryNoCompile()) + + assertHotReloadRequestsUnchanged(t, run, 2) + complete := cliVibeEntryContext(t, singleCliVibeEntry(t, readOnlyCliVibeLog(t, projectRoot), hotReloadBusyWaitCompleteOperation)) + assertCliVibeContextValues(t, complete, map[string]any{"resends": float64(0)}) +} 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 4a66fffef1..03d20f3ccd 100644 --- a/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback.go +++ b/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback.go @@ -13,6 +13,7 @@ import ( "github.com/hatayama/unity-cli-loop/common/clicore" clierrors "github.com/hatayama/unity-cli-loop/common/errors" "github.com/hatayama/unity-cli-loop/common/unityipc" + "github.com/hatayama/unity-cli-loop/common/vibelog" ) // Named here rather than in cli/common because only this dispatch branch needs the name. @@ -33,6 +34,11 @@ const ( hotReloadFallbackCompileMsField = "FallbackCompileMs" ) +const ( + hotReloadCompileFallbackDecidedOperation = "cli_hot_reload_compile_fallback_decided" + hotReloadCompileFallbackCompleteOperation = "cli_hot_reload_compile_fallback_complete" +) + // Raw JSON values, because the response fields are edited as encoded JSON. const ( hotReloadOutcomeReplacedByCompileJSON = `"ReplacedByCompile"` @@ -85,11 +91,22 @@ func runHotReloadWithCompileFallback( stdout io.Writer, stderr io.Writer, ) int { - result := runPlainTool(ctx, connection, hotReloadCommandName, params, stderr) - if len(result.result) == 0 { - return result.exitCode + sent := sendHotReloadWaitingForBusyEditor(ctx, connection, params, stderr) + if sent.finished { + return sent.exitCode } - if !isHotReloadCompileFallbackRequested(result.result) { + result := sent.result + retry := retryHotReloadAfterEditorReady(ctx, connection, params, stdout, stderr, result) + if retry.finished { + return retry.exitCode + } + result = retry.result + // Why the request's ID: the reader joins the fallback entries to the request's + // cli_tool_request_sent by it. + correlationID := result.correlationID + requested := isHotReloadCompileFallbackRequested(result.result) + logHotReloadCompileFallbackDecided(connection, correlationID, requested, result.result) + if !requested { clicore.WriteJSON(stdout, result.result) return result.exitCode } @@ -98,6 +115,7 @@ func runHotReloadWithCompileFallback( compileResult := hotReloadFallbackCompile(ctx, connection, stderr) compileElapsed := time.Since(compileStarted) if len(compileResult.result) == 0 { + logHotReloadCompileFallbackComplete(connection, correlationID, compileElapsed, compileResult, false) // The transport failure is already classified on stderr; the reload itself still happened. clicore.WriteJSON(stdout, result.result) return compileResult.exitCode @@ -107,6 +125,7 @@ func runHotReloadWithCompileFallback( if err == nil { merged, err = addHotReloadFallbackCompileTiming(merged, compileElapsed) } + logHotReloadCompileFallbackComplete(connection, correlationID, compileElapsed, compileResult, err == nil) if err != nil { clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ ProjectRoot: connection.ProjectRoot, @@ -130,6 +149,105 @@ func isHotReloadCompileFallbackRequested(raw []byte) bool { return answer.CompileFallback == hotReloadCompileFallbackRequestedValue } +// Written whether or not the fallback was requested, so the log shows a fallback that did not run +// as well as one that did. +func logHotReloadCompileFallbackDecided(connection unityipc.Connection, correlationID string, requested bool, raw []byte) { + writePlainToolVibeLog(connection.ProjectRoot, func() vibelog.CLIVibeLogEntry { + return vibelog.CLIVibeLogEntry{ + Level: "INFO", + Operation: hotReloadCompileFallbackDecidedOperation, + Message: "Decided whether hot reload falls back to a compile.", + Context: hotReloadCompileFallbackDecidedContext(correlationID, requested, raw), + CorrelationID: correlationID, + } + }) +} + +// Reads how the reload went as flags, counts and durations only: the warnings and the message are +// text about the project's code, which the log never carries. +func hotReloadCompileFallbackDecidedContext(correlationID string, requested bool, raw []byte) map[string]any { + entryContext := map[string]any{ + "correlation_id": correlationID, + "requested": requested, + "parse_error": false, + } + var fields map[string]json.RawMessage + // Why a nil map is a parse error too: JSON null decodes without an error and leaves the map nil, + // and injectHotReloadCompileFallback rejects it as not an object. + if err := json.Unmarshal(raw, &fields); err != nil || fields == nil { + entryContext["parse_error"] = true + return entryContext + } + // Only true and false count: JSON null would decode into a bool as false. + switch string(fields[hotReloadSuccessField]) { + case "true": + entryContext["success"] = true + case "false": + entryContext["success"] = false + } + outcome, _, _ := readHotReloadStringField(fields, hotReloadOutcomeField) + entryContext["outcome"] = outcome + entryContext["warnings_count"] = hotReloadWarningCount(fields) + if timing, isObject := readHotReloadTimingNumbers(fields); isObject { + entryContext["timing"] = timing + } + return entryContext +} + +// Keeps only the numbers of a Timing object, which are the phase durations in milliseconds, and +// reports false when Timing is missing or not an object. +func readHotReloadTimingNumbers(fields map[string]json.RawMessage) (map[string]float64, bool) { + raw := fields[hotReloadTimingField] + if len(raw) == 0 || raw[0] != '{' { + return nil, false + } + timing := map[string]any{} + if err := json.Unmarshal(raw, &timing); err != nil { + return nil, false + } + numbers := map[string]float64{} + for phase, value := range timing { + if milliseconds, isNumber := value.(float64); isNumber { + numbers[phase] = milliseconds + } + } + return numbers, true +} + +// Written once on every way out of a fallback that ran, after the merge, so the entry agrees with +// what the command reported. +func logHotReloadCompileFallbackComplete( + connection unityipc.Connection, + correlationID string, + elapsed time.Duration, + compileResult compileExecutionResult, + merged bool, +) { + writePlainToolVibeLog(connection.ProjectRoot, func() vibelog.CLIVibeLogEntry { + // Why merged counts: a compile that exits 0 with a result that cannot be merged still fails + // the command. + succeeded := compileResult.exitCode == 0 && len(compileResult.result) > 0 && merged + level := "INFO" + if !succeeded { + level = "ERROR" + } + return vibelog.CLIVibeLogEntry{ + Level: level, + Operation: hotReloadCompileFallbackCompleteOperation, + Message: "Finished the compile hot reload fell back to.", + Context: map[string]any{ + "correlation_id": correlationID, + "elapsed_ms": elapsed.Milliseconds(), + "compile_exit_code": compileResult.exitCode, + "compile_result_bytes": len(compileResult.result), + "merged": merged, + "succeeded": succeeded, + }, + CorrelationID: correlationID, + } + }) +} + func injectHotReloadCompileFallback(raw json.RawMessage, compileRaw json.RawMessage) ([]byte, error) { fields := map[string]json.RawMessage{} if err := json.Unmarshal(raw, &fields); err != nil { @@ -183,6 +301,11 @@ func injectHotReloadCompileFallback(raw json.RawMessage, compileRaw json.RawMess // 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) { + return addHotReloadTimingMs(raw, hotReloadFallbackCompileMsField, elapsed) +} + +// addHotReloadTimingMs adds one CLI-side duration to Timing under name, in milliseconds. +func addHotReloadTimingMs(raw []byte, name string, elapsed time.Duration) ([]byte, error) { fields := map[string]json.RawMessage{} if err := json.Unmarshal(raw, &fields); err != nil { return nil, err @@ -195,7 +318,7 @@ func addHotReloadFallbackCompileTiming(raw []byte, elapsed time.Duration) ([]byt return nil, err } } - timing[hotReloadFallbackCompileMsField] = json.RawMessage(strconv.FormatInt(elapsed.Milliseconds(), 10)) + timing[name] = json.RawMessage(strconv.FormatInt(elapsed.Milliseconds(), 10)) encoded, err := json.Marshal(timing) if err != nil { return nil, err @@ -275,13 +398,22 @@ func readHotReloadStringField(fields map[string]json.RawMessage, name string) (s // 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 { - var warnings []json.RawMessage - if err := json.Unmarshal(fields[hotReloadWarningsField], &warnings); err != nil || len(warnings) == 0 { + if hotReloadWarningCount(fields) == 0 { return hotReloadUnappliedInMethodReasons } return hotReloadUnappliedInWarnings } +// Counts the Warnings array, and counts a Warnings field that is missing, null or not an array as +// none. +func hotReloadWarningCount(fields map[string]json.RawMessage) int { + var warnings []json.RawMessage + if err := json.Unmarshal(fields[hotReloadWarningsField], &warnings); err != nil { + return 0 + } + return len(warnings) +} + func hotReloadCompileFallbackAdvice( compileSucceeded bool, unappliedPointer 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 aa03b6a8ac..eebc8a860a 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 @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "reflect" "strings" @@ -259,7 +260,7 @@ func runHotReloadWithFakeCompile( compileResult compileExecutionResult, ) (string, string, int, int) { t.Helper() - return runHotReloadWithDelayedFakeCompile(t, hotReloadResponse, compileResult, 0) + return runHotReloadWithDelayedFakeCompileInProjectRoot(t, t.TempDir(), hotReloadResponse, compileResult, 0) } // Same as runHotReloadWithFakeCompile, but the fake compile answers only after delay, so a test can @@ -271,7 +272,19 @@ func runHotReloadWithDelayedFakeCompile( delay time.Duration, ) (string, string, int, int) { t.Helper() - projectRoot := t.TempDir() + return runHotReloadWithDelayedFakeCompileInProjectRoot(t, t.TempDir(), hotReloadResponse, compileResult, delay) +} + +// Same as runHotReloadWithDelayedFakeCompile, in the given project root, so a test can read the +// vibe log the command wrote there. +func runHotReloadWithDelayedFakeCompileInProjectRoot( + t *testing.T, + projectRoot string, + hotReloadResponse string, + compileResult compileExecutionResult, + delay time.Duration, +) (string, string, int, int) { + t.Helper() listener := newLoopbackIpcListener(t) requests := make(chan map[string]any, 1) serverErr := make(chan error, 1) @@ -627,3 +640,263 @@ func assertJSONStringField(t *testing.T, fields map[string]json.RawMessage, name t.Fatalf("%s mismatch:\nwant %q\ngot %q", name, want, got) } } + +// A reload response that asks for the fallback, carrying text no log entry may copy: the warnings, +// the message, and a Timing field that is not a number. +const hotReloadVibeLogRequestedResponse = `{"Success":false,"Outcome":"Failed","CompileFallback":"Requested",` + + `"Warnings":["` + plainToolLogSentinel + ` first","` + plainToolLogSentinel + ` second"],` + + `"Timing":{"AnalysisMs":7,"TotalMs":12,"Note":"` + plainToolLogSentinel + `"},` + + `"Message":"` + plainToolLogSentinel + `"}` + +// Verifies a run that asks for the fallback logs the decision and the compile's completion under +// one correlation ID, besides the reload's own request and response, and copies no response text. +func TestRunHotReloadWritesFallbackDecidedAndCompleteVibeLogs(t *testing.T) { + enableCliVibeLog(t) + projectRoot := t.TempDir() + + _, _, _, code := runHotReloadWithDelayedFakeCompileInProjectRoot( + t, + projectRoot, + hotReloadVibeLogRequestedResponse, + compileExecutionResult{result: json.RawMessage(`{"Success":true,"Message":"ok"}`), exitCode: 0}, + 0) + + if code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + logContent := readOnlyCliVibeLog(t, projectRoot) + requestSent := singleCliVibeEntry(t, logContent, "cli_tool_request_sent") + singleCliVibeEntry(t, logContent, "cli_tool_response_received") + decided := singleCliVibeEntry(t, logContent, "cli_hot_reload_compile_fallback_decided") + // The reader joins the fallback entries to the request they follow by this ID. + assertSharedCliVibeCorrelationID(t, requestSent, decided) + complete := singleCliVibeEntry(t, logContent, "cli_hot_reload_compile_fallback_complete") + assertCliVibeEntryLevel(t, decided, "INFO") + assertCliVibeEntryLevel(t, complete, "INFO") + decidedContext := cliVibeEntryContext(t, decided) + assertCliVibeContextValues(t, decidedContext, map[string]any{ + "requested": true, + "parse_error": false, + "success": false, + "outcome": "Failed", + "warnings_count": float64(2), + }) + if timing := fmt.Sprint(decidedContext["timing"]); timing != "map[AnalysisMs:7 TotalMs:12]" { + t.Fatalf("timing = %s, want only the numeric phases", timing) + } + completeContext := cliVibeEntryContext(t, complete) + assertCliVibeContextValues(t, completeContext, map[string]any{ + "merged": true, + "succeeded": true, + "compile_exit_code": float64(0), + }) + if resultBytes, _ := completeContext["compile_result_bytes"].(float64); resultBytes <= 0 { + t.Fatalf("compile_result_bytes = %#v, want a positive number", completeContext["compile_result_bytes"]) + } + if _, ok := completeContext["elapsed_ms"].(float64); !ok { + t.Fatalf("elapsed_ms = %#v, want a number", completeContext["elapsed_ms"]) + } + assertSharedCliVibeCorrelationID(t, decided, complete) + assertCliVibeLogOmitsTheSentinel(t, logContent) +} + +// Verifies a run that needs no fallback still logs the decision, so the log shows the fallback was +// weighed and not run, and logs each field the response lacks as absent. +func TestRunHotReloadWritesFallbackDecidedWhenNotRequested(t *testing.T) { + enableCliVibeLog(t) + projectRoot := t.TempDir() + + runHotReloadWithDelayedFakeCompileInProjectRoot( + t, + projectRoot, + `{"Success":true,"CompileFallback":"NotNeeded"}`, + compileExecutionResult{result: json.RawMessage(`{"Success":true}`), exitCode: 0}, + 0) + + logContent := readOnlyCliVibeLog(t, projectRoot) + decidedContext := cliVibeEntryContext(t, singleCliVibeEntry(t, logContent, "cli_hot_reload_compile_fallback_decided")) + assertCliVibeContextValues(t, decidedContext, map[string]any{ + "requested": false, + "parse_error": false, + "success": true, + "outcome": "", + "warnings_count": float64(0), + }) + assertCliVibeContextOmits(t, decidedContext, "timing") + assertNoCliVibeEntry(t, logContent, "cli_hot_reload_compile_fallback_complete") +} + +// Verifies a reload response that is not an object is logged as a parse error that requested +// nothing, with no field read from it. +func TestRunHotReloadWritesFallbackDecidedWithParseErrorForANullResponse(t *testing.T) { + enableCliVibeLog(t) + projectRoot := t.TempDir() + + runHotReloadWithDelayedFakeCompileInProjectRoot( + t, + projectRoot, + `null`, + compileExecutionResult{result: json.RawMessage(`{"Success":true}`), exitCode: 0}, + 0) + + logContent := readOnlyCliVibeLog(t, projectRoot) + decidedContext := cliVibeEntryContext(t, singleCliVibeEntry(t, logContent, "cli_hot_reload_compile_fallback_decided")) + assertCliVibeContextValues(t, decidedContext, map[string]any{ + "requested": false, + "parse_error": true, + }) + assertCliVibeContextOmits(t, decidedContext, "success", "outcome", "warnings_count", "timing") + assertNoCliVibeEntry(t, logContent, "cli_hot_reload_compile_fallback_complete") +} + +// Verifies the decision logs no success for an answer whose Success is missing or null, rather than +// a false the Editor never sent. +func TestRunHotReloadWritesFallbackDecidedWithoutSuccessWhenTheAnswerHasNone(t *testing.T) { + answers := map[string]string{ + "missing": `{"CompileFallback":"NotNeeded"}`, + "null": `{"Success":null,"CompileFallback":"NotNeeded"}`, + } + for name, answer := range answers { + t.Run(name, func(t *testing.T) { + enableCliVibeLog(t) + projectRoot := t.TempDir() + + runHotReloadWithDelayedFakeCompileInProjectRoot( + t, + projectRoot, + answer, + compileExecutionResult{result: json.RawMessage(`{"Success":true}`), exitCode: 0}, + 0) + + logContent := readOnlyCliVibeLog(t, projectRoot) + decidedContext := cliVibeEntryContext(t, singleCliVibeEntry(t, logContent, "cli_hot_reload_compile_fallback_decided")) + assertCliVibeContextValues(t, decidedContext, map[string]any{ + "requested": false, + "parse_error": false, + }) + assertCliVibeContextOmits(t, decidedContext, "success") + }) + } +} + +// Verifies a reload request that fails logs the request and its failure but no fallback decision: +// no answer came back, so there was nothing to decide. +func TestRunHotReloadWritesNoFallbackDecisionWhenTheReloadRequestFails(t *testing.T) { + enableCliVibeLog(t) + projectRoot := t.TempDir() + original := hotReloadFallbackCompile + t.Cleanup(func() { hotReloadFallbackCompile = original }) + hotReloadFallbackCompile = func(context.Context, unityipc.Connection, io.Writer) compileExecutionResult { + t.Fatal("the fallback compile must not run after a failed reload request") + return compileExecutionResult{} + } + server := startFakeUnityServer(t, projectRoot, hotReloadCommandName, testUnityRPCFailureResponse) + var stdout, stderr bytes.Buffer + + code := runTool(context.Background(), server.connection, hotReloadCommandName, map[string]any{}, &stdout, &stderr) + + server.receivedRequest(t) + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + logContent := readOnlyCliVibeLog(t, projectRoot) + singleCliVibeEntry(t, logContent, "cli_tool_request_sent") + singleCliVibeEntry(t, logContent, "cli_tool_request_failed") + assertNoCliVibeEntry(t, logContent, "cli_hot_reload_compile_fallback_decided") + assertNoCliVibeEntry(t, logContent, "cli_hot_reload_compile_fallback_complete") +} + +// Verifies a fallback compile that failed is logged as an error, though its result was merged into +// the response. +func TestRunHotReloadWritesFallbackCompleteAsErrorWhenTheCompileFails(t *testing.T) { + enableCliVibeLog(t) + + complete, _ := runHotReloadFallbackAndReadTheCompleteEntry( + t, + compileExecutionResult{result: json.RawMessage(`{"Success":false,"Errors":[]}`), exitCode: 1}) + + assertCliVibeEntryLevel(t, complete, "ERROR") + assertCliVibeContextValues(t, cliVibeEntryContext(t, complete), map[string]any{ + "merged": true, + "succeeded": false, + "compile_exit_code": float64(1), + }) +} + +// Verifies a fallback compile that returned nothing is logged as an error that merged nothing, +// even with exit code 0. +func TestRunHotReloadWritesFallbackCompleteAsErrorWhenTheCompileReturnsNothing(t *testing.T) { + enableCliVibeLog(t) + + complete, _ := runHotReloadFallbackAndReadTheCompleteEntry(t, compileExecutionResult{}) + + assertCliVibeEntryLevel(t, complete, "ERROR") + assertCliVibeContextValues(t, cliVibeEntryContext(t, complete), map[string]any{ + "merged": false, + "succeeded": false, + "compile_exit_code": float64(0), + "compile_result_bytes": float64(0), + }) +} + +// Verifies a fallback compile whose result cannot be merged is logged as an error, matching the +// command's exit code 1, although the compile itself exited 0. +func TestRunHotReloadWritesFallbackCompleteAsErrorWhenTheCompileResultIsUndecodable(t *testing.T) { + enableCliVibeLog(t) + + complete, code := runHotReloadFallbackAndReadTheCompleteEntry( + t, + compileExecutionResult{result: json.RawMessage(`[1]`), exitCode: 0}) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + assertCliVibeEntryLevel(t, complete, "ERROR") + assertCliVibeContextValues(t, cliVibeEntryContext(t, complete), map[string]any{ + "merged": false, + "succeeded": false, + "compile_exit_code": float64(0), + }) +} + +// Runs a reload that asks for the fallback against the given compile result, and returns the one +// completion entry it logged with the command's exit code. +func runHotReloadFallbackAndReadTheCompleteEntry(t *testing.T, compileResult compileExecutionResult) (map[string]any, int) { + t.Helper() + projectRoot := t.TempDir() + _, _, compileCalls, code := runHotReloadWithDelayedFakeCompileInProjectRoot( + t, + projectRoot, + hotReloadVibeLogRequestedResponse, + compileResult, + 0) + if compileCalls != 1 { + t.Fatalf("compile call count = %d, want 1", compileCalls) + } + return singleCliVibeEntry(t, readOnlyCliVibeLog(t, projectRoot), "cli_hot_reload_compile_fallback_complete"), code +} + +func assertCliVibeContextValues(t *testing.T, contextMap map[string]any, want map[string]any) { + t.Helper() + for key, value := range want { + if contextMap[key] != value { + t.Fatalf("context %s = %#v, want %#v\n%#v", key, contextMap[key], value, contextMap) + } + } +} + +func assertCliVibeContextOmits(t *testing.T, contextMap map[string]any, keys ...string) { + t.Helper() + for _, key := range keys { + if value, present := contextMap[key]; present { + t.Fatalf("context %s must be absent, got %#v", key, value) + } + } +} + +func assertNoCliVibeEntry(t *testing.T, logContent string, operation string) { + t.Helper() + if entries := cliVibeEntriesForOperation(t, logContent, operation); len(entries) != 0 { + t.Fatalf("%s entries = %d, want 0", operation, len(entries)) + } +} diff --git a/cli/project-runner/internal/projectrunner/hot_reload_editor_ready_retry.go b/cli/project-runner/internal/projectrunner/hot_reload_editor_ready_retry.go new file mode 100644 index 0000000000..cf96c6844b --- /dev/null +++ b/cli/project-runner/internal/projectrunner/hot_reload_editor_ready_retry.go @@ -0,0 +1,389 @@ +package projectrunner + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "time" + + "github.com/hatayama/unity-cli-loop/common/clicontract" + "github.com/hatayama/unity-cli-loop/common/clicore" + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/common/unityipc" + "github.com/hatayama/unity-cli-loop/common/vibelog" +) + +const ( + hotReloadRetryAfterEditorReadyField = "RetryAfterEditorReady" + hotReloadSelectedFilesField = "SelectedFiles" + hotReloadFilesParam = "Files" + hotReloadEditorReadyRetryNoteField = "EditorReadyRetryNote" + hotReloadEditorReadyWaitMsField = "EditorReadyWaitMs" +) + +const ( + hotReloadEditorReadyRetryDecidedOperation = "cli_hot_reload_editor_ready_retry_decided" + hotReloadEditorReadyRetryCompleteOperation = "cli_hot_reload_editor_ready_retry_complete" +) + +const ( + // %d is the whole seconds this command waited for the Editor. + hotReloadEditorReadyRetryNoteFormat = "The first apply was refused because the Editor was compiling or importing, so this command waited %ds for the Editor to settle and applied the same request again; every other field describes the second apply." + hotReloadEditorReadyGaveUpNoteFormat = "The apply was refused because the Editor was compiling or importing; this command waited %ds for the Editor to settle, but it did not, so no second apply ran." + hotReloadEditorReadyWaitingLine = "hot-reload: the Editor is compiling or importing, so the reload was refused; waiting for it to settle, then applying again..." +) + +type hotReloadEditorReadyWaitOptions struct { + pollInterval time.Duration + budget time.Duration + probeTimeout time.Duration + // busyResendInterval is how often the busy wait sends the request again while a cancelled + // execute-dynamic-code request holds the Editor. + busyResendInterval time.Duration +} + +// Why a package variable: the fallback tests already swap hotReloadFallbackCompile, so the tests +// of this file are not parallel either. +var hotReloadEditorReadyWaitDefaults = hotReloadEditorReadyWaitOptions{ + pollInterval: time.Second, + budget: compileWaitTimeout, + probeTimeout: 5 * time.Second, + // The Editor's grace period before it takes back a cancelled request's slot. + busyResendInterval: 5 * time.Second, +} + +// hotReloadEditorReadyRetryOutcome is what the retry leaves for the fallback decision. +type hotReloadEditorReadyRetryOutcome struct { + // result is the response the fallback decision continues with. + result toolExecutionResult + // finished is true when stdout was already written and exitCode is the command's. + finished bool + exitCode int +} + +// retryHotReloadAfterEditorReady waits for the Editor to settle and applies the same files once +// more when the first response says it was refused only because the Editor was compiling or +// importing. Any other response passes through unchanged. +func retryHotReloadAfterEditorReady( + ctx context.Context, + connection unityipc.Connection, + params map[string]any, + stdout io.Writer, + stderr io.Writer, + first toolExecutionResult, +) hotReloadEditorReadyRetryOutcome { + // Why the first request's ID: the reader joins these entries to the request's + // cli_tool_request_sent by it. + correlationID := first.correlationID + requested, parseError := isHotReloadRetryAfterEditorReadyRequested(first.result) + logHotReloadEditorReadyRetryDecided(connection, correlationID, requested, parseError) + if !requested { + return hotReloadEditorReadyRetryOutcome{result: first} + } + + // A plain line rather than the tool spinner: the spinner is off on a non-TTY stderr, and on a + // TTY its Stop erases its own line, so the reader would not see why the command paused. + _, _ = fmt.Fprintln(stderr, hotReloadEditorReadyWaitingLine) + waited, ready, err := waitForEditorReady(ctx, connection, hotReloadEditorReadyWaitDefaults) + if err != nil { + clicore.WriteJSON(stdout, first.result) + writeHotReloadClassifiedError(stderr, connection, err) + logHotReloadEditorReadyRetryComplete(connection, correlationID, waited, false, nil) + return hotReloadEditorReadyRetryOutcome{finished: true, exitCode: 1} + } + if !ready { + return finishHotReloadEditorNeverReady(connection, stdout, stderr, first, waited) + } + return applyHotReloadAgain(ctx, connection, params, stdout, stderr, first, waited) +} + +// The first response stays the answer, with a note saying the Editor did not settle; no fallback +// compile runs, because the Editor that is still compiling would refuse it too. +func finishHotReloadEditorNeverReady( + connection unityipc.Connection, + stdout io.Writer, + stderr io.Writer, + first toolExecutionResult, + waited time.Duration, +) hotReloadEditorReadyRetryOutcome { + logHotReloadEditorReadyRetryComplete(connection, first.correlationID, waited, false, nil) + merged, err := injectHotReloadEditorReadyNote( + first.result, + composeHotReloadEditorReadyNote(first.result, fmt.Sprintf(hotReloadEditorReadyGaveUpNoteFormat, wholeSeconds(waited)))) + if err == nil && hasHotReloadTiming(merged) { + // The note says how long the command waited, so Timing carries that wait too. + merged, err = addHotReloadEditorReadyWaitMs(merged, first.result, waited) + } + if err != nil { + writeHotReloadClassifiedError(stderr, connection, err) + return hotReloadEditorReadyRetryOutcome{finished: true, exitCode: 1} + } + clicore.WriteJSON(stdout, merged) + return hotReloadEditorReadyRetryOutcome{finished: true, exitCode: first.exitCode} +} + +func applyHotReloadAgain( + ctx context.Context, + connection unityipc.Connection, + params map[string]any, + stdout io.Writer, + stderr io.Writer, + first toolExecutionResult, + waited time.Duration, +) hotReloadEditorReadyRetryOutcome { + // Why the selected files and not the same params: with no files given, the Editor selects the + // changed files anew, and after Unity's own compile took the edit in that selection is empty + // and fails validation; the files the first run named go through the explicit path and report + // NothingToApply instead. + retryParams := hotReloadRetryParams(params, hotReloadSelectedFiles(first.result)) + second := runPlainTool(ctx, connection, hotReloadCommandName, retryParams, stderr) + logHotReloadEditorReadyRetryComplete(connection, first.correlationID, waited, true, &second) + if len(second.result) == 0 { + // The transport failure is already classified on stderr; the first apply's answer is the + // last one the Editor gave. + clicore.WriteJSON(stdout, first.result) + return hotReloadEditorReadyRetryOutcome{finished: true, exitCode: second.exitCode} + } + + merged, err := injectHotReloadEditorReadyNote( + second.result, + composeHotReloadEditorReadyNote(first.result, fmt.Sprintf(hotReloadEditorReadyRetryNoteFormat, wholeSeconds(waited)))) + if err == nil { + merged, err = addHotReloadEditorReadyWaitMs(merged, first.result, waited) + } + if err != nil { + writeHotReloadClassifiedError(stderr, connection, err) + return hotReloadEditorReadyRetryOutcome{finished: true, exitCode: 1} + } + return hotReloadEditorReadyRetryOutcome{result: toolExecutionResult{ + result: merged, + exitCode: second.exitCode, + correlationID: second.correlationID, + }} +} + +// Only an explicit true asks for the retry: a package that does not report the field never gets a +// second apply. parseError is true when the response is not a JSON object. +func isHotReloadRetryAfterEditorReadyRequested(raw []byte) (bool, bool) { + var fields map[string]json.RawMessage + // Why a nil map is a parse error too: JSON null decodes without an error and leaves it nil. + if err := json.Unmarshal(raw, &fields); err != nil || fields == nil { + return false, true + } + return string(fields[hotReloadRetryAfterEditorReadyField]) == "true", false +} + +// A missing or unreadable list counts as none, which leaves the request as it was sent. +func hotReloadSelectedFiles(raw []byte) []string { + answer := struct { + SelectedFiles []string `json:"SelectedFiles"` + }{} + if err := json.Unmarshal(raw, &answer); err != nil { + return nil + } + return answer.SelectedFiles +} + +// hotReloadRetryParams copies the first request and, when the first run named its files, sends +// exactly those. The caller's map is left as it was. +func hotReloadRetryParams(params map[string]any, selectedFiles []string) map[string]any { + retryParams := make(map[string]any, len(params)+1) + for key, value := range params { + retryParams[key] = value + } + if len(selectedFiles) > 0 { + retryParams[hotReloadFilesParam] = selectedFiles + } + return retryParams +} + +// waitForEditorReady polls the Editor status until the Editor answers and is ready, the budget +// runs out, or ctx is cancelled. +// Why every probe error counts as "not yet": the domain reload that follows Unity's compile takes +// the server down and brings it back. +func waitForEditorReady( + ctx context.Context, + connection unityipc.Connection, + options hotReloadEditorReadyWaitOptions, +) (time.Duration, bool, error) { + startedAt := time.Now() + deadline := startedAt.Add(options.budget) + for { + if probeEditorReady(ctx, connection, options.probeTimeout) { + return time.Since(startedAt), true, nil + } + if !time.Now().Before(deadline) { + return time.Since(startedAt), false, nil + } + select { + case <-ctx.Done(): + return time.Since(startedAt), false, ctx.Err() + case <-time.After(options.pollInterval): + } + } +} + +func probeEditorReady(ctx context.Context, connection unityipc.Connection, probeTimeout time.Duration) bool { + status, answered := probeHotReloadEditorStatus(ctx, connection, probeTimeout) + return answered && classifyEditorState(status) == statusStateReady +} + +// probeHotReloadEditorStatus asks the Editor for its status once; answered is false when it did not answer +// or the answer was not readable. +func probeHotReloadEditorStatus( + ctx context.Context, + connection unityipc.Connection, + probeTimeout time.Duration, +) (editorStatusResponse, bool) { + probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + raw, err := unityipc.NewClient(connection, clicontract.ProjectRunnerVersion()).Send( + probeCtx, + editorStatusBridgeCommandName, + map[string]any{}) + if err != nil { + return editorStatusResponse{}, false + } + var response editorStatusResponse + if err := json.Unmarshal(raw, &response); err != nil { + return editorStatusResponse{}, false + } + return response, true +} + +// composeHotReloadEditorReadyNote appends sentence to the note an earlier wait in this command left +// on prior, so the note tells every wait in order. +func composeHotReloadEditorReadyNote(prior []byte, sentence string) string { + answer := struct { + EditorReadyRetryNote string `json:"EditorReadyRetryNote"` + }{} + if json.Unmarshal(prior, &answer) != nil || answer.EditorReadyRetryNote == "" { + return sentence + } + return answer.EditorReadyRetryNote + " " + sentence +} + +// addHotReloadEditorReadyWaitMs sets EditorReadyWaitMs on raw to the wait an earlier wait left on +// prior plus waited, so the field covers every wait in this command. +func addHotReloadEditorReadyWaitMs(raw []byte, prior []byte, waited time.Duration) ([]byte, error) { + answer := struct { + Timing json.RawMessage `json:"Timing"` + }{} + earlier := struct { + EditorReadyWaitMs int64 `json:"EditorReadyWaitMs"` + }{} + // Why errors are ignored: a Timing that is not an object, or a value that is not a number, + // holds no earlier wait, so it counts as zero. + if json.Unmarshal(prior, &answer) == nil && len(answer.Timing) > 0 && answer.Timing[0] == '{' { + _ = json.Unmarshal(answer.Timing, &earlier) + } + return addHotReloadTimingMs(raw, hotReloadEditorReadyWaitMsField, waited+time.Duration(earlier.EditorReadyWaitMs)*time.Millisecond) +} + +func injectHotReloadEditorReadyNote(raw []byte, note string) ([]byte, error) { + fields := map[string]json.RawMessage{} + if err := json.Unmarshal(raw, &fields); err != nil { + return nil, err + } + if fields == nil { + return nil, errors.New("hot-reload response must be a JSON object") + } + encoded, err := json.Marshal(note) + if err != nil { + return nil, err + } + fields[hotReloadEditorReadyRetryNoteField] = encoded + return json.Marshal(fields) +} + +func wholeSeconds(duration time.Duration) int64 { + return int64(duration / time.Second) +} + +func writeHotReloadClassifiedError(stderr io.Writer, connection unityipc.Connection, err error) { + clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ + ProjectRoot: connection.ProjectRoot, + Command: hotReloadCommandName, + }) +} + +// Written whether or not the retry was requested, so the log shows a retry that did not run as +// well as one that did. +func logHotReloadEditorReadyRetryDecided( + connection unityipc.Connection, + correlationID string, + requested bool, + parseError bool, +) { + writePlainToolVibeLog(connection.ProjectRoot, func() vibelog.CLIVibeLogEntry { + return vibelog.CLIVibeLogEntry{ + Level: "INFO", + Operation: hotReloadEditorReadyRetryDecidedOperation, + Message: "Decided whether hot reload waits for the Editor and applies again.", + Context: map[string]any{ + "correlation_id": correlationID, + "requested": requested, + "parse_error": parseError, + }, + CorrelationID: correlationID, + } + }) +} + +// Written once on every way out of a retry that was requested. second is nil when no second +// apply was sent. +func logHotReloadEditorReadyRetryComplete( + connection unityipc.Connection, + correlationID string, + waited time.Duration, + ready bool, + second *toolExecutionResult, +) { + writePlainToolVibeLog(connection.ProjectRoot, func() vibelog.CLIVibeLogEntry { + entryContext := map[string]any{ + "correlation_id": correlationID, + "second_correlation_id": "", + "waited_ms": waited.Milliseconds(), + "ready": ready, + "second_result": false, + } + level := "WARN" + if second != nil { + entryContext["second_correlation_id"] = second.correlationID + addHotReloadSecondApplyContext(entryContext, second.result) + } + if ready && entryContext["second_result"] == true { + level = "INFO" + } + return vibelog.CLIVibeLogEntry{ + Level: level, + Operation: hotReloadEditorReadyRetryCompleteOperation, + Message: "Finished waiting for the Editor and applying hot reload again.", + Context: entryContext, + CorrelationID: correlationID, + } + }) +} + +// Reads only Success and Outcome: the rest of the response is text about the project's code, +// which the log never carries. +func addHotReloadSecondApplyContext(entryContext map[string]any, raw []byte) { + if len(raw) == 0 { + return + } + entryContext["second_result"] = true + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil || fields == nil { + return + } + switch string(fields[hotReloadSuccessField]) { + case "true": + entryContext["second_success"] = true + case "false": + entryContext["second_success"] = false + } + outcome, _, _ := readHotReloadStringField(fields, hotReloadOutcomeField) + entryContext["second_outcome"] = outcome +} diff --git a/cli/project-runner/internal/projectrunner/hot_reload_editor_ready_retry_test.go b/cli/project-runner/internal/projectrunner/hot_reload_editor_ready_retry_test.go new file mode 100644 index 0000000000..a461ee5c88 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/hot_reload_editor_ready_retry_test.go @@ -0,0 +1,712 @@ +package projectrunner + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "reflect" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +const ( + editorReadyRetryFirstRefused = `{"Success":false,"Outcome":"Failed","RetryAfterEditorReady":true,` + + `"SelectedFiles":["Assets/A.cs","Assets/B.cs"],"CompileFallback":"Requested","Message":"refused","Timing":{"TotalMs":12}}` + editorReadyRetryStatusCompiling = `{"IsBusy":false,"HasEditorState":true,"IsCompiling":true,"IsUpdating":false,` + + `"SecondsSinceLastMainThreadTick":0.1}` + editorReadyRetryStatusReady = `{"IsBusy":false,"HasEditorState":true,"IsCompiling":false,"IsUpdating":false,` + + `"SecondsSinceLastMainThreadTick":0.1}` + editorReadyRetrySecondApplied = `{"Success":true,"Outcome":"Applied","RetryAfterEditorReady":false,` + + `"CompileFallback":"NotNeeded","Message":"applied","Timing":{"TotalMs":34}}` +) + +// scriptedIPCStep is one request the fake Editor expects and how it answers it. +type scriptedIPCStep struct { + method string + result string + // rpcError answers with this JSON-RPC error object instead of a result. + rpcError string + // drop closes the connection after reading the request, without an answer. + drop bool + // onServed runs after the answer was written, so a test can act at a known point of the run. + onServed func() +} + +type scriptedIPCRequest struct { + method string + params map[string]any +} + +// serveScriptedIPCResponses answers one connection per step in order, then reports any further +// request as unexpected, which is how a third apply is caught. +func serveScriptedIPCResponses( + listener net.Listener, + steps []scriptedIPCStep, + requests chan<- scriptedIPCRequest, + serverErr chan<- error, +) { + for index, step := range steps { + conn, err := listener.Accept() + if err != nil { + // Why silent: the listener closes at cleanup, and a run that ends early leaves the rest + // of the script unserved; the requests a test expects are checked on its own side. + return + } + request, err := readScriptedIPCRequest(conn) + if err != nil { + _ = conn.Close() + serverErr <- fmt.Errorf("step %d: %w", index, err) + return + } + requests <- request + if request.method != step.method { + _ = conn.Close() + serverErr <- fmt.Errorf("step %d: method mismatch: %s", index, request.method) + return + } + if step.drop { + _ = conn.Close() + continue + } + response := []byte(fmt.Sprintf(`{"jsonrpc":"2.0","result":%s,"id":1}`, step.result)) + if step.rpcError != "" { + response = []byte(fmt.Sprintf(`{"jsonrpc":"2.0","error":%s,"id":1}`, step.rpcError)) + } + writeErr := unityipc.Write(conn, response) + _ = conn.Close() + if writeErr != nil { + serverErr <- fmt.Errorf("step %d: %w", index, writeErr) + return + } + if step.onServed != nil { + step.onServed() + } + } + + conn, err := listener.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + request, err := readScriptedIPCRequest(conn) + if err != nil { + return + } + serverErr <- fmt.Errorf("unexpected request after the script: %s", request.method) +} + +func readScriptedIPCRequest(conn net.Conn) (scriptedIPCRequest, error) { + payload, err := unityipc.Read(bufio.NewReader(conn)) + if err != nil { + return scriptedIPCRequest{}, err + } + request := struct { + Method string `json:"method"` + Params map[string]any `json:"params"` + }{} + if err := json.Unmarshal(payload, &request); err != nil { + return scriptedIPCRequest{}, err + } + return scriptedIPCRequest{method: request.Method, params: request.Params}, nil +} + +type editorReadyRetryRun struct { + stdout string + stderr string + code int + compileCalls int + requests []scriptedIPCRequest +} + +// hotReloadRequests keeps only the apply requests, in the order they arrived. +func (run editorReadyRetryRun) hotReloadRequests() []scriptedIPCRequest { + applies := []scriptedIPCRequest{} + for _, request := range run.requests { + if request.method == hotReloadCommandName { + applies = append(applies, request) + } + } + return applies +} + +func (run editorReadyRetryRun) stdoutFields(t *testing.T) map[string]any { + t.Helper() + fields := map[string]any{} + if err := json.Unmarshal([]byte(run.stdout), &fields); err != nil { + t.Fatalf("stdout is not a JSON object: %v\n%s", err, run.stdout) + } + return fields +} + +// useFastEditorReadyWait shortens the wait for the Editor so a scripted run finishes in +// milliseconds, and restores the defaults afterwards. +func useFastEditorReadyWait(t *testing.T, options hotReloadEditorReadyWaitOptions) { + t.Helper() + original := hotReloadEditorReadyWaitDefaults + hotReloadEditorReadyWaitDefaults = options + t.Cleanup(func() { + hotReloadEditorReadyWaitDefaults = original + }) +} + +func fastEditorReadyWaitOptions() hotReloadEditorReadyWaitOptions { + return hotReloadEditorReadyWaitOptions{ + pollInterval: time.Millisecond, + budget: 2 * time.Second, + probeTimeout: time.Second, + } +} + +// runScriptedHotReload runs hot reload against the scripted Editor, counting the fallback compiles +// the run asks for. The fallback compile answers with compileResult. +func runScriptedHotReload( + t *testing.T, + ctx context.Context, + projectRoot string, + params map[string]any, + steps []scriptedIPCStep, + compileResult compileExecutionResult, +) editorReadyRetryRun { + t.Helper() + listener := newLoopbackIpcListener(t) + requests := make(chan scriptedIPCRequest, len(steps)+1) + serverErr := make(chan error, len(steps)+1) + go serveScriptedIPCResponses(listener, steps, requests, serverErr) + + original := hotReloadFallbackCompile + compileCalls := 0 + hotReloadFallbackCompile = func(context.Context, unityipc.Connection, io.Writer) compileExecutionResult { + compileCalls++ + return compileResult + } + t.Cleanup(func() { + hotReloadFallbackCompile = original + }) + + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{Network: listener.Addr().Network(), Address: listener.Addr().String()}, + ProjectRoot: projectRoot, + } + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runHotReloadWithCompileFallback(ctx, connection, params, &stdout, &stderr) + + // Why a short grace: the post-script Accept reports a third apply asynchronously. + time.Sleep(20 * time.Millisecond) + assertServerDidNotFail(t, serverErr) + received := []scriptedIPCRequest{} + for { + select { + case request := <-requests: + received = append(received, request) + continue + default: + } + break + } + return editorReadyRetryRun{ + stdout: stdout.String(), + stderr: stderr.String(), + code: code, + compileCalls: compileCalls, + requests: received, + } +} + +func editorReadyRetryNoCompile() compileExecutionResult { + return compileExecutionResult{result: json.RawMessage(`{"Success":true}`), exitCode: 0} +} + +func withFiles(params map[string]any, files []any) map[string]any { + copied := map[string]any{} + for key, value := range params { + copied[key] = value + } + copied["Files"] = files + return copied +} + +func assertSecondApplyParams(t *testing.T, run editorReadyRetryRun, want func(first map[string]any) map[string]any) { + t.Helper() + applies := run.hotReloadRequests() + if len(applies) != 2 { + t.Fatalf("hot-reload requests = %d, want 2\nstdout=%s\nstderr=%s", len(applies), run.stdout, run.stderr) + } + expected := want(applies[0].params) + if !reflect.DeepEqual(applies[1].params, expected) { + t.Fatalf("second apply params = %#v, want %#v", applies[1].params, expected) + } +} + +func assertNoteAbsent(t *testing.T, fields map[string]any) { + t.Helper() + if _, present := fields["EditorReadyRetryNote"]; present { + t.Fatalf("EditorReadyRetryNote must be absent: %#v", fields["EditorReadyRetryNote"]) + } +} + +func assertAppliedAfterTheWait(t *testing.T, run editorReadyRetryRun) { + t.Helper() + if run.code != 0 { + t.Fatalf("exit code = %d, want 0\nstdout=%s\nstderr=%s", run.code, run.stdout, run.stderr) + } + if run.compileCalls != 0 { + t.Fatalf("compile calls = %d, want 0", run.compileCalls) + } + fields := run.stdoutFields(t) + if fields["Outcome"] != "Applied" { + t.Fatalf("Outcome = %#v, want Applied", fields["Outcome"]) + } + if note, _ := fields["EditorReadyRetryNote"].(string); note == "" { + t.Fatalf("EditorReadyRetryNote = %#v, want a sentence", fields["EditorReadyRetryNote"]) + } + timing, _ := fields["Timing"].(map[string]any) + if waited, isNumber := timing["EditorReadyWaitMs"].(float64); !isNumber || waited < 0 { + t.Fatalf("Timing.EditorReadyWaitMs = %#v, want a number of at least 0", timing["EditorReadyWaitMs"]) + } + if timing["TotalMs"] != float64(34) { + t.Fatalf("Timing.TotalMs = %#v, want the second apply's 34", timing["TotalMs"]) + } + if !strings.Contains(run.stderr, "waiting for it to settle") { + t.Fatalf("stderr must say the command is waiting: %q", run.stderr) + } +} + +// Verifies a refusal that only needs the Editor to settle is applied again once it is ready, with +// the files the first run selected, and the second apply's response is the one reported. +func TestRunHotReloadAppliesAgainOnceTheEditorIsReady(t *testing.T) { + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + run := runScriptedHotReload(t, context.Background(), t.TempDir(), + map[string]any{"CompileOnSkip": "auto"}, + []scriptedIPCStep{ + {method: hotReloadCommandName, result: editorReadyRetryFirstRefused}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusCompiling}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: editorReadyRetrySecondApplied}, + }, + editorReadyRetryNoCompile()) + + assertAppliedAfterTheWait(t, run) + assertSecondApplyParams(t, run, func(first map[string]any) map[string]any { + return withFiles(first, []any{"Assets/A.cs", "Assets/B.cs"}) + }) +} + +// Verifies the second apply resends the first request unchanged when the first response names no +// files, whether or not the caller gave files. +func TestRunHotReloadSendsTheSameParamsWhenTheFirstResponseNamesNoFiles(t *testing.T) { + cases := []struct { + name string + firstResponse string + params map[string]any + }{ + { + name: "absent", + firstResponse: `{"Success":false,"Outcome":"Failed","RetryAfterEditorReady":true,"CompileFallback":"Requested","Message":"refused"}`, + params: map[string]any{"CompileOnSkip": "auto"}, + }, + { + name: "empty", + firstResponse: `{"Success":false,"Outcome":"Failed","RetryAfterEditorReady":true,"SelectedFiles":[],"CompileFallback":"Requested","Message":"refused"}`, + params: map[string]any{"CompileOnSkip": "auto"}, + }, + { + name: "absent with given files", + firstResponse: `{"Success":false,"Outcome":"Failed","RetryAfterEditorReady":true,"CompileFallback":"Requested","Message":"refused"}`, + params: map[string]any{"CompileOnSkip": "auto", "Files": []any{"Assets/Given.cs"}}, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + run := runScriptedHotReload(t, context.Background(), t.TempDir(), testCase.params, + []scriptedIPCStep{ + {method: hotReloadCommandName, result: testCase.firstResponse}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: editorReadyRetrySecondApplied}, + }, + editorReadyRetryNoCompile()) + + assertSecondApplyParams(t, run, func(first map[string]any) map[string]any { + return first + }) + }) + } +} + +// Verifies the files the caller gave are replaced by the normalized list the first run selected. +func TestRunHotReloadReplacesGivenFilesWithTheSelectedOnes(t *testing.T) { + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + run := runScriptedHotReload(t, context.Background(), t.TempDir(), + map[string]any{"Files": []any{"./Assets/A.cs"}}, + []scriptedIPCStep{ + {method: hotReloadCommandName, result: `{"Success":false,"Outcome":"Failed","RetryAfterEditorReady":true,` + + `"SelectedFiles":["Assets/A.cs"],"CompileFallback":"Requested","Message":"refused"}`}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: editorReadyRetrySecondApplied}, + }, + editorReadyRetryNoCompile()) + + assertSecondApplyParams(t, run, func(first map[string]any) map[string]any { + return withFiles(first, []any{"Assets/A.cs"}) + }) +} + +// Verifies a second apply that finds the edit already compiled in by Unity is reported as it is, +// with no compile. +func TestRunHotReloadKeepsASecondApplyThatHadNothingLeftToApply(t *testing.T) { + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + run := runScriptedHotReload(t, context.Background(), t.TempDir(), map[string]any{}, + []scriptedIPCStep{ + {method: hotReloadCommandName, result: editorReadyRetryFirstRefused}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: `{"Success":true,"Outcome":"NothingToApply","RetryAfterEditorReady":false,` + + `"CompileFallback":"NotNeeded","Message":"nothing"}`}, + }, + editorReadyRetryNoCompile()) + + if run.code != 0 || run.compileCalls != 0 { + t.Fatalf("exit code = %d, compile calls = %d, want 0 and 0\nstderr=%s", run.code, run.compileCalls, run.stderr) + } + if outcome := run.stdoutFields(t)["Outcome"]; outcome != "NothingToApply" { + t.Fatalf("Outcome = %#v, want NothingToApply", outcome) + } +} + +// Verifies the wait survives the Editor dropping the connection while it reloads its domain. +func TestRunHotReloadKeepsWaitingWhileTheEditorRestarts(t *testing.T) { + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + run := runScriptedHotReload(t, context.Background(), t.TempDir(), map[string]any{}, + []scriptedIPCStep{ + {method: hotReloadCommandName, result: editorReadyRetryFirstRefused}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusCompiling}, + {method: editorStatusBridgeCommandName, drop: true}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: editorReadyRetrySecondApplied}, + }, + editorReadyRetryNoCompile()) + + assertAppliedAfterTheWait(t, run) +} + +// Verifies a second refusal is not applied a third time; the second response decides the fallback +// compile, and both notes are reported. +func TestRunHotReloadDoesNotApplyAThirdTimeAndLetsTheSecondResponseDecideTheCompile(t *testing.T) { + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + run := runScriptedHotReload(t, context.Background(), t.TempDir(), map[string]any{}, + []scriptedIPCStep{ + {method: hotReloadCommandName, result: editorReadyRetryFirstRefused}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: editorReadyRetryFirstRefused}, + }, + compileExecutionResult{result: json.RawMessage(`{"Success":true}`), exitCode: 0}) + + if applies := len(run.hotReloadRequests()); applies != 2 { + t.Fatalf("hot-reload requests = %d, want 2", applies) + } + if run.compileCalls != 1 { + t.Fatalf("compile calls = %d, want 1", run.compileCalls) + } + if run.code != 0 { + t.Fatalf("exit code = %d, want the compile's 0\nstderr=%s", run.code, run.stderr) + } + fields := run.stdoutFields(t) + for _, name := range []string{"Compile", "CompileFallbackNote", "EditorReadyRetryNote"} { + if _, present := fields[name]; !present { + t.Fatalf("%s missing from stdout: %s", name, run.stdout) + } + } +} + +// Verifies a response that does not ask for the retry is applied once, as before. +func TestRunHotReloadDoesNotApplyAgainUnlessAsked(t *testing.T) { + cases := map[string]string{ + "absent": `{"Success":true,"Outcome":"Applied","CompileFallback":"NotNeeded","Message":"applied"}`, + "false": `{"Success":true,"Outcome":"Applied","RetryAfterEditorReady":false,"CompileFallback":"NotNeeded","Message":"applied"}`, + } + for name, response := range cases { + t.Run(name, func(t *testing.T) { + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + run := runScriptedHotReload(t, context.Background(), t.TempDir(), map[string]any{}, + []scriptedIPCStep{{method: hotReloadCommandName, result: response}}, + editorReadyRetryNoCompile()) + + if len(run.requests) != 1 || run.compileCalls != 0 || run.code != 0 { + t.Fatalf("requests = %d, compile calls = %d, exit code = %d, want 1, 0, 0", + len(run.requests), run.compileCalls, run.code) + } + assertNoteAbsent(t, run.stdoutFields(t)) + }) + } +} + +// Verifies an Editor that does not settle within the budget leaves the first response with a note +// saying so, and neither a second apply nor a compile runs. +func TestRunHotReloadGivesUpWhenTheEditorDoesNotSettle(t *testing.T) { + options := fastEditorReadyWaitOptions() + options.budget = 30 * time.Millisecond + useFastEditorReadyWait(t, options) + steps := []scriptedIPCStep{{method: hotReloadCommandName, result: editorReadyRetryFirstRefused}} + for range 50 { + steps = append(steps, scriptedIPCStep{method: editorStatusBridgeCommandName, result: editorReadyRetryStatusCompiling}) + } + run := runScriptedHotReload(t, context.Background(), t.TempDir(), map[string]any{}, steps, editorReadyRetryNoCompile()) + + if applies := len(run.hotReloadRequests()); applies != 1 { + t.Fatalf("hot-reload requests = %d, want 1", applies) + } + if run.compileCalls != 0 || run.code != 1 { + t.Fatalf("compile calls = %d, exit code = %d, want 0 and 1", run.compileCalls, run.code) + } + fields := run.stdoutFields(t) + if fields["Outcome"] != "Failed" { + t.Fatalf("Outcome = %#v, want Failed", fields["Outcome"]) + } + if note, _ := fields["EditorReadyRetryNote"].(string); !strings.Contains(note, "did not") { + t.Fatalf("EditorReadyRetryNote = %#v, want the gave-up sentence", fields["EditorReadyRetryNote"]) + } + // The note says how long the command waited, so Timing carries that wait too. + timing, _ := fields["Timing"].(map[string]any) + if waited, isNumber := timing["EditorReadyWaitMs"].(float64); !isNumber || waited < 30 { + t.Fatalf("Timing.EditorReadyWaitMs = %#v, want the wait of at least the 30 ms budget", timing["EditorReadyWaitMs"]) + } +} + +// Verifies a cancel during the wait reports the cancel and leaves the first response as it was. +func TestRunHotReloadReportsACancelWhileWaitingForTheEditor(t *testing.T) { + options := fastEditorReadyWaitOptions() + options.pollInterval = 50 * time.Millisecond + useFastEditorReadyWait(t, options) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + steps := []scriptedIPCStep{ + {method: hotReloadCommandName, result: editorReadyRetryFirstRefused}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusCompiling, onServed: cancel}, + } + for range 50 { + steps = append(steps, scriptedIPCStep{method: editorStatusBridgeCommandName, result: editorReadyRetryStatusCompiling}) + } + run := runScriptedHotReload(t, ctx, t.TempDir(), map[string]any{}, steps, editorReadyRetryNoCompile()) + + if run.code != 1 || run.compileCalls != 0 { + t.Fatalf("exit code = %d, compile calls = %d, want 1 and 0", run.code, run.compileCalls) + } + if !strings.Contains(run.stderr, context.Canceled.Error()) { + t.Fatalf("stderr must report the cancel: %q", run.stderr) + } + fields := run.stdoutFields(t) + if fields["Outcome"] != "Failed" { + t.Fatalf("Outcome = %#v, want the first response's Failed", fields["Outcome"]) + } + assertNoteAbsent(t, fields) +} + +// Verifies a second apply that gets no answer leaves the first response on stdout and fails the +// command with the transport error. +func TestRunHotReloadKeepsTheFirstResponseWhenTheSecondRequestFails(t *testing.T) { + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + run := runScriptedHotReload(t, context.Background(), t.TempDir(), map[string]any{}, + []scriptedIPCStep{ + {method: hotReloadCommandName, result: editorReadyRetryFirstRefused}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, drop: true}, + }, + editorReadyRetryNoCompile()) + + if run.code != 1 || run.compileCalls != 0 { + t.Fatalf("exit code = %d, compile calls = %d, want 1 and 0", run.code, run.compileCalls) + } + // Why the error code and not any text: the waiting line is on stderr whenever the retry runs. + if !strings.Contains(run.stderr, "UNITY_DISCONNECTED_AFTER_DISPATCH") { + t.Fatalf("stderr must carry the transport error: %q", run.stderr) + } + fields := run.stdoutFields(t) + if fields["Message"] != "refused" { + t.Fatalf("Message = %#v, want the first response's", fields["Message"]) + } + assertNoteAbsent(t, fields) +} + +// Verifies the retry writes its decision and its end to the CLI vibe log under the first request's +// correlation ID, and names the second request's ID. +func TestRunHotReloadWritesEditorReadyRetryVibeLogs(t *testing.T) { + enableCliVibeLog(t) + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + projectRoot := t.TempDir() + run := runScriptedHotReload(t, context.Background(), projectRoot, map[string]any{}, + []scriptedIPCStep{ + {method: hotReloadCommandName, result: editorReadyRetryFirstRefused}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: editorReadyRetrySecondApplied}, + }, + editorReadyRetryNoCompile()) + if run.code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr=%s", run.code, run.stderr) + } + + logContent := readOnlyCliVibeLog(t, projectRoot) + sent := cliVibeEntriesForOperation(t, logContent, "cli_tool_request_sent") + if len(sent) != 2 { + t.Fatalf("cli_tool_request_sent entries = %d, want 2\n%s", len(sent), logContent) + } + decided := singleCliVibeEntry(t, logContent, "cli_hot_reload_editor_ready_retry_decided") + complete := singleCliVibeEntry(t, logContent, "cli_hot_reload_editor_ready_retry_complete") + assertCliVibeContextValues(t, cliVibeEntryContext(t, decided), map[string]any{"requested": true}) + completeContext := cliVibeEntryContext(t, complete) + assertCliVibeContextValues(t, completeContext, map[string]any{ + "ready": true, + "second_success": true, + "second_outcome": "Applied", + }) + if _, isNumber := completeContext["waited_ms"].(float64); !isNumber { + t.Fatalf("waited_ms = %#v, want a number", completeContext["waited_ms"]) + } + assertSharedCliVibeCorrelationID(t, sent[0], decided) + assertSharedCliVibeCorrelationID(t, decided, complete) + second := vibeLogContextString(t, sent[1], "correlation_id") + if completeContext["second_correlation_id"] != second { + t.Fatalf("second_correlation_id = %#v, want the second request's %q", completeContext["second_correlation_id"], second) + } + // The fallback decision reads the second answer, so its entry follows the second request. + fallbackDecided := singleCliVibeEntry(t, logContent, "cli_hot_reload_compile_fallback_decided") + assertSharedCliVibeCorrelationID(t, sent[1], fallbackDecided) +} + +// Verifies a response that does not ask for the retry, or cannot be read, logs the decision and no +// end entry. +func TestRunHotReloadLogsAnEditorReadyRetryThatWasNotAskedFor(t *testing.T) { + cases := map[string]struct { + response string + parseError bool + }{ + "absent": {response: `{"Success":true,"Outcome":"Applied","CompileFallback":"NotNeeded","Message":"applied"}`}, + "null": {response: `null`, parseError: true}, + } + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + enableCliVibeLog(t) + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + projectRoot := t.TempDir() + runScriptedHotReload(t, context.Background(), projectRoot, map[string]any{}, + []scriptedIPCStep{{method: hotReloadCommandName, result: testCase.response}}, + editorReadyRetryNoCompile()) + + logContent := readOnlyCliVibeLog(t, projectRoot) + decided := singleCliVibeEntry(t, logContent, "cli_hot_reload_editor_ready_retry_decided") + assertCliVibeContextValues(t, cliVibeEntryContext(t, decided), map[string]any{ + "requested": false, + "parse_error": testCase.parseError, + }) + assertNoCliVibeEntry(t, logContent, "cli_hot_reload_editor_ready_retry_complete") + }) + } +} + +// Verifies a second response that is not a JSON object fails the command instead of being written +// without the note that says it is the second apply. +func TestRunHotReloadFailsWhenTheSecondResponseIsNotAnObject(t *testing.T) { + enableCliVibeLog(t) + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + projectRoot := t.TempDir() + run := runScriptedHotReload(t, context.Background(), projectRoot, map[string]any{}, + []scriptedIPCStep{ + {method: hotReloadCommandName, result: editorReadyRetryFirstRefused}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: `null`}, + }, + editorReadyRetryNoCompile()) + + if run.code != 1 || run.compileCalls != 0 { + t.Fatalf("exit code = %d, compile calls = %d, want 1 and 0", run.code, run.compileCalls) + } + if run.stdout != "" || !strings.Contains(run.stderr, "must be a JSON object") { + t.Fatalf("stdout must be empty and stderr must carry the error: stdout=%q stderr=%q", run.stdout, run.stderr) + } + complete := singleCliVibeEntry(t, readOnlyCliVibeLog(t, projectRoot), "cli_hot_reload_editor_ready_retry_complete") + completeContext := cliVibeEntryContext(t, complete) + assertCliVibeContextValues(t, completeContext, map[string]any{"second_result": true}) + assertCliVibeContextOmits(t, completeContext, "second_success", "second_outcome") +} + +// Verifies the second apply's Success is logged as false when the Editor refused it again. +func TestRunHotReloadLogsASecondRefusalAsUnsuccessful(t *testing.T) { + enableCliVibeLog(t) + useFastEditorReadyWait(t, fastEditorReadyWaitOptions()) + projectRoot := t.TempDir() + runScriptedHotReload(t, context.Background(), projectRoot, map[string]any{}, + []scriptedIPCStep{ + {method: hotReloadCommandName, result: editorReadyRetryFirstRefused}, + {method: editorStatusBridgeCommandName, result: editorReadyRetryStatusReady}, + {method: hotReloadCommandName, result: `{"Success":false,"Outcome":"Failed","CompileFallback":"NotNeeded","Message":"refused"}`}, + }, + editorReadyRetryNoCompile()) + + complete := singleCliVibeEntry(t, readOnlyCliVibeLog(t, projectRoot), "cli_hot_reload_editor_ready_retry_complete") + assertCliVibeContextValues(t, cliVibeEntryContext(t, complete), map[string]any{ + "second_success": false, + "second_outcome": "Failed", + }) +} + +// Verifies only a JSON object takes the note: anything else is reported as an error. +func TestInjectHotReloadEditorReadyNoteRejectsANonObject(t *testing.T) { + for _, raw := range []string{`null`, `[1]`, `not json`} { + if _, err := injectHotReloadEditorReadyNote([]byte(raw), "note"); err == nil { + t.Fatalf("injectHotReloadEditorReadyNote(%s) succeeded, want an error", raw) + } + } +} + +// Verifies a SelectedFiles value that is not a list of strings counts as no list, so the request +// is resent as it was. +func TestHotReloadSelectedFilesTreatsAnUnreadableListAsNone(t *testing.T) { + if files := hotReloadSelectedFiles([]byte(`{"SelectedFiles":"Assets/A.cs"}`)); files != nil { + t.Fatalf("hotReloadSelectedFiles = %#v, want nil", files) + } +} + +// Verifies a wait is added to the EditorReadyWaitMs an earlier wait left, so the field covers every +// wait in the command. +func TestAddHotReloadEditorReadyWaitMsAddsToAnEarlierWait(t *testing.T) { + cases := map[string]struct { + prior string + want float64 + }{ + "earlier wait": {prior: `{"Timing":{"EditorReadyWaitMs":1500}}`, want: 3500}, + "no Timing": {prior: `{}`, want: 2000}, + "null Timing": {prior: `{"Timing":null}`, want: 2000}, + } + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + merged, err := addHotReloadEditorReadyWaitMs([]byte(`{"Timing":{"TotalMs":1}}`), []byte(testCase.prior), 2*time.Second) + if err != nil { + t.Fatal(err) + } + fields := map[string]any{} + if err := json.Unmarshal(merged, &fields); err != nil { + t.Fatal(err) + } + timing, _ := fields["Timing"].(map[string]any) + if timing["EditorReadyWaitMs"] != testCase.want { + t.Fatalf("EditorReadyWaitMs = %#v, want %v", timing["EditorReadyWaitMs"], testCase.want) + } + }) + } +} + +// Verifies a note is appended to the note an earlier wait left, in order. +func TestComposeHotReloadEditorReadyNoteAppendsToAnEarlierNote(t *testing.T) { + if got := composeHotReloadEditorReadyNote([]byte(`{"EditorReadyRetryNote":"A."}`), "B."); got != "A. B." { + t.Fatalf("composeHotReloadEditorReadyNote = %q, want %q", got, "A. B.") + } + if got := composeHotReloadEditorReadyNote([]byte(`{}`), "B."); got != "B." { + t.Fatalf("composeHotReloadEditorReadyNote = %q, want %q", got, "B.") + } +} 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 31195146c9..b272c5cc57 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_release_recovery.go +++ b/cli/project-runner/internal/projectrunner/pause_point_release_recovery.go @@ -26,13 +26,9 @@ const compileEditorUpdatingErrorCode = "COMPILE_EDITOR_UPDATING" var sendSetCodeOptimizationDebug = sendSetCodeOptimizationDebugFromUnity -var sendFreshCompileRequest = sendWithTransientConnectionRetryAndResponseTimeout - var waitPausePointRecoveryBusyRetry = waitContextDuration -var runOneFreshCompileForPausePointRecovery = runOneFreshCompileForPausePointRecoveryDefault - -var runFreshCompileForPausePointRecovery = runFreshCompileWithBusyRetryForPausePointRecovery +var runFreshCompileForPausePointRecovery = runFreshCompileForPausePointRecoveryDefault type pausePointEnableFailureProbe struct { Success bool `json:"Success"` @@ -163,6 +159,7 @@ func waitContextDuration(ctx context.Context, duration time.Duration) error { func sendCompileWithBusyRetry( ctx context.Context, connection unityipc.Connection, + send compileSendFunc, method string, params map[string]any, progress unityipc.ProgressFunc, @@ -171,7 +168,7 @@ func sendCompileWithBusyRetry( ) (unityipc.UnitySendOutcome, error) { deadline := time.Now().Add(budget) for { - outcome, err := sendFreshCompileRequest(ctx, connection, method, params, progress, responseTimeout) + outcome, err := send(ctx, connection, method, params, progress, responseTimeout) if err == nil || !isUnityServerBusyRPCError(err) { return outcome, err } @@ -189,80 +186,58 @@ func sendCompileWithBusyRetry( } } -func runOneFreshCompileForPausePointRecoveryDefault( +func runFreshCompileForPausePointRecoveryDefault( ctx context.Context, connection unityipc.Connection, params map[string]any, stdout io.Writer, stderr io.Writer, - budget time.Duration, ) int { - deps := defaultCompileWaitDeps() - deps.sendCompile = func( - sendCtx context.Context, - sendConnection unityipc.Connection, - method string, - sendParams map[string]any, - progress unityipc.ProgressFunc, - responseTimeout time.Duration, - ) (unityipc.UnitySendOutcome, error) { - return sendCompileWithBusyRetry( - sendCtx, sendConnection, method, sendParams, progress, responseTimeout, budget) - } - return runFreshCompileWithDomainReloadWaitWithDeps(ctx, connection, params, stdout, stderr, deps) + return runFreshCompileForPausePointRecoveryWithDeps(ctx, connection, params, stdout, stderr, defaultCompileWaitDeps()) } -// Issues a fresh compile for pause-point recovery, retrying server_busy sends and -// compile results that only mean Unity is still compiling or updating. -func runFreshCompileWithBusyRetryForPausePointRecovery( +// runFreshCompileForPausePointRecoveryWithDeps compiles for pause-point recovery. The compile the +// Debug switch scheduled keeps Unity busy, so a send rejected as server_busy is sent again for as +// long as the wait allows; a request Unity lost or rejected as busy is sent again the way +// 'uloop compile' sends it again, under a new request ID. +func runFreshCompileForPausePointRecoveryWithDeps( ctx context.Context, connection unityipc.Connection, params map[string]any, stdout io.Writer, stderr io.Writer, + deps compileWaitDeps, ) int { - waitTimeout, timeoutErr := compileWaitTimeoutFromParams(params) - if timeoutErr != nil { - clierrors.WriteClassifiedError(stderr, timeoutErr, clierrors.ErrorContext{ + // Why read the wait first: it is also the budget for sending a server_busy send again. + waitTimeout, err := compileWaitTimeoutFromParams(params) + if err != nil { + clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ ProjectRoot: connection.ProjectRoot, Command: clicore.CompileCommandName, }) return 1 } - deadline := time.Now().Add(waitTimeout) - for { - var attemptOut bytes.Buffer - remaining := time.Until(deadline) - if remaining < 0 { - remaining = 0 - } - code := runOneFreshCompileForPausePointRecovery( - ctx, connection, params, &attemptOut, stderr, remaining) - if code == 0 { - return 0 - } - if !isCompileEditorBusyRejection(attemptOut.Bytes()) { - _, _ = stdout.Write(attemptOut.Bytes()) - return code - } - remaining = time.Until(deadline) - if remaining <= 0 { - _, _ = stdout.Write(attemptOut.Bytes()) - return code - } - wait := pausePointRecoveryCompileBusyRetryInterval - if wait > remaining { - wait = remaining - } - if waitErr := waitPausePointRecoveryBusyRetry(ctx, wait); waitErr != nil { - clierrors.WriteClassifiedError(stderr, waitErr, clierrors.ErrorContext{ - ProjectRoot: connection.ProjectRoot, - Command: pausePointEnableCommandName, - }) - return 1 - } + // Why take the send out first: reading it after deps.sendCompile is replaced would wrap the wrapper. + inner := compileSendOrDefault(deps) + deps.sendCompile = func( + sendCtx context.Context, + sendConnection unityipc.Connection, + method string, + sendParams map[string]any, + progress unityipc.ProgressFunc, + responseTimeout time.Duration, + ) (unityipc.UnitySendOutcome, error) { + return sendCompileWithBusyRetry( + sendCtx, sendConnection, inner, method, sendParams, progress, responseTimeout, time.Until(deadline)) + } + result := runFreshCompileRecoveringWithDeps(ctx, connection, params, stderr, deps) + // Why nothing on success: the enable response is the command's output, and the caller writes the + // compile result only when the compile failed. + if result.exitCode == 0 { + return 0 } + return writeCompileExecutionResult(stdout, result) } func recoverReleaseCodeOptimization( diff --git a/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go b/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go index ae70ea0845..42fad59be8 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go @@ -412,124 +412,6 @@ func TestRunEnablePausePointAndAwait_WhenReleaseError_RecoversAndListsSwitchWarn } } -// Verifies a COMPILE_ALREADY_IN_PROGRESS compile result is retried once, then recovery completes. -func TestCompleteEnableWithReleaseRecovery_WhenCompileAlreadyInProgressOnce_RetriesAndCompletes(t *testing.T) { - originalSwitch := sendSetCodeOptimizationDebug - originalCompile := runFreshCompileForPausePointRecovery - originalAttempt := runOneFreshCompileForPausePointRecovery - originalWait := waitPausePointRecoveryBusyRetry - t.Cleanup(func() { - sendSetCodeOptimizationDebug = originalSwitch - runFreshCompileForPausePointRecovery = originalCompile - runOneFreshCompileForPausePointRecovery = originalAttempt - waitPausePointRecoveryBusyRetry = originalWait - }) - - sendSetCodeOptimizationDebug = func(ctx context.Context, connection unityipc.Connection) error { - return nil - } - waitPausePointRecoveryBusyRetry = func(ctx context.Context, duration time.Duration) error { - return nil - } - compileAttemptCount := 0 - runOneFreshCompileForPausePointRecovery = func( - ctx context.Context, - connection unityipc.Connection, - params map[string]any, - stdout io.Writer, - stderr io.Writer, - budget time.Duration, - ) int { - compileAttemptCount++ - if compileAttemptCount == 1 { - _, _ = stdout.Write([]byte(`{"Success":false,"ErrorCode":"COMPILE_ALREADY_IN_PROGRESS"}`)) - return 1 - } - return 0 - } - runFreshCompileForPausePointRecovery = runFreshCompileWithBusyRetryForPausePointRecovery - - sendCount := 0 - var stdout bytes.Buffer - code := completeEnableWithReleaseRecovery( - context.Background(), - unityipc.Connection{ProjectRoot: t.TempDir()}, - &stdout, - io.Discard, - func(writer io.Writer) int { - sendCount++ - if sendCount == 1 { - _, _ = writer.Write([]byte(releaseCodeOptimizationEnableFailureJSON)) - return 1 - } - _, _ = writer.Write([]byte(successfulEnableJSON)) - return 0 - }, - ) - if code != 0 { - t.Fatalf("expected success, got %d with stdout %s", code, stdout.String()) - } - if compileAttemptCount != 2 { - t.Fatalf("compile attempt count mismatch: %d", compileAttemptCount) - } - if sendCount != 2 { - t.Fatalf("enable send count mismatch: %d", sendCount) - } - var payload map[string]any - if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { - t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String()) - } - warning, _ := payload["Warning"].(string) - if warning != pausePointAutoDebugSwitchWarning { - t.Fatalf("Warning mismatch: %q", warning) - } -} - -// Verifies a cancelled retry wait reports the cancel error instead of the busy compile JSON. -func TestRunFreshCompileWithBusyRetry_WhenRetryWaitCancelled_ReportsCancelNotBusyResult(t *testing.T) { - originalAttempt := runOneFreshCompileForPausePointRecovery - originalWait := waitPausePointRecoveryBusyRetry - t.Cleanup(func() { - runOneFreshCompileForPausePointRecovery = originalAttempt - waitPausePointRecoveryBusyRetry = originalWait - }) - - busyResult := []byte(`{"Success":false,"ErrorCode":"COMPILE_ALREADY_IN_PROGRESS"}`) - runOneFreshCompileForPausePointRecovery = func( - ctx context.Context, - connection unityipc.Connection, - params map[string]any, - stdout io.Writer, - stderr io.Writer, - budget time.Duration, - ) int { - _, _ = stdout.Write(busyResult) - return 1 - } - waitPausePointRecoveryBusyRetry = func(ctx context.Context, duration time.Duration) error { - return context.Canceled - } - - var stdout bytes.Buffer - var stderr bytes.Buffer - code := runFreshCompileWithBusyRetryForPausePointRecovery( - context.Background(), - unityipc.Connection{ProjectRoot: t.TempDir()}, - map[string]any{}, - &stdout, - &stderr, - ) - if code != 1 { - t.Fatalf("expected cancel exit 1, got %d", code) - } - if bytes.Contains(stdout.Bytes(), []byte("COMPILE_ALREADY_IN_PROGRESS")) { - t.Fatalf("cancelled wait must not write busy compile JSON: %s", stdout.String()) - } - if !strings.Contains(stderr.String(), "context canceled") && !strings.Contains(stderr.String(), "canceled") { - t.Fatalf("expected classified cancel error on stderr, got %s", stderr.String()) - } -} - // Verifies a failed recovery compile writes its stdout buffer and does not resend enable. func TestCompleteEnableWithReleaseRecovery_WhenCompileFails_WritesStdoutAndDoesNotResend(t *testing.T) { originalSwitch := sendSetCodeOptimizationDebug @@ -784,19 +666,15 @@ func serverBusyRPCError(t *testing.T) error { return rpcErr } -// stubFreshCompileSends replaces the compile sender with one that returns the given errors in -// order, then succeeds, and records the retry waits requested in between. -func stubFreshCompileSends(t *testing.T, errs []error, waitErr error) (*int, *[]time.Duration) { +// stubFreshCompileSends returns a compile sender that returns the given errors in order, then +// succeeds, and replaces the retry wait with one that records the waits requested in between. +func stubFreshCompileSends(t *testing.T, errs []error, waitErr error) (compileSendFunc, *int, *[]time.Duration) { t.Helper() - originalSend := sendFreshCompileRequest originalWait := waitPausePointRecoveryBusyRetry - t.Cleanup(func() { - sendFreshCompileRequest = originalSend - waitPausePointRecoveryBusyRetry = originalWait - }) + t.Cleanup(func() { waitPausePointRecoveryBusyRetry = originalWait }) sends := 0 waits := []time.Duration{} - sendFreshCompileRequest = func(context.Context, unityipc.Connection, string, map[string]any, unityipc.ProgressFunc, time.Duration) (unityipc.UnitySendOutcome, error) { + send := func(context.Context, unityipc.Connection, string, map[string]any, unityipc.ProgressFunc, time.Duration) (unityipc.UnitySendOutcome, error) { sends++ if sends <= len(errs) { return unityipc.UnitySendOutcome{}, errs[sends-1] @@ -807,14 +685,14 @@ func stubFreshCompileSends(t *testing.T, errs []error, waitErr error) (*int, *[] waits = append(waits, duration) return waitErr } - return &sends, &waits + return send, &sends, &waits } // Verifies busy compile sends are retried within the budget, with each wait capped by the time left. func TestSendCompileWithBusyRetryRetriesBusyUntilSuccess(t *testing.T) { - sends, waits := stubFreshCompileSends(t, []error{serverBusyRPCError(t)}, nil) + send, sends, waits := stubFreshCompileSends(t, []error{serverBusyRPCError(t)}, nil) - outcome, err := sendCompileWithBusyRetry(context.Background(), unityipc.Connection{}, "compile", map[string]any{}, nil, 0, time.Second) + outcome, err := sendCompileWithBusyRetry(context.Background(), unityipc.Connection{}, send, "compile", map[string]any{}, nil, 0, time.Second) if err != nil || string(outcome.Result) != `{"Success":true}` { t.Fatalf("unexpected result: outcome=%#v err=%v", outcome, err) @@ -831,153 +709,265 @@ func TestSendCompileWithBusyRetryRetriesBusyUntilSuccess(t *testing.T) { func TestSendCompileWithBusyRetryStopsRetrying(t *testing.T) { t.Run("budget exhausted", func(t *testing.T) { busy := serverBusyRPCError(t) - sends, waits := stubFreshCompileSends(t, []error{busy, busy}, nil) - _, err := sendCompileWithBusyRetry(context.Background(), unityipc.Connection{}, "compile", map[string]any{}, nil, 0, 0) + send, sends, waits := stubFreshCompileSends(t, []error{busy, busy}, nil) + _, err := sendCompileWithBusyRetry(context.Background(), unityipc.Connection{}, send, "compile", map[string]any{}, nil, 0, 0) if err != busy || *sends != 1 || len(*waits) != 0 { t.Fatalf("err=%v sends=%d waits=%v", err, *sends, *waits) } }) t.Run("non-busy error", func(t *testing.T) { failure := io.ErrUnexpectedEOF - sends, _ := stubFreshCompileSends(t, []error{failure}, nil) - _, err := sendCompileWithBusyRetry(context.Background(), unityipc.Connection{}, "compile", map[string]any{}, nil, 0, time.Minute) + send, sends, _ := stubFreshCompileSends(t, []error{failure}, nil) + _, err := sendCompileWithBusyRetry(context.Background(), unityipc.Connection{}, send, "compile", map[string]any{}, nil, 0, time.Minute) if err != failure || *sends != 1 { t.Fatalf("err=%v sends=%d", err, *sends) } }) t.Run("retry wait cancelled", func(t *testing.T) { - sends, _ := stubFreshCompileSends(t, []error{serverBusyRPCError(t)}, context.Canceled) - _, err := sendCompileWithBusyRetry(context.Background(), unityipc.Connection{}, "compile", map[string]any{}, nil, 0, time.Minute) + send, sends, _ := stubFreshCompileSends(t, []error{serverBusyRPCError(t)}, context.Canceled) + _, err := sendCompileWithBusyRetry(context.Background(), unityipc.Connection{}, send, "compile", map[string]any{}, nil, 0, time.Minute) if err != context.Canceled || *sends != 1 { t.Fatalf("err=%v sends=%d", err, *sends) } }) } -// Verifies the default recovery compile sends through the busy-retry sender with its budget: a -// busy answer is retried after one wait capped by that budget, and the following undispatched -// send failure is reported without waiting on compile status. -func TestRunOneFreshCompileForPausePointRecoveryUsesBusyRetrySender(t *testing.T) { - sends, waits := stubFreshCompileSends(t, []error{serverBusyRPCError(t), io.ErrUnexpectedEOF}, nil) +// A successful compile result. It starts the post-compile warmup, which waits for a Unity project +// these tests do not have, so a test that returns it cancels the command at that answer. +const pausePointRecoveryCompileSuccess = `{"Success":true}` + +// stubPausePointRecoveryBusyRetryWaits makes the wait between server_busy sends return at once and +// records each wait it was asked for. +func stubPausePointRecoveryBusyRetryWaits(t *testing.T) *[]time.Duration { + t.Helper() + originalWait := waitPausePointRecoveryBusyRetry + t.Cleanup(func() { waitPausePointRecoveryBusyRetry = originalWait }) + waits := []time.Duration{} + waitPausePointRecoveryBusyRetry = func(_ context.Context, duration time.Duration) error { + waits = append(waits, duration) + return nil + } + return &waits +} + +// runPausePointRecoveryCompile runs the pause-point recovery compile with the given deps and returns +// its exit code, stdout, and stderr. +func runPausePointRecoveryCompile( + t *testing.T, + ctx context.Context, + params map[string]any, + deps compileWaitDeps, +) (int, string, string) { + t.Helper() var stdout, stderr bytes.Buffer - budget := 500 * time.Millisecond + code := runFreshCompileForPausePointRecoveryWithDeps(ctx, unreachableConnection(t.TempDir()), params, &stdout, &stderr, deps) + return code, stdout.String(), stderr.String() +} - code := runOneFreshCompileForPausePointRecoveryDefault( - context.Background(), - unityipc.Connection{ProjectRoot: t.TempDir()}, - map[string]any{}, - &stdout, - &stderr, - budget, +// Verifies the recovery compile sends a request Unity lost again under a new request ID instead of +// waiting out the whole timeout, and that the successful compile leaves stdout empty. +func TestPausePointRecoveryCompileResendsWhenUnityLostTheRequest(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendDisconnected(), recoverySendAnswered()}, + []compileRecoveryAnswer{recoveryMissing()}, + []compileRecoveryAnswer{recoveryDone(pausePointRecoveryCompileSuccess)}, ) + scenario.cancelWhen(cancel, 1, 1) - if code != 1 { - t.Fatalf("exit code = %d, want 1", code) + code, stdout, stderr := runPausePointRecoveryCompile(t, ctx, map[string]any{}, scenario.deps()) + + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr:\n%s", code, stderr) } - if *sends != 2 { - t.Fatalf("sends = %d, want 2", *sends) + 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 queries := scenario.queriesOf(0); queries != 3 { + t.Fatalf("queries for the lost request = %d, want 3", queries) } - if len(*waits) != 1 || (*waits)[0] > budget { - t.Fatalf("waits = %v, want one wait no longer than %v", *waits, budget) + if stdout != "" { + t.Fatalf("a successful recovery compile must leave stdout to the enable response: %q", stdout) } - if !strings.Contains(stderr.String(), "unexpected EOF") { - t.Fatalf("stderr must report the send failure:\n%s", stderr.String()) + if strings.Contains(stderr, "COMPILE_WAIT_TIMEOUT") { + t.Fatalf("a resent compile must not report a wait timeout:\n%s", stderr) } } -// Verifies the recovery compile loop rejects an invalid timeout before compiling. -func TestRunFreshCompileWithBusyRetryRejectsInvalidTimeout(t *testing.T) { - originalAttempt := runOneFreshCompileForPausePointRecovery - t.Cleanup(func() { runOneFreshCompileForPausePointRecovery = originalAttempt }) - runOneFreshCompileForPausePointRecovery = func(context.Context, unityipc.Connection, map[string]any, io.Writer, io.Writer, time.Duration) int { - t.Fatal("compile must not run") - return 0 +// Verifies the recovery compile sends a compile Unity rejected as busy again under a new request ID +// once Unity is Ready, without the fixed wait it uses between server_busy sends. +func TestPausePointRecoveryCompileResendsAfterABusyRejection(t *testing.T) { + for _, errorCode := range []string{"COMPILE_ALREADY_IN_PROGRESS", "COMPILE_EDITOR_UPDATING"} { + t.Run(errorCode, func(t *testing.T) { + waits := stubPausePointRecoveryBusyRetryWaits(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendAnswered(), recoverySendAnswered()}, + compileRecoveryBusyRejectionAnswers(errorCode), + []compileRecoveryAnswer{recoveryDone(pausePointRecoveryCompileSuccess)}, + ) + scenario.cancelWhen(cancel, 1, 1) + + code, stdout, stderr := runPausePointRecoveryCompile(t, ctx, map[string]any{}, scenario.deps()) + + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr:\n%s", code, stderr) + } + 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 stdout != "" { + t.Fatalf("a successful recovery compile must leave stdout to the enable response: %q", stdout) + } + if len(*waits) != 0 { + t.Fatalf("a busy rejection is sent again once Unity is Ready, not after a fixed wait: %v", *waits) + } + }) } - var stdout, stderr bytes.Buffer +} - code := runFreshCompileWithBusyRetryForPausePointRecovery( - context.Background(), - unityipc.Connection{ProjectRoot: t.TempDir()}, - map[string]any{compileWaitTimeoutParam: 0}, - &stdout, - &stderr, +// Verifies a compile rejected as busy on every attempt is sent three times in all, and the last +// rejection is written to stdout as the recovery's failure. +func TestPausePointRecoveryCompileReturnsTheRejectionAfterTheAttemptLimit(t *testing.T) { + rejected := compileRecoveryBusyRejectionAnswers("COMPILE_ALREADY_IN_PROGRESS") + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendAnswered(), recoverySendAnswered(), recoverySendAnswered()}, + rejected, rejected, rejected, ) - if code != 1 || !strings.Contains(stderr.String(), "Invalid positive integer value for --timeout-seconds") { - t.Fatalf("code=%d stderr=%s", code, stderr.String()) + code, stdout, _ := runPausePointRecoveryCompile(t, context.Background(), map[string]any{}, scenario.deps()) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) } + if scenario.sendCount() != 3 { + t.Fatalf("compile sends = %d, want 3", scenario.sendCount()) + } + assertCompactJSONEqual(t, stdout, compileRecoveryRejection("COMPILE_ALREADY_IN_PROGRESS")) } -// stubRecoveryCompileAttempts makes each recovery compile attempt write the next result and -// return its code, and records retry waits. -func stubRecoveryCompileAttempts(t *testing.T, results []string, codes []int) (*int, *[]time.Duration) { - t.Helper() - originalAttempt := runOneFreshCompileForPausePointRecovery - originalWait := waitPausePointRecoveryBusyRetry - t.Cleanup(func() { - runOneFreshCompileForPausePointRecovery = originalAttempt - waitPausePointRecoveryBusyRetry = originalWait - }) - attempts := 0 - waits := []time.Duration{} - runOneFreshCompileForPausePointRecovery = func(_ context.Context, _ unityipc.Connection, _ map[string]any, stdout io.Writer, _ io.Writer, _ time.Duration) int { - attempts++ - _, _ = stdout.Write([]byte(results[attempts-1])) - return codes[attempts-1] +// Verifies a definitive compile failure is written to stdout once, as one JSON object, without a resend. +func TestPausePointRecoveryCompileWritesADefinitiveFailureOnce(t *testing.T) { + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendAnswered()}, + []compileRecoveryAnswer{recoveryDone(compileRecoveryDefinitiveResult)}, + ) + + code, stdout, _ := runPausePointRecoveryCompile(t, context.Background(), map[string]any{}, scenario.deps()) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) } - waitPausePointRecoveryBusyRetry = func(_ context.Context, duration time.Duration) error { - waits = append(waits, duration) - return nil + if scenario.sendCount() != 1 { + t.Fatalf("compile sends = %d, want 1", scenario.sendCount()) + } + fields := decodeSingleJSONObject(t, stdout) + if errorCount := string(fields["ErrorCount"]); errorCount != "1" { + t.Fatalf("ErrorCount = %s, want 1", errorCount) } - return &attempts, &waits } -// Verifies a compile result meaning Unity is still updating is retried, with the wait capped by -// the time left, and a later success returns 0 without writing the busy result. -func TestRunFreshCompileWithBusyRetryRetriesEditorUpdating(t *testing.T) { - attempts, waits := stubRecoveryCompileAttempts(t, - []string{`{"Success":false,"ErrorCode":"COMPILE_EDITOR_UPDATING"}`, `{"Success":true}`}, - []int{1, 0}) - var stdout bytes.Buffer - - code := runFreshCompileWithBusyRetryForPausePointRecovery( - context.Background(), - unityipc.Connection{ProjectRoot: t.TempDir()}, - map[string]any{compileWaitTimeoutParam: 1}, - &stdout, - io.Discard, +// Verifies a send refused as server_busy, because another tool held Unity's execution slot, is sent +// again after one wait that the time left in the compile wait caps below the retry interval. +func TestPausePointRecoveryCompileRetriesAServerBusySend(t *testing.T) { + waits := stubPausePointRecoveryBusyRetryWaits(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendAnswered()}, + []compileRecoveryAnswer{recoveryDone(pausePointRecoveryCompileSuccess)}, ) + // The refused send never reaches the scenario, so the scenario's first send is the retry. + scenario.cancelWhen(cancel, 0, 1) + deps := scenario.deps() + scriptedSend := deps.sendCompile + busy := serverBusyRPCError(t) + sends := 0 + deps.sendCompile = func( + sendCtx context.Context, + connection unityipc.Connection, + method string, + params map[string]any, + progress unityipc.ProgressFunc, + responseTimeout time.Duration, + ) (unityipc.UnitySendOutcome, error) { + sends++ + if sends == 1 { + return unityipc.UnitySendOutcome{}, busy + } + return scriptedSend(sendCtx, connection, method, params, progress, responseTimeout) + } + + // Why a 1s wait: it is shorter than the retry interval, so only a budget taken from the time left + // in the compile wait keeps the retry wait at or under 1s. + code, stdout, stderr := runPausePointRecoveryCompile(t, ctx, map[string]any{compileWaitTimeoutParam: 1}, deps) - if code != 0 || *attempts != 2 { - t.Fatalf("code=%d attempts=%d", code, *attempts) + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr:\n%s", code, stderr) + } + if sends != 2 { + t.Fatalf("sends = %d, want 2", sends) } - if len(*waits) != 1 || (*waits)[0] > time.Second { - t.Fatalf("retry wait must be capped by the 1s budget: %v", *waits) + if len(*waits) != 1 || (*waits)[0] <= 0 || (*waits)[0] > time.Second { + t.Fatalf("waits = %v, want one wait capped by the 1s compile wait", *waits) } - if stdout.Len() != 0 { - t.Fatalf("busy result must not be written: %s", stdout.String()) + if stdout != "" { + t.Fatalf("a successful recovery compile must leave stdout to the enable response: %q", stdout) } } -// Verifies a non-retryable compile failure is written to stdout once and returned without retrying. -func TestRunFreshCompileWithBusyRetryReturnsNonRetryableFailure(t *testing.T) { - failure := `{"Success":false,"ErrorCount":3}` - attempts, waits := stubRecoveryCompileAttempts(t, []string{failure}, []int{1}) - var stdout bytes.Buffer +// Verifies an invalid --timeout-seconds is rejected before anything is sent. +func TestPausePointRecoveryCompileRejectsInvalidTimeout(t *testing.T) { + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + t.Fatal("compile status must not be queried") + return compileStatusResponse{}, nil + }) + deps.sendCompile = func(context.Context, unityipc.Connection, string, map[string]any, unityipc.ProgressFunc, time.Duration) (unityipc.UnitySendOutcome, error) { + t.Fatal("compile must not be sent") + return unityipc.UnitySendOutcome{}, nil + } - code := runFreshCompileWithBusyRetryForPausePointRecovery( - context.Background(), - unityipc.Connection{ProjectRoot: t.TempDir()}, - map[string]any{}, - &stdout, - io.Discard, + code, stdout, stderr := runPausePointRecoveryCompile(t, context.Background(), map[string]any{compileWaitTimeoutParam: 0}, deps) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr, "Invalid positive integer value for --timeout-seconds") { + t.Fatalf("stderr must reject the timeout:\n%s", stderr) + } + if stdout != "" { + t.Fatalf("stdout must stay empty: %q", stdout) + } +} + +// Verifies a cancellation while waiting on compile status is reported on stderr and leaves stdout empty. +func TestPausePointRecoveryCompileReportsCancellationWhileWaiting(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + scenario := newCompileRecoveryScenario(t, + []compileRecoverySend{recoverySendDisconnected()}, + []compileRecoveryAnswer{recoveryCompiling()}, ) + scenario.cancelWhen(cancel, 0, 3) + + code, stdout, stderr := runPausePointRecoveryCompile(t, ctx, map[string]any{}, scenario.deps()) - if code != 1 || *attempts != 1 || len(*waits) != 0 { - t.Fatalf("code=%d attempts=%d waits=%v", code, *attempts, *waits) + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr, context.Canceled.Error()) { + t.Fatalf("stderr must report the cancellation:\n%s", stderr) } - if stdout.String() != failure { - t.Fatalf("stdout = %q, want %q", stdout.String(), failure) + if stdout != "" { + t.Fatalf("stdout must stay empty: %q", stdout) } } diff --git a/cli/project-runner/internal/projectrunner/plain_tool_log.go b/cli/project-runner/internal/projectrunner/plain_tool_log.go new file mode 100644 index 0000000000..7617092383 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/plain_tool_log.go @@ -0,0 +1,144 @@ +package projectrunner + +// CLI vibe log writers for the plain tool path: the request sent, and either the response received +// or the failure, joined through a shared correlation ID. They record parameter keys and sizes, +// never a parameter value or a response body, which can name files of the project or carry code. + +import ( + "errors" + "sort" + "time" + + "github.com/hatayama/unity-cli-loop/common/clicontract" + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/common/unityipc" + "github.com/hatayama/unity-cli-loop/common/vibelog" +) + +const ( + plainToolRequestSentOperation = "cli_tool_request_sent" + plainToolResponseReceivedOperation = "cli_tool_response_received" + plainToolRequestFailedOperation = "cli_tool_request_failed" +) + +// writePlainToolVibeLog mirrors writeCompileVibeLog: the entry is built only when the log is on, +// so a command run without ULOOP_DEBUG pays nothing for it. +func writePlainToolVibeLog(projectRoot string, buildEntry func() vibelog.CLIVibeLogEntry) { + if !vibelog.IsCLIVibeLogEnabled() { + return + } + + _ = vibelog.WriteCLIVibeLog(projectRoot, buildEntry()) +} + +func logPlainToolRequestSent(connection unityipc.Connection, command string, params map[string]any, correlationID string) { + writePlainToolVibeLog(connection.ProjectRoot, func() vibelog.CLIVibeLogEntry { + return vibelog.CLIVibeLogEntry{ + Level: "INFO", + Operation: plainToolRequestSentOperation, + Message: "Sent the tool request to Unity.", + Context: map[string]any{ + "command": command, + "correlation_id": correlationID, + "project_identity": vibelog.ProjectIdentity(connection.ProjectRoot), + "cli_version": clicontract.ProjectRunnerVersion(), + "param_keys": sortedParamKeys(params), + "array_lengths": paramArrayLengths(params), + }, + CorrelationID: correlationID, + } + }) +} + +func logPlainToolResponseReceived( + connection unityipc.Connection, + command string, + correlationID string, + elapsed time.Duration, + outcome unityipc.UnitySendOutcome, + result []byte, + exitCode int, +) { + writePlainToolVibeLog(connection.ProjectRoot, func() vibelog.CLIVibeLogEntry { + // Why INFO even for exit code 1: a tool that reports a failure in its response was still + // reached and answered, and the response, not this log, says what failed. + return vibelog.CLIVibeLogEntry{ + Level: "INFO", + Operation: plainToolResponseReceivedOperation, + Message: "Received the tool response from Unity.", + Context: map[string]any{ + "command": command, + "correlation_id": correlationID, + "elapsed_ms": elapsed.Milliseconds(), + "request_accepted": outcome.RequestAccepted, + "result_bytes": len(result), + "exit_code": exitCode, + }, + CorrelationID: correlationID, + } + }) +} + +func logPlainToolRequestFailed( + connection unityipc.Connection, + command string, + correlationID string, + elapsed time.Duration, + outcome unityipc.UnitySendOutcome, + err error, +) { + writePlainToolVibeLog(connection.ProjectRoot, func() vibelog.CLIVibeLogEntry { + // Why no err.Error(): a Unity parameter-validation RPC error quotes the raw parameter values + // in its message, and this log must never carry parameter values. + return vibelog.CLIVibeLogEntry{ + Level: "ERROR", + Operation: plainToolRequestFailedOperation, + Message: "The tool request to Unity failed.", + Context: map[string]any{ + "command": command, + "correlation_id": correlationID, + "elapsed_ms": elapsed.Milliseconds(), + "request_accepted": outcome.RequestAccepted, + "error_kind": classifyPlainToolError(err), + }, + CorrelationID: correlationID, + } + }) +} + +// classifyPlainToolError names what failed without any of the error's text: "rpc:" followed by the +// type Unity attached to its error (empty when it attached none), "final_response_timeout", or +// "other". +func classifyPlainToolError(err error) string { + var rpcErr *unityipc.RPCError + if errors.As(err, &rpcErr) { + return "rpc:" + clierrors.RPCDataType(rpcErr.Data) + } + if clierrors.IsFinalResponseTimeoutError(err) { + return "final_response_timeout" + } + return "other" +} + +func sortedParamKeys(params map[string]any) []string { + keys := make([]string, 0, len(params)) + for key := range params { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// Only the two shapes the CLI parses an array option into: a JSON array and a comma-separated list. +func paramArrayLengths(params map[string]any) map[string]int { + lengths := map[string]int{} + for key, value := range params { + switch array := value.(type) { + case []any: + lengths[key] = len(array) + case []string: + lengths[key] = len(array) + } + } + return lengths +} diff --git a/cli/project-runner/internal/projectrunner/plain_tool_log_test.go b/cli/project-runner/internal/projectrunner/plain_tool_log_test.go new file mode 100644 index 0000000000..6e951cb051 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/plain_tool_log_test.go @@ -0,0 +1,283 @@ +package projectrunner + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/common/unityipc" + "github.com/hatayama/unity-cli-loop/common/vibelog" +) + +// A value no log entry may contain. It is matched as a bare substring so that a parameter value +// leaking without its JSON quotes, for example through fmt.Sprint(params), is caught as well. +const plainToolLogSentinel = "sentinel-9f3c7a1e" + +const plainToolLogTestCommand = "get-logs" + +// Verifies a plain tool command writes one request entry and one response entry that share a +// correlation ID, name the parameter keys and array lengths, and never carry a parameter value. +func TestRunPlainToolWritesRequestAndResponseVibeLogs(t *testing.T) { + // Both forms the CLI parses an array option into: a JSON array and a comma-separated list. + arrayForms := map[string]any{ + "json array": []any{plainToolLogSentinel + "-first", plainToolLogSentinel + "-second"}, + "comma list": []string{plainToolLogSentinel + "-first", plainToolLogSentinel + "-second"}, + } + for name, files := range arrayForms { + t.Run(name, func(t *testing.T) { + enableCliVibeLog(t) + projectRoot := t.TempDir() + params := map[string]any{"files": files, "verbose": true} + + result := runPlainToolAgainstFakeUnityResult(t, projectRoot, params, `{"Success":true}`) + + if result.exitCode != 0 { + t.Fatalf("exit code = %d, want 0", result.exitCode) + } + logContent := readOnlyCliVibeLog(t, projectRoot) + sent := singleCliVibeEntry(t, logContent, "cli_tool_request_sent") + received := singleCliVibeEntry(t, logContent, "cli_tool_response_received") + assertCliVibeEntryLevel(t, sent, "INFO") + assertCliVibeEntryLevel(t, received, "INFO") + sentContext := cliVibeEntryContext(t, sent) + receivedContext := cliVibeEntryContext(t, received) + if sentContext["command"] != plainToolLogTestCommand || receivedContext["command"] != plainToolLogTestCommand { + t.Fatalf("command mismatch: sent %#v, received %#v", sentContext["command"], receivedContext["command"]) + } + if sentContext["project_identity"] != vibelog.ProjectIdentity(projectRoot) { + t.Fatalf("project_identity = %#v, want %q", sentContext["project_identity"], vibelog.ProjectIdentity(projectRoot)) + } + if got := fmt.Sprint(sentContext["param_keys"]); got != "[files verbose]" { + t.Fatalf("param_keys = %s, want [files verbose]", got) + } + arrayLengths, ok := sentContext["array_lengths"].(map[string]any) + if !ok || arrayLengths["files"] != float64(2) || len(arrayLengths) != 1 { + t.Fatalf("array_lengths = %#v, want only files: 2", sentContext["array_lengths"]) + } + assertSharedCliVibeCorrelationID(t, sent, received) + if receivedContext["exit_code"] != float64(0) { + t.Fatalf("exit_code = %#v, want 0", receivedContext["exit_code"]) + } + if resultBytes, ok := receivedContext["result_bytes"].(float64); !ok || resultBytes <= 0 { + t.Fatalf("result_bytes = %#v, want a positive number", receivedContext["result_bytes"]) + } + // The fake answers with the final frame only, never with an accepted frame first. + if receivedContext["request_accepted"] != false { + t.Fatalf("request_accepted = %#v, want false", receivedContext["request_accepted"]) + } + assertCliVibeLogOmitsTheSentinel(t, logContent) + }) + } +} + +// Verifies a tool that answers with a failed envelope is logged as a received response with the +// command's exit code 1, not as a failed request: the transport delivered the answer. +func TestRunPlainToolLogsTheEnvelopeExitCodeOfAFailedTool(t *testing.T) { + enableCliVibeLog(t) + projectRoot := t.TempDir() + + result := runPlainToolAgainstFakeUnityResult(t, projectRoot, map[string]any{}, `{"Success":false,"Message":"x"}`) + + if result.exitCode != 1 { + t.Fatalf("exit code = %d, want 1", result.exitCode) + } + logContent := readOnlyCliVibeLog(t, projectRoot) + received := singleCliVibeEntry(t, logContent, "cli_tool_response_received") + assertCliVibeEntryLevel(t, received, "INFO") + if exitCode := cliVibeEntryContext(t, received)["exit_code"]; exitCode != float64(1) { + t.Fatalf("exit_code = %#v, want 1", exitCode) + } + if failed := cliVibeEntriesForOperation(t, logContent, "cli_tool_request_failed"); len(failed) != 0 { + t.Fatalf("cli_tool_request_failed entries = %d, want 0", len(failed)) + } +} + +// Verifies a request Unity answers with an RPC error writes one failure entry, classified as an +// RPC error, that shares the request's correlation ID and carries no parameter value. +func TestRunPlainToolWritesFailureVibeLog(t *testing.T) { + enableCliVibeLog(t) + projectRoot := t.TempDir() + params := map[string]any{"files": []any{plainToolLogSentinel}} + + result := runPlainToolAgainstFakeUnity(t, projectRoot, params, testUnityRPCFailureResponse) + + if result.exitCode != 1 { + t.Fatalf("exit code = %d, want 1", result.exitCode) + } + logContent := readOnlyCliVibeLog(t, projectRoot) + sent := singleCliVibeEntry(t, logContent, "cli_tool_request_sent") + failed := singleCliVibeEntry(t, logContent, "cli_tool_request_failed") + assertCliVibeEntryLevel(t, failed, "ERROR") + if errorKind, _ := cliVibeEntryContext(t, failed)["error_kind"].(string); !strings.HasPrefix(errorKind, "rpc:") { + t.Fatalf("error_kind = %q, want an rpc: kind", errorKind) + } + assertSharedCliVibeCorrelationID(t, sent, failed) + if received := cliVibeEntriesForOperation(t, logContent, "cli_tool_response_received"); len(received) != 0 { + t.Fatalf("cli_tool_response_received entries = %d, want 0", len(received)) + } + assertCliVibeLogOmitsTheSentinel(t, logContent) +} + +// Verifies the failure entry does not copy Unity's error message, which can quote the raw +// parameter values when Unity rejects a parameter. +func TestRunPlainToolDoesNotCopyTheUnityErrorMessage(t *testing.T) { + enableCliVibeLog(t) + projectRoot := t.TempDir() + response := strings.Replace( + testUnityRPCFailureResponse, + "tool exploded in Unity", + "Invalid value '"+plainToolLogSentinel+"' for parameter", + 1) + + runPlainToolAgainstFakeUnity(t, projectRoot, map[string]any{}, response) + + logContent := readOnlyCliVibeLog(t, projectRoot) + singleCliVibeEntry(t, logContent, "cli_tool_request_failed") + assertCliVibeLogOmitsTheSentinel(t, logContent) +} + +// Verifies the error kind names what failed without any of the error's text. +func TestClassifyPlainToolErrorNamesTheKindWithoutTheMessage(t *testing.T) { + cases := []struct { + name string + err error + want string + }{ + { + name: "rpc error with a data type", + err: &unityipc.RPCError{Message: plainToolLogSentinel, Data: []byte(`{"type":"server_busy"}`)}, + want: "rpc:server_busy", + }, + { + name: "rpc error without data", + err: &unityipc.RPCError{Message: plainToolLogSentinel}, + want: "rpc:", + }, + { + name: "wrapped rpc error", + err: fmt.Errorf("send failed: %w", &unityipc.RPCError{Message: plainToolLogSentinel}), + want: "rpc:", + }, + { + name: "final response timeout", + err: timeoutOnlyError{}, + want: "final_response_timeout", + }, + { + name: "anything else", + err: errors.New(plainToolLogSentinel), + want: "other", + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := classifyPlainToolError(testCase.err); got != testCase.want { + t.Fatalf("classifyPlainToolError = %q, want %q", got, testCase.want) + } + }) + } +} + +// Verifies a plain tool command with the log off leaves no log directory behind. +func TestRunPlainToolWritesNothingWhenDebugIsOff(t *testing.T) { + // Explicitly off, so the test also holds when it runs from a shell that exported ULOOP_DEBUG. + t.Setenv(vibelog.CLIVibeLogEnvName, "") + projectRoot := t.TempDir() + + runPlainToolAgainstFakeUnityResult(t, projectRoot, map[string]any{"verbose": true}, `{"Success":true}`) + + logDirectory := filepath.Join(projectRoot, vibelog.CLIVibeLogDirectory) + if _, err := os.Stat(logDirectory); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("the log directory must not exist when the log is off: %v", err) + } +} + +// Verifies the entry is not even built when the log is off. The file system cannot show this: +// vibelog.WriteCLIVibeLog writes nothing when the log is off, whoever calls it. +func TestWritePlainToolVibeLogDoesNotBuildTheEntryWhenDisabled(t *testing.T) { + t.Setenv(vibelog.CLIVibeLogEnvName, "") + + writePlainToolVibeLog(t.TempDir(), func() vibelog.CLIVibeLogEntry { + t.Fatal("the entry must not be built while the log is off") + return vibelog.CLIVibeLogEntry{} + }) +} + +// Runs the plain tool path against a fake Unity that answers the one request with result. +func runPlainToolAgainstFakeUnityResult( + t *testing.T, + projectRoot string, + params map[string]any, + result string, +) toolExecutionResult { + t.Helper() + return runPlainToolAgainstFakeUnity(t, projectRoot, params, fmt.Sprintf(`{"jsonrpc":"2.0","result":%s,"id":1}`, result)) +} + +// Runs the plain tool path against a fake Unity that answers the one request with the raw +// JSON-RPC response, so a test can return an error as well as a result. +func runPlainToolAgainstFakeUnity( + t *testing.T, + projectRoot string, + params map[string]any, + response string, +) toolExecutionResult { + t.Helper() + server := startFakeUnityServer(t, projectRoot, plainToolLogTestCommand, response) + var stderr bytes.Buffer + result := runPlainTool(context.Background(), server.connection, plainToolLogTestCommand, params, &stderr) + server.receivedRequest(t) + return result +} + +// Returns the only entry of the operation in the log. +func singleCliVibeEntry(t *testing.T, logContent string, operation string) map[string]any { + t.Helper() + entries := cliVibeEntriesForOperation(t, logContent, operation) + if len(entries) != 1 { + t.Fatalf("%s entries = %d, want 1\n%s", operation, len(entries), logContent) + } + return entries[0] +} + +// Returns the entry's context as decoded JSON, so a test can read arrays, objects and booleans. +func cliVibeEntryContext(t *testing.T, entry map[string]any) map[string]any { + t.Helper() + contextMap, ok := entry["context"].(map[string]any) + if !ok { + t.Fatalf("vibe log context missing: %#v", entry) + } + return contextMap +} + +func assertCliVibeEntryLevel(t *testing.T, entry map[string]any, want string) { + t.Helper() + if entry["level"] != want { + t.Fatalf("%v level = %#v, want %s", entry["operation"], entry["level"], want) + } +} + +// Asserts both entries carry one correlation ID, in their context and at the top level. +func assertSharedCliVibeCorrelationID(t *testing.T, first map[string]any, second map[string]any) { + t.Helper() + firstID := vibeLogContextString(t, first, "correlation_id") + secondID := vibeLogContextString(t, second, "correlation_id") + if firstID == "" || firstID != secondID { + t.Fatalf("correlation_id must be shared: %q, %q", firstID, secondID) + } + if first["correlation_id"] != firstID || second["correlation_id"] != secondID { + t.Fatalf("top-level correlation_id must match the context: %#v, %#v", first["correlation_id"], second["correlation_id"]) + } +} + +func assertCliVibeLogOmitsTheSentinel(t *testing.T, logContent string) { + t.Helper() + if strings.Contains(logContent, plainToolLogSentinel) { + t.Fatalf("the vibe log must not contain a parameter value or an error message:\n%s", logContent) + } +} diff --git a/cli/project-runner/internal/projectrunner/run.go b/cli/project-runner/internal/projectrunner/run.go index f01f09d601..9b6abf1d32 100644 --- a/cli/project-runner/internal/projectrunner/run.go +++ b/cli/project-runner/internal/projectrunner/run.go @@ -16,6 +16,7 @@ import ( "github.com/hatayama/unity-cli-loop/common/clicore" "github.com/hatayama/unity-cli-loop/common/project" "github.com/hatayama/unity-cli-loop/common/unityipc" + "github.com/hatayama/unity-cli-loop/common/vibelog" ) func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) int { @@ -105,83 +106,58 @@ func runToolExecution( type toolExecutionResult struct { result json.RawMessage exitCode int + // correlationID is the vibe log ID of the request that produced this result, so entries + // written after it can be joined to that request. + correlationID string } func runPlainTool(ctx context.Context, connection unityipc.Connection, command string, params map[string]any, stderr io.Writer) toolExecutionResult { - applyDebugTimingParams(command, params) - startedAt := time.Now() - spinner := clicore.NewToolSpinner(stderr, command) - outcome, err := sendWithTransientConnectionRetry( - ctx, - connection, - command, - params, - ui.NewSpinnerProgressFunc(spinner, fmt.Sprintf("Executing %s...", command)), - ) - spinner.Stop() + result, outcome, err := sendPlainTool(ctx, connection, command, params, stderr, defaultConnectionRetryDeps()) if err != nil { - writeDebugTiming(stderr, command, time.Since(startedAt), outcome) clierrors.WriteToolFailure(stderr, err, outcome, clierrors.ErrorContext{ ProjectRoot: connection.ProjectRoot, Command: command, }) - return toolExecutionResult{exitCode: 1} } - result := stripDebugTimingResult(command, outcome.Result) - writeDebugTiming(stderr, command, time.Since(startedAt), outcome) - return toolExecutionResult{result: result, exitCode: toolEnvelopeExitCode(result)} + return result } -func runExecuteDynamicCodeWithDomainReloadWait(ctx context.Context, connection unityipc.Connection, params map[string]any, stdout io.Writer, stderr io.Writer) int { - applyDebugTimingParams(clicore.ExecuteDynamicCodeCommandName, params) +// sendPlainTool sends one tool request and returns what came back. A failure is logged but not +// written to stderr, so a caller that recovers from it (hot reload waiting out a busy Editor) +// decides what the user sees. The failed result still carries the request's correlation ID. +func sendPlainTool( + ctx context.Context, + connection unityipc.Connection, + command string, + params map[string]any, + stderr io.Writer, + deps connectionRetryDeps, +) (toolExecutionResult, unityipc.UnitySendOutcome, error) { + applyDebugTimingParams(command, params) + correlationID := vibelog.NewCLIVibeCorrelationID() + logPlainToolRequestSent(connection, command, params, correlationID) startedAt := time.Now() - spinner := clicore.NewToolSpinner(stderr, clicore.ExecuteDynamicCodeCommandName) - outcome, err := sendWithTransientConnectionRetry( + spinner := clicore.NewToolSpinner(stderr, command) + outcome, err := sendWithTransientConnectionRetryWithDeps( ctx, connection, - clicore.ExecuteDynamicCodeCommandName, + command, params, - ui.NewSpinnerProgressFunc(spinner, "Executing execute-dynamic-code..."), + ui.NewSpinnerProgressFunc(spinner, fmt.Sprintf("Executing %s...", command)), + 0, + deps, ) + spinner.Stop() if err != nil { - if shouldWaitForExecuteDynamicCodeDisconnect(err, outcome) { - spinner.Update("Connection lost during execute-dynamic-code. Waiting for domain reload to complete...") - if waitErr := clicore.WaitForToolReadiness(ctx, connection.ProjectRoot); waitErr != nil { - spinner.Stop() - clierrors.WriteClassifiedError(stderr, waitErr, clierrors.ErrorContext{ - ProjectRoot: connection.ProjectRoot, - Command: clicore.ExecuteDynamicCodeCommandName, - }) - return 1 - } - } - spinner.Stop() - writeDebugTiming(stderr, clicore.ExecuteDynamicCodeCommandName, time.Since(startedAt), outcome) - clierrors.WriteToolFailure(stderr, err, outcome, clierrors.ErrorContext{ - ProjectRoot: connection.ProjectRoot, - Command: clicore.ExecuteDynamicCodeCommandName, - }) - return 1 - } - - if executeDynamicCodeDomainReloadWaitRequired(outcome.Result) { - spinner.Update("Waiting for domain reload to complete...") - if err := clicore.WaitForToolReadiness(ctx, connection.ProjectRoot); err != nil { - spinner.Stop() - clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ - ProjectRoot: connection.ProjectRoot, - Command: clicore.ExecuteDynamicCodeCommandName, - }) - return 1 - } + writeDebugTiming(stderr, command, time.Since(startedAt), outcome) + logPlainToolRequestFailed(connection, command, correlationID, time.Since(startedAt), outcome, err) + return toolExecutionResult{exitCode: 1, correlationID: correlationID}, outcome, err } - - spinner.Stop() - result := stripExecuteDynamicCodeControlResult(outcome.Result) - result = stripDebugTimingResult(clicore.ExecuteDynamicCodeCommandName, result) - clicore.WriteJSON(stdout, result) - writeDebugTiming(stderr, clicore.ExecuteDynamicCodeCommandName, time.Since(startedAt), outcome) - return toolEnvelopeExitCode(result) + result := stripDebugTimingResult(command, outcome.Result) + writeDebugTiming(stderr, command, time.Since(startedAt), outcome) + exitCode := toolEnvelopeExitCode(result) + logPlainToolResponseReceived(connection, command, correlationID, time.Since(startedAt), outcome, result, exitCode) + return toolExecutionResult{result: result, exitCode: exitCode, correlationID: correlationID}, outcome, nil } func runCompileWithDomainReloadWait(ctx context.Context, connection unityipc.Connection, params map[string]any, stdout io.Writer, stderr io.Writer) int { @@ -267,18 +243,6 @@ func runCompileWithReattachPolicy( return runFreshCompileRecoveringWithDeps(ctx, connection, params, stderr, compileWait) } -func runFreshCompileWithDomainReloadWaitWithDeps( - ctx context.Context, - connection unityipc.Connection, - params map[string]any, - stdout io.Writer, - stderr io.Writer, - compileWait compileWaitDeps, -) int { - result := runFreshCompileWithDomainReloadWaitResultWithDeps(ctx, connection, params, stderr, compileWait) - return writeCompileExecutionResult(stdout, result) -} - func runFreshCompileWithDomainReloadWaitResultWithDeps( ctx context.Context, connection unityipc.Connection, diff --git a/cli/project-runner/internal/projectrunner/run_test.go b/cli/project-runner/internal/projectrunner/run_test.go index c3b9b3bb5e..6a3a74b48b 100644 --- a/cli/project-runner/internal/projectrunner/run_test.go +++ b/cli/project-runner/internal/projectrunner/run_test.go @@ -232,26 +232,22 @@ func TestRunFreshCompileRejectsInvalidTimeout(t *testing.T) { t.Fatal("compile must not be sent") return unityipc.UnitySendOutcome{}, nil } - var stdout, stderr bytes.Buffer + var stderr bytes.Buffer - code := runFreshCompileWithDomainReloadWaitWithDeps( + result := runFreshCompileWithDomainReloadWaitResultWithDeps( context.Background(), unreachableConnection(t.TempDir()), map[string]any{compileWaitTimeoutParam: "soon"}, - &stdout, &stderr, deps, ) - if code != 1 { - t.Fatalf("exit code = %d, want 1", code) + if result.exitCode != 1 || len(result.result) != 0 { + t.Fatalf("unexpected result: %#v", result) } if !strings.Contains(stderr.String(), "Invalid positive integer value for --timeout-seconds") { t.Fatalf("stderr must reject the timeout:\n%s", stderr.String()) } - if stdout.Len() != 0 { - t.Fatalf("stdout must stay empty: %s", stdout.String()) - } } // Verifies that when the compile connection drops after dispatch, the fresh compile path diff --git a/docs/glossary.md b/docs/glossary.md index 56ea4392f8..d4c1befc20 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -251,6 +251,10 @@ reports `RetargetedToHotReloadPatch: true`; one that cannot be re-targeted is su A Cecil-rewritten copy of a project assembly under `Library/UloopHotReload/PublicizedRefs/fmt2/` in which every type and member is public — except field-like event backing fields, which stay non-public so shim compilation does not see both the event and its same-named backing field -(CS0229). Hot reload uses these copies only as compile-time +(CS0229). A project assembly other than the edited one that grants the edited assembly no +internals is copied under `Library/UloopHotReload/PublicizedExternalRefs/fmt1/` instead, where its +top-level internal types and its private, internal and private protected members stay as they +are, so shim compilation sees no more of it than the edited assembly's own compile did, apart from +protected members and nested types. Hot reload uses these copies only as compile-time references for shim compilation so private/internal member access type-checks; they are never loaded into the Editor domain as the runtime identity of the target types. diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 5601ae33ba..4bf0a678a1 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -71,8 +71,18 @@ added on one side alone fails a test rather than silently serializing to nothing Harmony ID: `io.github.hatayama.uloop.hot-reload` (distinct from the pause point's ID). Caches: `Library/UloopHotReload/PublicizedRefs/fmt2/-.dll`, +`Library/UloopHotReload/PublicizedExternalRefs/fmt1/-.dll`, `Library/UloopHotReload/Worker//`, and `Library/UloopHotReload/SourceSnapshot/-/`. +The shim compile references a fully publicized copy of the edited assembly, and of every other +project assembly that grants the edited one its internals through `InternalsVisibleTo`. Any other +project assembly is referenced through a `PublicizedExternalRefs` copy that keeps its top-level +internal types and its private, internal and private protected members as they are, so the shim +compile binds calls the way the edited assembly's own compile does: a public extension method +stays unambiguous next to an internal or private one of the same signature in another assembly. +Protected members of that copy are still publicized, because a shim calls a base type's protected +members from outside the type hierarchy, and so are nested types, which cannot declare extension +methods. 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 diff --git a/docs/soak-testing.md b/docs/soak-testing.md index d78324d834..013e6bd671 100644 --- a/docs/soak-testing.md +++ b/docs/soak-testing.md @@ -98,7 +98,8 @@ pinned runner the request is logged and ignored. Values above 1200 exceed Unity's 20-minute result retention, which weakens the post-timeout recovery. uloop is single-flight, so a command issued while Unity still runs an earlier -one is refused at dispatch with `UNITY_SERVER_BUSY`. That is back-pressure from +one is refused at dispatch with `UNITY_SERVER_BUSY` (`hot-reload` instead waits for the running +command; see the hot-reload skill's scope-and-limits). That is back-pressure from the harness's own previous command, not a defect: the PowerShell variant waits (up to 20 tries, 30s apart) and records only the decisive attempt, so one slow compile no longer fails every command behind it. diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index f89515be6d..f9457370c6 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -36,7 +36,8 @@ command ran against: - A missing line is evidence only when the define was set and the code path logs at all. Coverage is per call site, not per command: for example, `execute-dynamic-code`, `simulate-keyboard`, `screenshot`, compile, domain reload, server binding, and hot reload log - start and completion, while the CLI side currently logs compile requests and window focus only. + start and completion, while the CLI side logs compile requests, window focus, and the tool + requests described under "CLI entries for tool commands" below. Check that the operation you expect is logged somewhere (`git grep` the operation name) before reading its absence as "it never ran". @@ -54,6 +55,35 @@ Each line is one JSON object: which is generated per log call. - `environment.domain_reload_state` tells whether an entry was written during a domain reload. +## CLI entries for tool commands + +A tool command that sends one request and prints Unity's answer, such as `hot-reload` or +`get-logs`, writes these entries. A command that runs a wait or a flow of its own writes none of +them: `compile` (it has its own `cli_compile_*` entries), an `execute-dynamic-code`, `run-tests`, +or `control-play-mode` that waits for a domain reload or a Play Mode change, and commands such as +`enable-pause-point`, `await-pause-point`, and `status`. + +| `operation` | Written | `context` | +|---|---|---| +| `cli_tool_request_sent` | before the request is sent | `command`, `correlation_id`, `project_identity`, `cli_version`, `param_keys` (sorted), `array_lengths` (element count of each array parameter) | +| `cli_tool_response_received` | when Unity answered | `command`, `correlation_id`, `elapsed_ms`, `request_accepted`, `result_bytes`, `exit_code` | +| `cli_tool_request_failed` (`ERROR`) | when no answer came, or Unity answered with an error | `command`, `correlation_id`, `elapsed_ms`, `request_accepted`, `error_kind` (`rpc:`, `final_response_timeout`, or `other`) | +| `cli_hot_reload_busy_wait_decided` | when the first `hot-reload` answer is `server_busy` (another uloop command holds the Editor; the preceding `cli_tool_request_failed` has `error_kind` `rpc:server_busy`) | `correlation_id` (the first request's), `running_tool_name`, `running_tool_phase`, `running_tool_elapsed_seconds`, `budget_ms`, `resend_interval_ms` | +| `cli_hot_reload_busy_wait_complete` (`WARN` unless the Editor became ready and the request sent after the wait answered) | after the wait and the one request sent after it, on every way out of a wait that started (a cancel, a failed send, and a bad answer included) | `correlation_id` (the first request's), `second_correlation_id` (the request sent after the wait, or empty when none was sent), `waited_ms`, `ready`, `resends` (requests sent again during the wait while a cancelled `execute-dynamic-code` held the Editor; each also has its own `cli_tool_request_sent`), `second_result`, and that answer's `second_success` and `second_outcome` | +| `cli_hot_reload_editor_ready_retry_decided` | after every `hot-reload` answer (the one after a busy wait, when one ran), before the fallback decision | `correlation_id` (the request's), `requested`, `parse_error` | +| `cli_hot_reload_editor_ready_retry_complete` (`WARN` unless the Editor settled and the second apply answered) | after the wait and the second apply, when a retry was requested | `correlation_id` (the first request's), `second_correlation_id` (the second request's, or empty when none was sent), `waited_ms`, `ready`, `second_result`, and the second answer's `second_success` and `second_outcome` | +| `cli_hot_reload_compile_fallback_decided` | after every `hot-reload` answer the fallback decision sees: the second one after an editor-ready retry, and none when that retry ended the command (see `cli_hot_reload_editor_ready_retry_complete`) | `correlation_id` (the request's), `requested`, `parse_error`, and the answer's `success`, `outcome`, `warnings_count`, and `timing` (numbers only) | +| `cli_hot_reload_compile_fallback_complete` (`ERROR` unless `succeeded`) | after the fallback compile, when one ran | `correlation_id`, `elapsed_ms`, `compile_exit_code`, `compile_result_bytes`, `merged`, `succeeded` | + +- These entries hold keys, counts, sizes, and flags, never a parameter value, a response body, + or an error message: a parameter can name the project's files or carry code, and Unity's + parameter-validation error quotes the values it rejected. Read the values from the command's + own output. +- `cli_tool_response_received` with `exit_code` 1 is a tool that answered with a failure; the + request itself got through. +- The `cli_tool_*` entries of one command share one `correlation_id`, and the two + `cli_hot_reload_*` entries share another. Pair the two groups by time. + ## How to use them in an investigation 1. Find the time window from the report or the command output, then open the matching