Skip to content

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

Description

@IvanMurzak

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

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions