IvanMurzak / IvanMurzak/Unity-MCP
NuGet resolver doesn't trigger after package upgrade when project has compile errors — [InitializeOnLoad] blocked, no domain reload
Nobody has claimed this yet.
- 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 onNuGetPackageRestorer/NuGetPackageInstaller.
Steps to reproduce
- In a third-party Unity project, install
com.ivanmurzak.unity.mcpat version N, which depends onReflectorNetvX. Resolver runs on first reload, installs DLLs, project compiles,UNITY_MCP_READYset. - Upgrade the Unity package via UPM to version N+1, where the
ReflectorNetdependency is bumped to vY (vY > vX) and the plugin code uses newReflectorNetAPIs only available in vY. - UPM writes new package files to disk. Unity attempts a recompile.
- Compilation fails: plugin assemblies reference
ReflectorNetsymbols that don't exist in the still-on-diskReflectorNet.<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
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.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
ResolveOnceruns today (or at minimum, the newRemoveStaleSiblingVersionspass 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 existingAllPackagesInstalled()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.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. - 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.updatepolling against the PackageManager client (heavier, but works as a fallback ifEvents.registeredPackagesproves 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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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