Skip to content

Conversation

@thomhurst
Copy link
Owner

Summary

Fixes the timing issue where hooks from library assemblies were collected before their module initializers completed, resulting in 0 hooks being found.

Problem

When using Microsoft.Testing.Platform, module initializers from different assemblies run asynchronously. The sequence was:

  1. Test assembly module initializer runs
  2. References library types: _ = typeof(LibraryType)
  3. Library assembly loads, but module initializer is queued (not executed immediately)
  4. Test module initializer completes
  5. HookDelegateBuilder.InitializeAsync() runs and collects hooks
  6. 0 hooks found because library module initializer hasn't run yet
  7. Library module initializer finally runs (too late)
  8. Library hooks get registered to Sources.* collections

Debug output showed:

Building global hook delegates...
Built 0 global hook delegates
[ModuleInitializer] TUnit infrastructure initializing...  ← Library's initializer, too late!

Solution

Use RuntimeHelpers.RunClassConstructor(type.TypeHandle) to force library module initializers to complete synchronously. Static constructors can only run AFTER module initializers, so calling this blocks until initialization finishes.

Generated code now:

var type_0 = typeof(global::TUnit.TestProject.Library.AsyncBaseTests);
// Force module initializer to complete before proceeding
// RunClassConstructor triggers static constructor, which can only run AFTER module initializer completes
global::System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor(type_0.TypeHandle);
global::TUnit.Core.GlobalContext.Current.GlobalLogger.LogDebug("[ModuleInitializer] Assembly initialized: ...");

AOT Compatible - Uses only statically-known types and AOT-safe RuntimeHelpers API

Changes

InfrastructureGenerator.cs

  • Replace _ = typeof(typeName) with RunClassConstructor() for synchronous initialization
  • Add logging to show when assembly initialization completes
  • Update documentation explaining the fix

claude-code-review.yml

  • Enhanced workflow to always post review comments (even when code is good)
  • Focus on suggesting architectural improvements, not minor optimizations
  • Explain WHY suggestions are better, not just WHAT to change

Snapshot Tests

  • Updated verified snapshots for fully qualified type names in generated code
  • Added missing ConflictingNamespaceTests snapshots

Test Plan

  • Build succeeds
  • Source generator produces correct code with RunClassConstructor()
  • Snapshot tests updated and pass
  • Verify hooks from library assemblies are now found during collection
  • Test with the original failing scenario from the issue

Related Issues

Related to the recent fix in #4599 for library assembly hooks not executing.

Fixes timing issue where hooks were collected before library module
initializers completed, resulting in 0 hooks found.

**Problem:**
Module initializers from different assemblies run asynchronously. When
test assembly's module initializer referenced library types via typeof(),
the library loaded but its module initializer was queued, not executed
synchronously. HookDelegateBuilder.InitializeAsync() then ran before
library hooks were registered to Sources.* collections.

**Solution:**
Use RuntimeHelpers.RunClassConstructor(type.TypeHandle) to force library
module initializers to complete synchronously. Static constructors can
only run AFTER module initializers, so this blocks until initialization
finishes. AOT-compatible (uses statically-known types).

**Changes:**
- InfrastructureGenerator: Replace typeof() with RunClassConstructor()
- Add logging for assembly initialization completion
- Update documentation explaining the fix
- Enhance Claude Code Review workflow to always comment and suggest
  architectural improvements

**Snapshot Tests:**
Updated verified snapshots for fully qualified type names in generated code.
Enhances logging to include the assembly name in all module initializer
log messages, making it easy to identify which assembly's module
initializer is running and when library assemblies are being loaded.

Example output:
[ModuleInitializer:TUnit.TestProject] Loading assembly containing: ...
[ModuleInitializer:TUnit.TestProject] Assembly initialized: ...
[ModuleInitializer:TUnit.TestProject.Library] TUnit infrastructure initializing...

This helps debug timing issues between test and library assemblies.
Prevents TypeInitializationException in third-party packages that reference
TUnit by ensuring TUnit.Core assembly is loaded before the module initializer
attempts to access GlobalContext or other TUnit.Core types.

This is cleaner than defensive try-catch as it ensures dependencies are
properly loaded and logging actually works.
Wraps all logging statements in try-catch blocks for defense in depth.
The explicit TUnit.Core loading (typeof) still fails fast if there's a
real problem, but logging failures won't crash initialization.

This provides:
- Fail-fast on real issues (TUnit.Core can't load)
- Graceful degradation (logging failures don't crash)
- Better debugging (logs when available)
- Safety for edge cases and third-party packages
…ption

The explicit TUnit.Core loading was causing FileNotFoundException in
third-party packages (like Verify.TUnit) during their build/test phase
when TUnit.Core.dll isn't available yet in the output directory.

By wrapping the loading in try-catch, we gracefully handle cases where
TUnit.Core is unavailable while still providing the early-loading
benefits for normal test execution.
MinVer automatically sets assembly version based on git tags, ensuring
that locally built assemblies have matching versions with published packages.
This fixes version mismatch issues where third-party packages like Verify.TUnit
expect specific TUnit.Core versions (e.g., 1.12.65.0).
MinVer should only be a GlobalPackageReference, not also a PackageVersion.
The root cause: Third-party NuGet packages (like Verify.TUnit) may be built
with older versions of TUnit.Core.SourceGenerator. When they run in CI with
newer TUnit.Core versions, any unguarded TUnit.Core type access causes
FileNotFoundException or version mismatch errors.

The solution: Wrap EVERY TUnit.Core reference (logging, SourceRegistrar) in
individual try-catch blocks. This makes the generated code resilient to:
- TUnit.Core version mismatches
- TUnit.Core not being available (transitive dependencies)
- Build order issues (assembly not loaded yet)

Critical changes:
- SourceRegistrar.IsEnabled set FIRST before any logging (most important)
- Each logging call wrapped separately so one failure doesn't block others
- Assembly loading (RunClassConstructor) still works even if logging fails
- Clear error messages in catch blocks explain why operations were skipped

This ensures library assemblies with hooks can still load other assemblies
even when TUnit.Core has version mismatches or isn't available.
This was referenced Feb 2, 2026
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