Skip to content

[Vsintegration] Fix lost assembly references and project cleanup in the code model - #2127

Merged
RobertvanderHulst merged 9 commits into
X-Sharp:devfrom
hpetriffer:fix/codemodel-assembly-cleanup
Oct 27, 2026
Merged

RobertvanderHulst merged 9 commits into
X-Sharp:devfrom
hpetriffer:fix/codemodel-assembly-cleanup

Conversation

@hpetriffer

Copy link
Copy Markdown
Contributor

Follow-up to #2125. This PR fixes the FOREIGN KEY failures that were still open there, along with several walker and cleanup problems in the code model that showed up while investigating them.

Assembly references lost in the database

SaveProjectAssemblyReferences deleted every assembly that no project referenced yet (delete from Assemblies where id not in (select IdAssembly from AssembliesPerProject)). Projects resolve their references on many threads, so the first project to save deleted assemblies that other projects had just read but not linked yet, together with their types (cascade).

  • Every later save then failed with FOREIGN KEY constraint failed on those rows, so no project stored its assembly references.
  • On the next load the same assemblies were inserted, read and deleted again.
  • Measured with RadixWfSDK.slnx: one cleanup deleted 8 assemblies, and 93 of 97 project saves failed on them.

Changes:

  • XDatabase: unreferenced assemblies are now removed in DeleteOrphanFiles when the database is opened, before any XAssembly has its Id.
  • SystemTypeController.LoadAssembly: it created and read a new XAssembly for mscorlib.dll on every call (once per project). It now uses the cached instance.

ModelWalker

  • No false finish: a project counts as walked only when all of its files were walked. When Stop() (the solution closes) or Suspend() (completion, quick info and so on) skipped files, FileWalkCompleted, ProjectWalkComplete and type resolution no longer run for it.
  • Suspended projects are walked again: after a suspend the project is queued again, and files that were already walked are not parsed a second time.
  • One generation per walker thread: the current project and the file count now belong to the walk itself (ProjectWalk) instead of static fields, and every walker thread checks its generation. A thread that still finishes files after Stop() can no longer change the walk of the next solution.
  • Status bar: the animation is reset when the walk is aborted.

Projects that are no longer part of the solution

  • Cleanup moved to close: AfterOpen deleted every database project that was not loaded yet. CPS projects create one code model per TargetFramework asynchronously, after AfterOpen, so their files and types were deleted and parsed again on every load. Projects that no XProject has read in this session are now deleted when the solution closes, before the database is saved.
  • Safety checks: nothing is deleted when the solution closes while it is still loading. Nothing is deleted while a CPS project still has code models to create (XSolution.SetModelsPending, set by XSharpProjectAdapter).
  • Adapter: duplicated lines were removed.

Test

VS 2022 experimental instance, RadixWfSDK.slnx:

  • Six runs of the assembly reference fix (settled database, new database, devenv on the E-cores): 0 errors in the whole log, against 38–164 per load before. Every assembly is read once per load: 469 reads for 469 files, where mscorlib alone was read 98 times before. A load with an up-to-date database writes no assembly data any more.
  • Project cleanup and walker changes (new database): 0 [ERR], 0 FOREIGN KEY failures, 0 projects deleted at close.
  • Not tested in the IDE: the last commit (status bar reset, dead code removed in AfterOpen). It builds.

🤖 Generated with Claude Code

hpetriffer and others added 9 commits October 26, 2026 18:24
While XSolution.Close() closed the database on the UI thread, background
threads (CPS design-time builds, the ModelWalker) kept using it, and
several threads read the same assemblies at the same time. In the logs
this showed up as "No connection associated with this command",
"Database is not open" and NullReferenceExceptions in
AssemblyReader.AddType, OnTargetDesignTimeBuildChanged and FindType.

- XDatabase: all access to the in memory connection is serialized by a
  fixed lock object instead of oConn, which CloseDatabase sets to NULL.
  Every method checks IsDbOpen again once it holds the lock, and
  CloseDatabase closes the connection under that lock. The backup locks
  only around BackupDatabase as before; the VACUUM stays outside.
- XAssembly.Read() runs under a lock per assembly and does not read
  again when another thread has just read the unchanged file. When the
  database is closed, the file time and size are kept in memory, so type
  lookups do not read the assembly over and over.
- SystemTypeController.LoadAssembly: one lock per file name. Threads
  that asked for the same assembly at the same time each created (and
  read) their own XAssembly; the logs showed 30 reads of one file.
- XSharpProjectAdapter: no reference resolution, file changes or new
  target models while the solution closes or after it has closed. CPS
  keeps delivering design-time builds until the projects are unloaded.
- XProject.RootNamespace no longer fails after Close() cleared the
  project node while a background parse still resolves types.

