Skip to content

fix: flatten NuGetResolver install layout to escape Windows MAX_PATH#736

Merged
IvanMurzak merged 11 commits intomainfrom
worktree-733-nuget-resolver-flat-layout-long-path
May 9, 2026
Merged

fix: flatten NuGetResolver install layout to escape Windows MAX_PATH#736
IvanMurzak merged 11 commits intomainfrom
worktree-733-nuget-resolver-flat-layout-long-path

Conversation

@IvanMurzak
Copy link
Copy Markdown
Owner

@IvanMurzak IvanMurzak commented May 8, 2026

Summary

  • Restructures the NuGet dependency resolver's on-disk layout to escape Windows' MAX_PATH = 260 limit by dropping the per-package {Id}.{Version}/ directory and writing every DLL flat under Assets/Plugins/NuGet/ with the versioned filename pattern {stem}.{packageVersion}.dll (e.g. McpPlugin.6.2.0.dll, System.Memory.10.0.3.dll).
  • Introduces a manifest (.nuget-installed.json) as the primary source of truth for the package -> DLL -> version mapping, with on-disk filename parsing as the disaster-recovery fallback.
  • Adds a one-shot legacy migration so existing installs upgrade cleanly without CS0436/CS0433 duplicate-assembly errors.
  • Adds a Windows-only long-path pre-flight check that aborts before any disk write when the resulting DLL path would exceed MAX_PATH minus meta-slack.
  • Updates the four consuming asmdef files (Runtime, Editor, Editor.Tests, Tests) so precompiledReferences lists the versioned filenames in lockstep with NuGetConfig.Packages.

The longest path on the originally-failing 271-char setup drops to ~226 chars — comfortably inside MAX_PATH with headroom for future package version bumps and longer DLL names.

Test plan

  • All EditMode tests pass against the new layout (904 / 904 DependencyResolver tests pass; 2 pre-existing SceneViewToolbarOverlayTests TearDown NREs are unrelated to this change).
  • New / reworked fixtures:
    • NuGetExtractorFlatLayoutTests (synthetic .nupkg -> versioned flat extraction; ToVersionedFileName idempotence)
    • NuGetInstallManifestTests (round-trip serialization, case-insensitive lookup, idempotent saves, TryParseInstalledDllName edge cases — greedy version tail, package IDs with dots, short stems, malformed tails)
    • NuGetLegacyMigrationTests (happy path, idempotence, mixed legacy + flat state, Windows file-lock abort)
    • NuGetLongPathPreflightTests (cross-platform OS check, threshold boundary)
    • Reworked NuGetPackageInstallerStaleVersionCleanupTests for the manifest-driven helper with versioned DLL fixtures
  • On the local Windows worktree (.claude/worktrees/733-nuget-resolver-flat-layout-long-path/), the resolver migrated the legacy {Id}.{Version}/ layout to versioned flat DLLs in a single domain reload; every consuming asmdef resolved its precompiledReferences against the versioned filenames; no compile errors after the migration.

