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
- 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.
- 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.
- UPM writes new package files to disk. Unity attempts a recompile.
- Compilation fails: plugin assemblies reference
ReflectorNet symbols that don't exist in the still-on-disk ReflectorNet.<vX>.dll.
- 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
Summary
Follow-up to #703 / #705. Even with the
Install()-time stale-version cleanup landed, upgradingcom.ivanmurzak.unity.mcpin 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 newNuGetDependencyResolverstatic 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 += ResolveOncehandler already fired, unsubscribed itself, and ran exactly once. So nothing in the running domain ever attempts another restore.The fact that the
DependencyResolverassembly is wrapped in its ownasmdefand has no compilation errors inside itself is necessary but not sufficient —[InitializeOnLoad]cares about whole-project compile state, not per-assembly state.Affected area
Unity-MCPUnity-MCP-PluginPackages/com.ivanmurzak.unity.mcp/Editor/DependencyResolver/NuGetDependencyResolver.cs(the[InitializeOnLoad]entry point), with downstream impact onNuGetPackageRestorer/NuGetPackageInstaller.Steps to reproduce
com.ivanmurzak.unity.mcpat version N, which depends onReflectorNetvX. Resolver runs on first reload, installs DLLs, project compiles,UNITY_MCP_READYset.ReflectorNetdependency is bumped to vY (vY > vX) and the plugin code uses newReflectorNetAPIs only available in vY.ReflectorNetsymbols that don't exist in the still-on-diskReflectorNet.<vX>.dll.[InitializeOnLoad]never fires for the new compile.Expected: Resolver detects the package change, removes
ReflectorNet.<vX>/, downloadsReflectorNet.<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 whenInstall()actually runs. In the reproduction aboveInstall()never gets a chance: the resolver's entry point is gated on a successful recompile, which never happens.Suspected cause
[InitializeOnLoad]semantics in Unity:[InitializeOnLoad]types from the failing compile are never registered.NuGetDependencyResolverstatic constructor already ran exactly once, registeredResolveOncetoEditorApplication.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.asmdefcompiles 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: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:
ResolveOnceruns today (or at minimum, the newRemoveStaleSiblingVersionspass plus extraction of the new versions).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
[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 existingAllPackagesInstalled()short-circuit covers this.Debug.LogErrorand leave the project in its current state — the user can retry by re-importing or manually deleting the old folder. Do NOT setUNITY_MCP_READYfrom a partial run, same rule as today.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.updatepolling against the PackageManager client (heavier, but works as a fallback ifEvents.registeredPackagesproves unreliable across Unity versions).Impact
<Id>.<oldVersion>/directory manually under the NuGet install path and reimport, same as the NuGet resolver doesn't replace stale ReflectorNet version after package update — compilation errors in user projects #703 workaround.Related