Measured in the VS 2022 experimental instance with RadixWfSDK.slnx,
closed 60 s after loading, with a new database and devenv pinned to the
E-cores (the race only shows while VS is still busy): 75 errors between
"Closing solution" and "Shutdown VS" before, 0 after; the close took
16.2 s before and 2.6 s after. Assembly reads during the load went from
656-3354 to 566-595.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SaveProjectAssemblyReferences deleted every assembly that no project
referenced yet ("delete from Assemblies where id not in (select
IdAssembly from AssembliesPerProject)"). The projects resolve their
references on many threads, so the first project that saved its
references deleted assemblies other projects had just read but not
linked yet, together with their types (cascade). Every later save of
every project then failed with "FOREIGN KEY constraint failed" on those
rows, so no project stored its assembly references, and the next load
inserted, read and deleted the same assemblies again. Measured with
RadixWfSDK.slnx: one cleanup deleted 8 assemblies, and the saves of 93
of 97 projects failed on them.

- The cleanup of unreferenced assemblies now runs in DeleteOrphanFiles
  when the database is opened, before any XAssembly has its Id.
- SystemTypeController.LoadAssembly created and read a second XAssembly
  for mscorlib.dll on every call (once per project); it now uses the
  cached instance.
- ModelWalker: after Stop() the walk of the current project no longer
  finishes as if all files were walked (FileWalkCompleted,
  ProjectWalkComplete, type resolution).

Measured in the VS 2022 experimental instance (six runs: settled, new
database, devenv on the E-cores): 0 errors in the whole log, before
38-164 per load. Every assembly is read once per load (469 for 469
files, mscorlib 98 times before), and a load with an up to date database
writes no assembly data any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e walked

walkOneFile also skips files while the walker is suspended, and quick
info, completion, signature help and peek suspend it while a walk runs.
The project was then still finished as walked (FileWalkCompleted,
ProjectWalkComplete, type resolution), and the skipped files were not
walked again. The MustAbort check of the previous commit only covered
Stop().

MustAbort is global state: when the next solution starts a new walker
thread, _state is Running again, so an old thread that was still busy
with its files after Stop() finished its project against the new
database. It also set the static _currentProject to NULL, so the new
thread could skip all files of its project and finish it as walked.

- Stop() increments a generation. Each walker thread gets the generation
  it was started for and stops when it changed (IsAborted).
- The project and the count of walked files are per walk (ProjectWalk)
  instead of the static _currentProject.
- A project with skipped files is queued again, unless the walk was
  aborted. The enqueue for type resolution checks the generation under
  the lock that Stop() takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the solution closes

DeleteProject is commented out (4242326): AfterOpen() called it for
every project in the database that was not registered yet, but the code
models of CPS projects (one per TargetFramework) are created after
AfterOpen(), so it deleted their files and types on every load. Since
then nothing deletes projects: a project removed from the solution or a
TargetFramework that is no longer used stays in the database with its
files, types and assembly references, and its assemblies are never
cleaned up either.

- XDatabase.Read(XProject) remembers the Ids of the projects read since
  the database was opened.
- XSolution.Close() deletes all other projects (cascade) before the
  database is saved, only when AfterOpen() ran for the solution: a
  solution closed while it is still loading has projects that were not
  read yet. The files and assemblies they leave behind are deleted by
  DeleteOrphanFiles when the database is opened again.

A project that was unloaded during the whole session is deleted as well;
it is parsed again when it is loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… pending

The CPS adapter creates the code models of the TargetFrameworks of a
cross targeting project asynchronously, after AfterOpen(): only when the
configuration group has arrived and the configured project is loaded. A
solution that was closed before then deleted the database rows of those
models with their files and types (DeleteProjectsNotInSession), and they
were parsed again on the next load.

- The adapter knows the TargetFrameworks that get a model (from the
  project file in LoadAsync, then from the configuration group) and
  reports to XSolution.SetModelsPending whether one of them has no model
  yet. UnloadAsync clears it.
- XSolution.Close() deletes the projects only when no CPS project has
  pending models, and logs why it skipped the cleanup.

A target whose configured project can not be loaded stays pending, so no
projects are deleted in that session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Left over from a14f11b (one code model per TargetFramework):

- UpdateTargetSubscriptionsAsync added the model of a dropped target
  twice to the models to close. The second Add was not under the if
  that excludes the primary model, so a model was closed twice, and the
  primary model could be closed although "the primary model is closed
  in UnloadAsync, never here".
- LoadAsync subscribed to ProjectWalkComplete and FileWalkComplete of
  the primary model, which CreateModel had already done, so its handlers
  (comment tasks, file walk) ran twice. It also set the model twice.
- A nested lock (modelGate) inside lock (modelGate), with an assignment
  of keep that was overwritten right away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… remove dead code in AfterOpen

- WalkSource left the status bar animation on when Stop() was called after the last file was walked.
- AfterOpen collected the project names from the database only to call DeleteProject, which no longer
  deletes anything: projects that are no longer part of the solution are deleted when it closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-cleanup

# Conflicts:
#	src/VisualStudio/ProjectSystemCPS/LanguageService/XSharpProjectAdapter.cs
#	src/VisualStudio/XSharpCodeModelXs/Database/XDatabase.prg
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants