Resolve source-host anchor assemblies by name (Phase 6e-4c1)#9630
Conversation
Decouple the runsettings-XML parsing files from the VSTest object model: - Replace the VSTest SettingsException thrown during runsettings/test-run-parameter parsing (RunSettingsUtilities, TestRunParameters, MSTestAdapterSettings) with a new neutral InvalidRunSettingsException. This exception is deliberately DISTINCT from the existing AdapterSettingsException to preserve behavior byte-for-byte: the only typed settings-error handler, MSTestDiscovererHelpers.InitializeDiscovery, catches AdapterSettingsException (invalid MSTest settings values -> report + no tests), while a structural runsettings error historically threw VSTest SettingsException and escaped that handler to the host. The malformed <AssemblyResolution> throw site is reachable through PopulateSettings via SettingsProvider.Load, so reusing AdapterSettingsException there would have changed the escape semantics; the distinct InvalidRunSettingsException (unrelated to AdapterSettingsException) preserves them. The other sites are caught only by a broad catch(Exception) in CacheSessionParameters, so behavior there is identical either way. Only the direct typed-throw unit assertions change. - Inline the VSTest ObjectModel.Constants runsettings node names (RunConfiguration, TestRunParameters) as neutral constants. - Repoint XmlRunSettingsUtilities.ReaderSettings at the equivalent neutral RunSettingsUtilities.ReaderSettings that already existed. - Add a neutral XmlReaderUtilities (ReadToRootNode + ReadToNextElement/SkipToNextElement) replacing the VSTest ObjectModel.Utilities helpers, reusing the exact navigation semantics the adapter already vendored privately in RunConfigurationSettings. Drops the PlatformServices VSTest-ObjectModel coupling from 10 to 6 files (the remaining are the netfx residuals + AssemblyResolver string literals). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…6e-4b) Remove the compile-time VSTest object-model dependency from the netfx AppDomain wiring: AppDomainUtilities used typeof(TestCase).Assembly to (1) add the object-model assembly's directory to the child app-domain's resolution paths and (2) anchor the 11.0 -> current binding redirect. Both run parent-side during test source host setup, after the adapter has already loaded the object model, so the assembly is resolved by simple name from the current domain instead - returning the same (post-redirect) assembly identity the type reference did, without a compile-time reference. The only remaining mention of the object model in this file is the assembly's simple name as a string literal (used for the lookup and, formerly, by the resolver's skip-list), which is not an assembly reference and does not block dropping the package. Proven on the netfx AppDomain scenario the type reference protects: PlatformServices.Desktop.IntegrationTests (assembly-resolution-from-runsettings + deployment app-domain paths) stays green (15/15). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Remove the compile-time VSTest object-model dependency from the netfx source-host setup. TestSourceHost used two `typeof(...).Assembly` anchors into the VSTest object model: - `typeof(EqtTrace).Assembly` (force-loading Microsoft.TestPlatform.CoreUtilities into the child app domain to avoid a recursive assembly-resolution cycle), and - `typeof(AssemblyHelper).Assembly` (locating the test-platform directory for the resolution paths). Both run parent-side (or reflect parent-side loaded assemblies) before the child app domain resolves anything, so they are resolved by simple name from the current domain via a small `GetLoadedAssembly(simpleName)` helper - returning the same loaded assembly identity the type references did. EqtTrace's defining assembly is CoreUtilities (it is type-forwarded from the object model), so that anchor targets CoreUtilities by name; AssemblyHelper lives in the object model, so that anchor targets the object model. The only remaining object-model mention in the file is the assembly simple name as a string literal (not an assembly reference). Proven on the netfx child-app-domain path: PlatformServices.Desktop.IntegrationTests stays green (15/15). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
bb13e25
into
dev/amauryleve/vstest-decoupling-conversion
There was a problem hiding this comment.
Note
🤖 Automated review by GitHub Copilot. Posted via a maintainer's GitHub token, so it appears under their account — the account owner did not write or approve this content personally. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.
Expert TestFx Review — PR #9630 (Post-Merge)
Phase 6e-4c1: Resolve source-host anchor assemblies by name
Overall: a clean, well-scoped refactoring. Two minor/major findings on the new DoesSourceReferenceAssembly implementation; everything else is in good shape.
Note on diff fidelity: The diff used to trigger this review contained a rendering artefact in
SuspendCodeCoverage.cs— the constructor header appeared duplicated inside the constructor body. The actual committed file (c561276) is correct. TheDoesSourceReferenceAssemblyimplementation in the actual commit also differs from the diff (usesArePublicKeyTokensEqualhelper + broadcatchinstead ofSequenceEqual+ typed exception filter) — presumably a follow-up fixup commit was squashed before merge.
Verdict Table
| # | Dimension | Status | Severity | Notes |
|---|---|---|---|---|
| 1 | Algorithmic Correctness | MAJOR | ArePublicKeyTokensEqual(byte[], byte[]) does not guard against null arguments; both call sites pass GetPublicKeyToken() which returns byte[]?. Functionally saved today by the broad catch, but fragile (see inline comment on line 142). |
|
| 2 | Threading & Concurrency | ✅ Clean | — | No shared mutable state. GetAssemblies() snapshot is inherently safe for a read. |
| 3 | Security & IPC Contract Safety | ✅ Clean | — | Assembly-name strings are const; no user-controlled input reaches Assembly.Load. Env-var mutation in SuspendCodeCoverage is process-scoped and properly restored. |
| 4 | Public API & Binary Compatibility | ✅ Clean | — | All new types (InvalidRunSettingsException, XmlReaderUtilities, SuspendCodeCoverage) are internal/internal sealed. No PublicAPI.Unshipped.txt update needed. No init accessors introduced. |
| 5 | Performance & Allocations | MINOR | GetObjectModelAssembly() is called twice in SetConfigurationFile (lines 172 & 175), each time scanning GetAssemblies(). Inexpensive in context but easily avoided with a local (see inline comment on line 175). |
|
| 6 | Cross-TFM Compatibility | ✅ Clean | — | #if NETFRAMEWORK / #if NETFRAMEWORK || (NET && !WINDOWS_UWP) guards are correctly scoped. ReflectionOnlyLoadFrom (netfx-only) stays behind #if NETFRAMEWORK. AppDomain APIs are correctly guarded. |
| 7 | Resource & IDisposable Management | ✅ Clean | — | SuspendCodeCoverage.Dispose correctly restores the previous env-var value with a _isDisposed guard. No unmanaged resources; GC.SuppressFinalize not required. |
| 8 | Defensive Coding at Boundaries | MAJOR | Same as #1 — ArePublicKeyTokensEqual receives potentially-null byte arrays without a null-guard. The broad catch { return null; } is the only runtime shield. |
|
| 9 | Localization & Resources | MINOR | XmlReaderUtilities.ReadToRootNode throws new InvalidRunSettingsException($"Could not find '{RunSettingsRootNodeName}'...") with a hardcoded interpolated string. All similar messages elsewhere go through Resource.*. Low impact (internal diagnostic path), but inconsistent with project conventions. |
|
| 10 | Test Isolation | ✅ Clean | — | SuspendCodeCoverage restores the process environment variable on dispose; no lasting side-effects between tests. |
| 11 | Assertion Quality | ✅ Clean | — | Updated test assertions correctly target InvalidRunSettingsException instead of SettingsException. No tautological or trivial assertions introduced. |
| 12 | Naming & Conventions | ✅ Clean | — | Constants follow PascalCase, private fields _camelCase. nameof not required (no member references to replace). ArePublicKeyTokensEqual is a clear, accurate name. |
| 13 | Error Handling Completeness | MINOR | The actual committed DoesSourceReferenceAssembly uses catch { return null; } (catch-all). The diff version used a typed filter (BadImageFormatException | FileNotFoundException | FileLoadException). The typed filter is better practice as it preserves unexpected exceptions (OutOfMemoryException, etc.) from being silently swallowed. |
|
| 14 | Code Duplication & Reuse | MINOR | TestSourceHost.GetLoadedAssembly(string simpleName) and AppDomainUtilities.GetObjectModelAssembly() are near-identical (same LINQ scan + Assembly.Load fallback). A shared TestPlatformAssemblyResolver utility (or promoting GetLoadedAssembly to AppDomainUtilities) would eliminate the duplication. Reasonable to defer to a follow-up decoupling phase. |
|
| 15 | MSTest/MTP-Specific Patterns | ✅ N/A | — | No test attributes, TestContext usage, or retry logic changed. |
| 16 | Documentation & Comments | ✅ Clean | — | All new types and helpers have accurate XML doc comments with <exception> documentation where relevant. The GetLoadedAssembly doc clearly states the "always loaded before this runs" invariant. |
| 17 | Dependency & Coupling | ✅ Clean | — | The explicit goal of the PR: removes ObjectModel and ObjectModel.Utilities compile-time references from TestSourceHost, AppDomainUtilities, TestSourceHandler, MSTestAdapterSettings, RunSettingsUtilities. Net improvement. |
| 18 | Configuration & Flags | ✅ Clean | — | Assembly simple-names ("Microsoft.VisualStudio.TestPlatform.ObjectModel", "Microsoft.TestPlatform.CoreUtilities") are stable VSTest contracts; const string is appropriate. __VANGUARD_SUSPEND_INSTRUMENT__ / "TRUE" match the well-known collector contract. |
| 19 | Diff Hygiene | MINOR | Presented diff showed a corrupted SuspendCodeCoverage constructor (actual committed code is clean — diff artefact). Copyright-header-only changes on TestSourceHost.cs and AppDomainUtilities.cs (trailing-whitespace normalisation). |
|
| 20 | Test Coverage for Changed Code | MINOR | DoesSourceReferenceAssembly / ArePublicKeyTokensEqual are not covered by the visible unit-test changes. The PR reports 897/935 PlatformServices unit tests pass and 15 integration tests pass, which provides reasonable integration-level coverage. A unit test for ArePublicKeyTokensEqual(null, ...) / (..., null) would lock in the expected behaviour and would guard against narrowing the catch. |
|
| 21 | MTP Extension Contracts | ✅ N/A | — | No Microsoft.Testing.Platform extension code touched. |
| 22 | PowerShell / Script Quality | ✅ N/A | — | No .ps1 or script files changed. |
Summary
No blocking issues. The two actionable items are:
-
ArePublicKeyTokensEqualnull safety (MAJOR, inline comment onTestSourceHandler.cs:142): update the signature tobyte[]?and add a null/empty guard to make the "name-only match when token absent" behaviour explicit, as described in the PR. This also makes the broadcatchsafe to tighten back to a typed filter in a follow-up. -
byte[] referenceAssemblyPublicKeyTokenannotation (MAJOR, inline comment onTestSourceHandler.cs:113): the type should bebyte[]?to matchGetPublicKeyToken()'s return type.
Everything else is informational and can be addressed in follow-up phases.
| try | ||
| { | ||
| string? referenceAssemblyName = referenceAssembly.Name; | ||
| byte[] referenceAssemblyPublicKeyToken = referenceAssembly.GetPublicKeyToken(); |
There was a problem hiding this comment.
Nullable type annotation mismatch — latent NRE risk
AssemblyName.GetPublicKeyToken() returns byte[]?, but the local is declared as byte[] (non-nullable):
byte[] referenceAssemblyPublicKeyToken = referenceAssembly.GetPublicKeyToken();
// ^^^^^^ should be byte[]?This propagates into ArePublicKeyTokensEqual(byte[] left, byte[] right) which dereferences .Length unconditionally. In practice, MSTest's strongly-named assemblies always produce a non-null token, so this doesn't fire today — but the broad catch { return null; } is the only runtime guard. If the catch is ever narrowed to typed exceptions (as the original diff version showed) the NRE would surface as null instead of false.
Suggested fix:
byte[]? referenceAssemblyPublicKeyToken = referenceAssembly.GetPublicKeyToken();And update ArePublicKeyTokensEqual to accept byte[]? (or add an upfront guard against null/empty in DoesSourceReferenceAssembly before passing to the helper).
| } | ||
| } | ||
|
|
||
| private static bool ArePublicKeyTokensEqual(byte[] left, byte[] right) |
There was a problem hiding this comment.
ArePublicKeyTokensEqual does not guard against null arguments
Both GetPublicKeyToken() calls at the call site can return null. The method signature accepts byte[] (non-nullable) and immediately accesses .Length, so a null token causes a NullReferenceException swallowed by the outer catch.
If the catch is ever tightened to specific exceptions (the original typed-filter version was more defensive), this will silently flip null→false instead of returning null from DoesSourceReferenceAssembly.
// Current signature
private static bool ArePublicKeyTokensEqual(byte[] left, byte[] right)
// Safer signature (handles unsigned / absent tokens explicitly)
private static bool ArePublicKeyTokensEqual(byte[]? left, byte[]? right)
{
// If either side has no token, treat as a name-only match (unsigned assemblies)
if (left is null || left.Length == 0 || right is null || right.Length == 0)
{
return true;
}
if (left.Length != right.Length) return false;
for (int i = 0; i < left.Length; i++) { if (left[i] != right[i]) return false; }
return true;
}This matches the behaviour described in the PR description ("If either key token is absent, treat as match on name alone").
| if (!string.Equals(currentVersionOfObjectModel, ObjectModelVersionBuiltAgainst, StringComparison.Ordinal)) | ||
| { | ||
| AssemblyName assemblyName = typeof(TestCase).Assembly.GetName(); | ||
| AssemblyName assemblyName = GetObjectModelAssembly().GetName(); |
There was a problem hiding this comment.
GetObjectModelAssembly() called twice in the same if-block
Both line 172 and this line call GetObjectModelAssembly(), which each time scans all loaded assemblies via AppDomain.CurrentDomain.GetAssemblies(). This is initialisation code so the cost is low, but the pattern is inconsistent — TestSourceHost.GetLoadedAssembly is invoked the same way. Caching in a local avoids redundant allocation and makes the intent clearer:
var objectModelAssembly = GetObjectModelAssembly();
string currentVersionOfObjectModel = objectModelAssembly.GetName().Version.ToString();
if (!string.Equals(currentVersionOfObjectModel, ObjectModelVersionBuiltAgainst, StringComparison.Ordinal))
{
AssemblyName assemblyName = objectModelAssembly.GetName();
...
}(Minor; fine to leave or fix as a follow-up.)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Phase 6e-4c1 — Resolve the source-host anchor assemblies by name
Part of the initiative to make
MSTestAdapter.PlatformServicesplatform-agnostic by removing its dependency on the VSTest object model. Strict byte-for-byte refactor.What this does
TestSourceHostused twotypeof(...).Assemblyanchors into the VSTest object model (both in netfx / net-non-uwp source-host setup):typeof(EqtTrace).Assembly— force-loadsMicrosoft.TestPlatform.CoreUtilitiesinto the child app domain to avoid a recursive assembly-resolution cycle (per the existing comment).typeof(AssemblyHelper).Assembly— locates the test-platform directory for the resolution paths.Both reflect parent-side loaded assemblies before the child domain resolves anything, so they are now resolved by simple name via a small
GetLoadedAssembly(simpleName)helper (the same pattern proven in 6e-4b), returning the same loaded assembly identity the type references did.Key detail (empirically verified against the built assemblies): the two anchors target different assemblies —
EqtTraceis type-forwarded from the object model, so its defining assembly is CoreUtilities (matching the "Force loading … CoreUtilities" comment);AssemblyHelperlives in the object model. The helper is guarded to exactly the TFMs where thetypeofs compiled (NETFRAMEWORKfor the EqtTrace anchor;NETFRAMEWORK || (NET && !WINDOWS_UWP)for the AssemblyHelper anchor /GetResolutionPaths).The only remaining object-model mention in the file is the assembly simple name as a string literal (not an assembly reference). This fully decouples
TestSourceHost.Fidelity proof
PlatformServices.Desktop.IntegrationTests(net462 child-app-domain force-load + assembly-resolution + deployment paths): 15/15 green — the exact scenario these anchors protect.Verification
build.cmd -c Debuggreen all TFMs (net462/net8.0/net9.0/UWP/WinUI), IDE0005 clean.PlatformServices.Desktop.IntegrationTests: 15, 0 failed.MSTestAdapter.PlatformServices.UnitTests: 897 (net8.0) / 935 (net462), 0 failed.Remaining after this
2 files with real deps:
TestSourceHandler(AssemblyHelper.DoesReferencesAssembly) andTestDeployment(SuspendCodeCoverage, netfx). Then Phase 7 (drop the PackageReference + guard test).Stacking
… 6e-3b #9626→6e-4a #9627→6e-4b #9628→ 6e-4c1 (this), ontodev/amauryleve/vstest-decoupling-base. Base = 6e-4b branch (dev/amauryleve/vstest-decoupling-appdomain). Do not rebase/reset the base.