IvanMurzak / IvanMurzak/Unity-MCP

NuGet resolver doesn't trigger after package upgrade when project has compile errors — [InitializeOnLoad] blocked, no domain reload

Open
#707 0 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

bug help wanted high-priority
Dominant language
C#
Stars
4.3k
Forks
379
Avg merge
6h 52m
Merged PRs (30d)
17

Description

Summary

Follow-up to #703 / #705. Even with the Install()-time stale-version cleanup landed, upgrading com.ivanmurzak.unity.mcp in a third-party Unity project still leaves the project broken: the NuGet resolver never runs after the upgrade, so the stale <Id>.<oldVersion>/ directory is never cleaned up and the new code never compiles.

The reason: the resolver is wired through [InitializeOnLoad], which Unity only invokes after a successful project-wide recompilation. The package upgrade itself introduces compile errors (new plugin code references newer APIs of a NuGet dep — e.g. ReflectorNet — but the old DLL is still on disk), Unity refuses to reload the domain, and the new NuGetDependencyResolver static constructor is never executed.

The previously-loaded resolver instance from the last successful reload is still alive in the editor's AppDomain, but its EditorApplication.update += ResolveOnce handler already fired, unsubscribed itself, and ran exactly once. So nothing in the running domain ever attempts another restore.

The fact that the DependencyResolver assembly is wrapped in its own asmdef and has no compilation errors inside itself is necessary but not sufficient — [InitializeOnLoad] cares about whole-project compile state, not per-assembly state.

Affected area

  • Repo: Unity-MCP
  • Project: Unity-MCP-Plugin
  • Code: Packages/com.ivanmurzak.unity.mcp/Editor/DependencyResolver/NuGetDependencyResolver.cs (the [InitializeOnLoad] entry point), with downstream impact on NuGetPackageRestorer / NuGetPackageInstaller.

Steps to reproduce

  1. In a third-party Unity project, install com.ivanmurzak.unity.mcp at version N, which depends on ReflectorNet vX. Resolver runs on first reload, installs DLLs, project compiles, UNITY_MCP_READY set.
  2. Upgrade the Unity package via UPM to version N+1, where the ReflectorNet dependency is bumped to vY (vY > vX) and the plugin code uses new ReflectorNet APIs only available in vY.
  3. UPM writes new package files to disk. Unity attempts a recompile.
  4. Compilation fails: plugin assemblies reference ReflectorNet symbols that don't exist in the still-on-disk ReflectorNet.<vX>.dll.
  5. Unity does NOT reload the domain (blocked by compile errors). [InitializeOnLoad] never fires for the new compile.

Expected: Resolver detects the package change, removes ReflectorNet.<vX>/, downloads ReflectorNet.<vY>/, then Unity's next recompile succeeds.

Actual: Nothing runs. The user is stuck in a broken-compile state until they manually delete the old NuGet folder (the workaround mentioned in #703).

Why the #705 fix isn't enough on its own

#705 made NuGetPackageInstaller.Install() self-cleaning — it now removes stale {Id}.<otherVersion>/ siblings of the package being installed. That fix is correct and necessary, but it only helps when Install() actually runs. In the reproduction above Install() never gets a chance: the resolver's entry point is gated on a successful recompile, which never happens.

Suspected cause

[InitializeOnLoad] semantics in Unity:

  • Fires after every successful script compilation cycle, not after every attempt.
  • If the post-import recompile fails, Unity keeps the previous AppDomain alive. New [InitializeOnLoad] types from the failing compile are never registered.
  • The previous AppDomain's NuGetDependencyResolver static constructor already ran exactly once, registered ResolveOnce to EditorApplication.update, ran it, and unsubscribed. There is no second trigger.

Per-assembly compile success doesn't help here. Even though com.IvanMurzak.Unity.MCP.DependencyResolver.asmdef compiles cleanly on its own (it has zero external dependencies, by design), Unity's domain reload is gated on the entire project compiling cleanly, not on individual asmdefs.

Proposed fix

Add a trigger that fires independently of [InitializeOnLoad] — specifically, one that survives across a failed recompile because it lives in the still-running AppDomain from the last successful compile.

Primary candidate: subscribe to UPM lifecycle events

In NuGetDependencyResolver's [InitializeOnLoad] static constructor, also subscribe to:

UnityEditor.PackageManager.Events.registeredPackages += OnRegisteredPackages;

This event fires from UPM after packages have been added/removed/updated on disk, before Unity attempts the script recompile that follows. The handler runs in the still-alive AppDomain from the last successful reload. Inside the handler:

  • Detect that a relevant package (the plugin or any dep) changed version.
  • Run the same restore + cleanup logic that ResolveOnce runs today (or at minimum, the new RemoveStaleSiblingVersions pass plus extraction of the new versions).
  • Trigger AssetDatabase.Refresh() so Unity picks up the corrected DLL set and the next recompile succeeds, unblocking domain reload.

After that, the normal [InitializeOnLoad] path resumes for subsequent reloads.

The handler must remain robust to being invoked while the project is in a broken-compile state — it can only rely on types defined inside the DependencyResolver assembly itself (which is already a design invariant of this assembly). It must not call into the main plugin assemblies.

Secondary considerations
  • Idempotency: If the handler and a later [InitializeOnLoad] both run for the same upgrade (e.g. happy path where the recompile succeeded on its own), the second pass should detect everything is already in sync and no-op. The existing AllPackagesInstalled() short-circuit covers this.
  • Subscription lifecycle: When the handler successfully unblocks the compile and Unity reloads the domain, the new AppDomain's static constructor will re-subscribe — the old subscription dies with the old domain.
  • Failure path: If the handler itself throws (e.g. network failure during NuGet download), surface a Debug.LogError and leave the project in its current state — the user can retry by re-importing or manually deleting the old folder. Do NOT set UNITY_MCP_READY from a partial run, same rule as today.
  • Alternative triggers to consider: AssetPostprocessor.OnPostprocessAllAssets (asset-level, not package-level — fires very often, would need filtering), AssemblyReloadEvents (fires on reload, but the problem is reload doesn't happen), EditorApplication.update polling against the PackageManager client (heavier, but works as a fallback if Events.registeredPackages proves unreliable across Unity versions).

Impact

  • Severity: High — this is the same end-user pain as #703 (broken project after upgrade) but with a different and more fundamental root cause. Without this fix, #705's cleanup logic has no way to trigger.
  • Workaround for end users today: delete the stale <Id>.<oldVersion>/ directory manually under the NuGet install path and reimport, same as the #703 workaround.

Related

  • #703 — original report of the duplicate-assembly problem after upgrade.
  • #705 — install-time stale-version cleanup. Necessary for the fix here to converge correctly, but does not address the trigger gap on its own.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with Packages/com.ivanmurzak.unity.mcp/Editor/DependencyResolver/NuGetDependencyResolver.cs, especially its [InitializeOnLoad] entry point and ResolveOnce path. Trace NuGetPackageRestorer and NuGetPackageInstaller, then verify a registeredPackages handler can clean stale versions, restore dependencies, refresh the AssetDatabase, remain idempotent, and report failures without partial readiness.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp, unity
Domain
game-dev, tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.