Skip to content

build: take Purview.Build 0.5.3 - #40

Merged
kieronlanning merged 10 commits into
mainfrom
perf-mem
Oct 13, 2026
Merged

kieronlanning merged 10 commits into
mainfrom
perf-mem

Conversation

@kieronlanning

Copy link
Copy Markdown
Contributor

No description provided.

…ions

ResolveReferences created a fresh MetadataReference per run for every assembly
named by AdditionalAssemblyTypes, so Roslyn could not reuse PEAssemblySymbols and
each test compilation built its own symbol tables.

References are now created once per path, process-wide, and an assembly already in
the trusted-platform set is not added a second time. TRUSTED_PLATFORM_ASSEMBLIES is
deduplicated at the source: the host lists a path once per deps.json library that
resolves to it, so the "reference the generator project twice" pattern put the same
file in the list twice. Additions are deduplicated because Solution.AddMetadataReferences
rejects duplicate items once the instances are shared.

Measured on zodsharp SourceGenerators.UnitTests (1,289 tests, four TFM executables
concurrently under dotnet test): 15,340 MB -> 3,450 MB peak, 35.6s -> 28.5s wall clock.
An isolated 100-compilation probe isolates the mechanism: 156 MB with shared
references against 8,146 MB with per-compilation references.
DriverRunResult is IDisposable because a run that compiles to an assembly creates a
collectible AssemblyLoadContext and a MetadataLoadContext. Nothing enforced disposal,
and consumers accumulated undisposed results without noticing.

PSGFR42 flags a disposable result from the Testing namespace that is produced and then
never disposed: no using declaration, not returned to a caller, not disposed explicitly.
The indirect case is covered too, where a helper creates a result, projects a value out
of it and returns only the projection. Discarded results are flagged as well.

Severity is keyed syntactically on whether the run compiles to an assembly (a Compile
options member, or CompileToAssembly = true): Warning when it does, because that is when
the leak holds an assembly and its load contexts, and Info otherwise.

DisposeTestResultCodeFixProvider turns the declaration into a using declaration, with
Fix-all support. The framework's own CompileToAssembly tests are fixed accordingly, and
the Testing docs and skills record the rule along with the harness reference-sharing
behaviour and the parallelism-ceiling guidance.
The cache was a ConditionalWeakTable<IAssemblySymbol, ConcurrentDictionary<ITypeSymbol, …>>
keyed with SymbolEqualityComparer.Default. That was wrong twice over.

A TypeIdentity records its type arguments as TypeReference values, and those carry the
nullable reference annotation — but SymbolEqualityComparer.Default ignores it. List<string?>
and List<string> therefore shared one entry, and whichever was constructed first won for
every later caller. That is the cause of a generator emitting RequiredRule<string?> for a
non-nullable member once an earlier compilation in the same process had asked for the
nullable form.

A constructed generic also reports its *definition's* assembly as ContainingAssembly, so the
entry hung off the long-lived corlib assembly symbol while the key strongly referenced source
symbols, pinning whole compilations. A probe of 100 compilations that each take a TypeIdentity
of List<SourceType> kept 100/100 alive after a forced collection; it now keeps 1/100, the same
as taking no identity at all.

Both defects only became observable process-wide once the Testing harness started sharing
MetadataReference instances, because that made the corlib assembly symbol — and so the cache
entry — live for the whole run. Keying weakly on the symbol fixes both and is faster:
TypeIdentityBenchmarks moves from 13.7-15.7 ns to 8.0-8.9 ns, still zero-allocation.
A scoped query - query.In(node), or the Query carried by any CodeQueryResult - has exactly one
root no matter how many trees it carries, but every search enumerated Trees and resolved
RootOf(tree), which returns that one root each time. Count<T>() and GetAll<T>() therefore
multiplied every match by the number of trees, and the Get/Has/TryGet family walked the same
subtree once per tree before concluding a node was absent.

It was invisible in the existing tests because they all query single-tree compilations; it
bites on result.Output(), where the compilation carries the user source plus every generated
tree.

Searches now enumerate Roots(), which yields the scoping node alone when the query is nested.
SourceGeneratorTestRunnerBenchmarks left every DriverRunResult undisposed, so the
CompileToAssembly variants accumulated one collectible AssemblyLoadContext per iteration and
measured the leak alongside the run. Flagged by PSGFR42.
…p entries ordered

RunIncremental created a logging session id and baked it into the shared driver, but never
called ConfigureLogging. No sink was ever registered for that id, so CreateLogger returned
null and every framework log entry written during RunIncrementalAsync/GenerateIncrementalAsync
was silently dropped.

The sink is now registered for the whole sequence - the driver is shared, so it has to outlive
the individual runs - and drained between runs, so each IncrementalCacheRun carries only what
that run wrote through a new LogEntries property. A rerun whose output step is cached logs
nothing, which makes logging a second signal alongside the step reasons. ConfigureLogging also
registers with a self-contained generator's embedded SourceGenLogging copy, so merged analyzers
are covered on this path too.

RunAsync collected entries into a ConcurrentBag, whose enumeration order is unspecified, so
DriverRunResult.LogEntries came back in an arbitrary order. It is a ConcurrentQueue now and
both paths report entries in the order the generator wrote them.

LogEntries is an init-only property rather than a positional record parameter, so the
IncrementalCacheRun constructor stays source- and binary-compatible.
Add Purview.Build 0.5.2 to .config/dotnet-tools.json and replace the hand-rolled
dotnet tool install --tool-path .tools/purview-build in the Justfile with
dotnet tool restore, running the pipeline as dotnet purview-build.

The version is now pinned in the tool manifest alongside CSharpier, so it is
restored by the same command as every other tool and needs no bespoke install
step, no --add-source feed argument and no platform-specific executable suffix.
The dead pipeline_feed variable is removed, and the docs describing the old
.tools/purview-build path are updated to match.
Purview.Build 0.5.3 defaults RequiredCompilerFlags to empty. Earlier versions
forced the optimization=release check on and could not be configured off, because
binding an empty JSON array contributes no configuration keys and so never
overwrote the tool's non-empty code default.

These packages have therefore always been validated against that flag. Declaring
it keeps that behaviour rather than silently losing the check on the move to
0.5.3.

Verified with just pipeline-pack-validate: all packages still valid.
Picks up the fix that makes RequiredCompilerFlags configurable: earlier versions
forced the compiler-flag check on because binding an empty JSON array
contributes no configuration keys, so the tool's non-empty code default could
never be overridden.

This repository declares "RequiredCompilerFlags": ["optimization=release"]
explicitly, so the check it has always run under is preserved.

Verified with just pipeline-pack-validate against 0.5.3.
@kieronlanning
kieronlanning merged commit 6694151 into main Oct 13, 2026
1 check passed
@kieronlanning
kieronlanning deleted the perf-mem branch October 13, 2026 10:21
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.

1 participant