Acceptance criteria coverage (issue #733)

  • Fresh install: Assets/Plugins/NuGet/ contains DLLs directly (no per-package subfolders) plus a .nuget-installed.json manifest.
  • Upgrade: every legacy {Id}.{Version}/ directory and its .meta file are deleted in the same restore cycle as the new flat extraction.
  • Migration runs at most once per project (idempotent on re-runs).
  • Locked legacy DLL: resolver aborts with a clear Debug.LogError and leaves the legacy folders intact.
  • Windows long-path overflow aborts with a clear error and leaves no partial state (test seam validates the boundary at threshold +/- 1).
  • Every DLL under Assets/Plugins/NuGet/ carries the package version in its filename; zero unversioned .dll files remain after a successful install.
  • All existing dependency-resolver tests pass against the new layout; new tests cover migration, manifest, long-path, filename parser, and disaster-recovery rebuild.
  • Disaster recovery: deleting .nuget-installed.json and re-running rebuilds the manifest from on-disk versioned filenames without re-extraction churn.

Closes #733

The NuGet dependency resolver previously installed each package into a
per-package directory ({Id}.{Version}/{Dll}.dll). On Windows, when the
project's absolute path is long enough that the resulting DLL paths exceed
MAX_PATH = 260, Unity's bundled Mono runtime — which opens DLLs through
legacy CreateFileW without the \?\ long-path prefix — fails with an opaque
DirectoryNotFoundException even when the file is on disk and even with the
"Enable Win32 long paths" group policy enabled. The Unity-AI-Animation
extension's test project hit this at 271 chars, 11 over the limit.

Restructure the on-disk layout so DLLs sit FLAT directly under
Assets/Plugins/NuGet/ (no per-package subfolder). The longest path on the
failing 271-char setup drops to 220 chars — comfortably inside MAX_PATH
with headroom for future package version bumps and longer DLL names.

Implementation:

- NuGetExtractor writes DLLs flat at the install root, preserving each
  DLL's original stem (System.Text.Json.dll, McpPlugin.dll, …). Filenames
  are intentionally unversioned: Unity asmdef precompiledReferences entries
  are filename-keyed at compile time, so versioned filenames would break
  every consuming asmdef on every NuGet bump. The package version lives in
  the new on-disk manifest instead.

- New NuGetInstallManifest (.nuget-installed.json at the install root) is
  the only source of truth for "which DLL belongs to which package at
  which version". Hand-rolled JSON read/write so the DependencyResolver
  assembly stays the only one that compiles before any NuGet package is
  on disk (the same invariant NuGetDependencyResolver was built against).

- New NuGetLegacyMigration runs once per project at the start of every
  Restore() pass: detects legacy {Id}.{Version}/ directories, deletes
  them with their .meta sidecars, then lets the normal install loop
  re-extract into the flat layout. Idempotent on every subsequent run.
  On Windows file-lock failures the migration aborts cleanly (legacy
  folders intact, no flat-layout DLLs written) so the project is never
  left in the duplicate-assembly state — the user restarts Unity to drop
  the AppDomain, and the migration retries on the next reload.

- New NuGetLongPathPreflight aborts the install BEFORE any disk write
  when a planned DLL path would exceed 255 chars (260 - 5-char .meta
  slack) on Windows. The check is a no-op on macOS / Linux.

- NuGetPackageInstaller / NuGetPackageRestorer / NuGetPluginConfigurator
  switched from directory enumeration to manifest-driven reads.
  Defense-in-depth filename-collision detection refuses installs whose
  DLL would overwrite one already owned by a different package ID in
  the manifest.

- The on-disk Assets/Plugins/NuGet/ contents are migrated to the new
  layout in this commit (the resolver's first run on the new code did
  the migration locally; the result is committed so CI sees the final
  state directly).

Tests:

- Reworked NuGetPackageInstallerStaleVersionCleanupTests for the flat
  layout (the helper is now manifest-driven).
- New NuGetInstallManifestTests cover round-trip serialization,
  case-insensitive lookup, and idempotent saves.
- New NuGetLegacyMigrationTests cover happy-path migration (single- and
  multi-DLL packages), idempotence on re-runs, mixed legacy + flat
  state, and the Windows file-lock abort path.
- New NuGetLongPathPreflightTests cover the cross-platform OS check
  and threshold boundary via an internal test seam.
- New NuGetExtractorFlatLayoutTests cover flat-layout extraction from
  synthetic .nupkg fixtures.

All 893 EditMode tests pass against the new layout.

Note on the spec deviation from the issue body: the issue called for
versioned filenames ({stem}.{packageVersion}.dll). That's incompatible
with Unity's asmdef precompiledReferences — those resolve by filename at
compile time, so versioned filenames would force every consuming asmdef
to be edited on every NuGet bump. The flat-layout-with-unversioned-names
design saves more path characters anyway (51 vs 44) and keeps every
asmdef working unchanged. The "self-describing layout" property the spec
wanted is satisfied by the manifest, which is now the authoritative
package → DLL → version mapping.

Closes #733
Copilot AI review requested due to automatic review settings May 8, 2026 20:09
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

Test Results

   12 files    546 suites   41m 10s ⏱️
  920 tests   919 ✅ 1 💤 0 ❌
5 520 runs  5 514 ✅ 6 💤 0 ❌

Results for commit 94fa831.

♻️ This comment has been updated with latest results.

Per the issue spec and reviewer correction, the flat-layout DLL filenames
must include the package version: {stem}.{packageVersion}.dll
(e.g. McpPlugin.6.2.0.dll, ReflectorNet.5.1.1.dll). The previous unversioned
form (McpPlugin.dll) was a deviation from #733; this commit aligns the
implementation with the spec.

Changes:

- NuGetExtractor.PlanDllPaths / ExtractDlls take a packageVersion parameter
  and produce filenames via NuGetExtractor.ToVersionedFileName(stem, ver).
  The rename is idempotent — re-running against an already-versioned input
  is a no-op.

- NuGetInstallManifest re-introduces TryParseInstalledDllName and
  TryRebuildFromDisk: the manifest stays the primary source of truth, and
  on-disk filename parsing is the disaster-recovery fallback that the spec
  explicitly relies on.

- NuGetPackageRestorer rebuilds the manifest from versioned filenames when
  .nuget-installed.json is missing — restoring the disaster-recovery story
  the spec called for.

- NuGetPackageInstaller passes package.Version into the extractor and
  uses TryParseInstalledDllName when checking which DLLs Unity already
  provides (the assembly identity comes from the stripped stem, not the
  versioned filename).

- NuGetPluginConfigurator strips the version tail before doing the
  UnityAssemblyResolver.IsAlreadyImported lookup so the assembly identity
  matches Unity's manifest name, not the on-disk filename.

- All four asmdef files (Runtime, Editor, Editor.Tests, Tests) updated to
  reference the versioned filenames in their precompiledReferences. This
  is the cost the project accepts for the self-describing layout — future
  NuGet bumps require updating the asmdef references in lockstep with
  NuGetConfig.Packages, but the manifest + migration handle the on-disk
  side automatically. The stale "Microsoft.Extensions.Diagnostics.dll"
  reference (no such DLL in the closure) was dropped from the Editor
  asmdef as cleanup.

- The on-disk Assets/Plugins/NuGet/ contents are re-migrated from the
  unversioned filenames to the versioned form; the manifest is rewritten
  to match.

- All test fixtures and assertions now use versioned filenames.
  NuGetExtractorFlatLayoutTests pins the rename behavior; the manifest
  tests cover round-trip serialization, case-insensitive lookup, idempotent
  saves, and TryParseInstalledDllName edge cases (greedy version tail,
  package IDs with dots, short stems, malformed tails). The legacy
  migration tests assert the post-migration steady state contains
  versioned flat DLLs.

Local EditMode test gate: 904 / 904 DependencyResolver tests pass with the
versioned filename layout. The project's pre-existing SceneViewToolbarOverlay
TearDown flakiness is unrelated.

Closes #733
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

IvanMurzak added 2 commits May 8, 2026 13:55
…est writes

Address PR #736 review findings:
- [high] Reconcile synthetic stem-keyed manifest entries left by TryRebuildFromDisk before the collision check fires, so multi-DLL packages (e.g. Microsoft.Bcl.Memory) can re-install on the disaster-recovery path instead of looping on "Refusing to install".
- [medium] Stage manifest writes via .nuget-installed.json.tmp + delete-then-rename so a crash mid-write cannot truncate the file.
- [medium] Cover the multi-DLL disaster-recovery + reconcile flow with new EditMode tests (would have caught the [high] finding).
- [low] Reword RemoveStaleSiblingVersions docstring to match the manifest.Packages.Remove(packageId) implementation.
- [low] LongPathPreflight error message now states the .meta-companion 5-char slack and the 255-char DLL cap explicitly.
- [low] Wrap Path.GetFullPath in CheckWith so legacy Mono PathTooLongException surfaces as InstallPathTooLongException (consistent failure class).
- [low] Acknowledge in NuGetLegacyMigration's XML doc that legacy meta GUIDs are intentionally not preserved (asmdef precompiledReferences are filename-based).

simplify-pass: 1
…ling and persistence

Address pass-2 review findings:

- [medium] Gate alreadyOnDisk on planned subseteq manifestEntry.Dlls so a partial post-migration entry forces re-extraction of missing DLLs.
- [low] MigrateSyntheticOwnerEntries now returns bool; caller persists the manifest immediately on a successful migration so a downstream halt cannot leave it desynced.
- [low] NuGetLegacyMigration: document the plugin-exclusive directory contract for the install path.
- [low] MigrateSyntheticOwnerEntries: XML-doc note that this migrator silently absorbs same-version cross-package DLL collisions (intentional for disaster recovery).

Adds two regression tests covering the partial-disk-state migration path and the no-op return-value contract.

simplify-pass: 2
Copilot AI review requested due to automatic review settings May 8, 2026 21:19
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

IvanMurzak added 2 commits May 8, 2026 14:50
PR #736 flattened Unity-MCP-Plugin/Assets/Plugins/NuGet/ to versioned
filenames (McpPlugin.6.2.0.dll, ReflectorNet.5.1.1.dll, ...) and
updated com.IvanMurzak.Unity.MCP.Runtime.asmdef's precompiledReferences
to those versioned names. The three Unity-Tests/<version>/ projects
each ship their own committed Assets/Plugins/NuGet/ mirror — those
mirrors were left in the OLD nested {pkg}.{ver}/{pkg}.dll layout, so
Unity's filename-keyed precompiledReferences resolution missed every
DLL and the runtime asmdef failed to compile in CI with hundreds of
CS0246/CS0234 errors across all 12 Unity test jobs.

Replicate the source-of-truth flat layout into each mirror:

- Unity-Tests/2022.3.62f3/Assets/Plugins/NuGet/
- Unity-Tests/2023.2.22f1/Assets/Plugins/NuGet/
- Unity-Tests/6000.3.1f1/Assets/Plugins/NuGet/

The post-state of every mirror is byte-for-byte identical to
Unity-MCP-Plugin/Assets/Plugins/NuGet/ at HEAD: same DLL filenames,
same .dll.meta files (so asset-database GUIDs are now consistent
across source and mirrors), and the .nuget-installed.json manifest is
included for parity. The directory-level NuGet.meta on each mirror is
preserved (each test project keeps its own folder GUID; nothing
referenced the OLD per-package folder GUIDs externally — verified by
scanning every scene/asmdef/asset in Unity-Tests/<ver>/Assets/ for any
reference to the discarded mirror GUIDs; zero hits).

Local Unity EditMode test gate: 910 / 910 pass at this commit
(parity with the baseline in PR #736 — the change is mirror-only,
no plugin-source code touched).

Closes #733
The Unity-Tests mirrors copied every DLL from
Unity-MCP-Plugin/Assets/Plugins/NuGet/, including McpPlugin.6.2.0.dll
and McpPlugin.Common.6.2.0.dll. Those two assemblies are produced BY
the Unity-MCP-Plugin itself and are reachable in the test projects via
the UPM file:// reference to
Unity-MCP-Plugin/Packages/com.ivanmurzak.unity.mcp/. Having the same
internal-simple-name assembly present in two locations causes the C#
compiler to emit CS1704 ("An assembly with the same simple name has
already been imported") on every Unity test-runner matrix job (12
jobs across 3 versions × 2 platforms × 2 testModes).

Refine the mirror invariant: Unity-Tests/<version>/Assets/Plugins/NuGet/
is a byte-for-byte mirror of Unity-MCP-Plugin/Assets/Plugins/NuGet/
EXCEPT for DLLs whose simple-name matches an assembly produced by
Unity-MCP-Plugin/ itself — currently McpPlugin and McpPlugin.Common.

Changes:
- Delete McpPlugin.6.2.0.{dll,dll.meta} and
  McpPlugin.Common.6.2.0.{dll,dll.meta} from each of the three CI
  matrix mirrors (2022.3.62f3, 2023.2.22f1, 6000.3.1f1).
- Drop com.IvanMurzak.McpPlugin and com.IvanMurzak.McpPlugin.Common
  entries from each per-mirror .nuget-installed.json so the manifest
  stays in sync with on-disk state.
- Source-of-truth Unity-MCP-Plugin/Assets/Plugins/NuGet/ is unchanged
  (the plugin self-installs those DLLs into its own NuGet folder
  during build; that is correct and remains untouched).

Local validation: 910/910 EditMode tests pass on Unity 2023.2.22f1.

Closes #733
Copilot AI review requested due to automatic review settings May 8, 2026 22:16
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

@IvanMurzak IvanMurzak self-assigned this May 8, 2026
@IvanMurzak IvanMurzak added the bug Something isn't working label May 8, 2026
Copilot AI review requested due to automatic review settings May 8, 2026 22:52
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

Copilot AI review requested due to automatic review settings May 9, 2026 00:16
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

…ata; cross-platform test coverage

ExtractPackageIdFromDirName previously gated detection on
System.Version.TryParse, which rejects SemVer prerelease ("1.0.0-preview")
and build-metadata ("1.2.3+build.42") tails. Real NuGet packages use those,
so legacy folders shaped `Foo.1.0.0-preview/` were silently skipped by the
migration and the user ended up with both the old nested DLL and the new
flat-layout DLL on disk simultaneously — duplicate-assembly compile errors.
Add a SemVer-shape regex fallback so those folders are detected and removed
in the same pass. Regression test covers prerelease, build-metadata, and
dot-suffix prerelease folder names.

Test split for the platform-sensitive cases that fail on the Linux Docker
CI runner:

- Run_FileLock_AbortsAndLeavesLegacyIntact (Windows) — guarded #if
  UNITY_EDITOR_WIN. FileShare.None is advisory on Unix and does not block
  Directory.Delete(recursive:true), so the migration completes instead of
  aborting and the assertion fails for a platform-specific reason.

- Run_PermissionDenied_AbortsAndLeavesLegacyIntact (Unix) — new. Uses
  libc chmod 0o500 on the install-path parent to deny write, which makes
  the final rmdir of the now-empty legacy subdirectory throw
  UnauthorizedAccessException. The migration catches that and reports
  Outcome.AbortedFileLock the same way as the Windows file-lock path.
  Self-skips when geteuid()==0 because the kernel bypasses chmod-based
  denial for root, which is the GameCI Unity Docker container's default.

- CheckWith_BoundaryAtThreshold_DoesNotThrow (Windows) — guarded.
  CheckWith_BoundaryAboveThreshold_Throws (Windows) — guarded. Both
  craft `C:\…` paths that Mono on Linux does not recognise as drive-rooted,
  so Path.GetFullPath inflates them with the cwd and breaks the boundary
  precision the tests are pinning.

- CheckWith_BoundaryAtThreshold_UnixShapedPath_DoesNotThrow (Unix) — new.
  CheckWith_BoundaryAboveThreshold_UnixShapedPath_Throws (Unix) — new.
  Use `/tmp/…` paths that round-trip through Path.GetFullPath unchanged
  on Unix, pinning the same `<=` boundary semantics that the Windows
  variants pin on Windows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@IvanMurzak IvanMurzak merged commit 1581d93 into main May 9, 2026
17 checks passed
@IvanMurzak IvanMurzak deleted the worktree-733-nuget-resolver-flat-layout-long-path branch May 9, 2026 06:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NuGetResolver fails on Windows when project path is long (MAX_PATH=260) — flatten install layout + add long-path pre-flight

2 participants