Skip to content

perf: defer hook metadata construction to first use (lazy hook registration) #5448

@thomhurst

Description

@thomhurst

Background

Follow-up to #5394 / PR that split test filter data out of the heavy per-class .cctor. Tests now defer __classMetadata, __mm_N, entry-array construction, etc. until a test from the class is actually selected for materialization. Hooks do not yet benefit from this pattern.

Current behavior

HookMetadataGenerator emits one static readonly int _h_X = SourceRegistrar.RegisterHook(...) field per hook on the shared TUnit_HookRegistration partial class. Every field initializer constructs the full hook object inline at module init. Example from TUnit.Core.SourceGenerator.Tests/AssemblyBeforeTests.Test.verified.txt:

static readonly int _h_..._BeforeAll1_Before_Assembly = SourceRegistrar.RegisterHook(
    Sources.BeforeAssemblyHooks,
    typeof(AssemblyBase1).Assembly,
    new BeforeAssemblyHookMethod
    {
        MethodInfo = new MethodMetadata
        {
            Type = typeof(AssemblyBase1),
            TypeInfo = new ConcreteType(typeof(AssemblyBase1)),
            Name = "BeforeAll1",
            // ...
            Class = ClassMetadata.GetOrAdd("...", new ClassMetadata
            {
                Type = typeof(AssemblyBase1),
                Assembly = AssemblyMetadata.GetOrAdd("...", "..."),
                Parameters = Array.Empty<ParameterMetadata>(),
                Properties = Array.Empty<PropertyMetadata>(),
                // ...
            })
        },
        HookExecutor = DefaultExecutor.Instance,
        Order = 0,
        RegistrationIndex = HookRegistrationIndices.GetNextBeforeAssemblyHookIndex(),
        Body = /* delegate ref */,
        // ...
    });

All of this runs at module initialization, for every hook in the assembly, regardless of whether any test from that class will ever run. Per-hook costs at startup:

  • MethodMetadata allocation
  • ClassMetadata.GetOrAdd (dictionary lookup + possible allocation)
  • AssemblyMetadata.GetOrAdd
  • Parameter / property array allocations
  • Delegate allocation for Body
  • ConcurrentBag.Add into the appropriate Sources.*Hooks collection

For an assembly with N hooks this is O(N) work at module load. The per-class test metadata path now pays almost none of this until materialization — hooks should follow suit.

Goal

Defer heavy hook metadata construction until the hook is first needed (i.e. when its owning class/assembly actually executes a test). At module init we only need enough state to know ''a hook of kind K exists for scope S''.

Proposed approach

  1. Sources.cs — change the hook collections from ConcurrentBag<InstanceHookMethod> (etc.) to a factory-backed container, e.g. ConcurrentBag<Func<InstanceHookMethod>> or a small LazyHookEntry wrapper holding a Func<HookMethod> + a materialized cache.
  2. SourceRegistrar.RegisterHook — add overloads that accept a factory delegate instead of a pre-constructed hook object.
  3. HookMetadataGenerator — emit static () => new InstanceHookMethod { ... } instead of inlining the object at the call site. Keep the per-hook Initializer delegate body class as-is (it's already lazy).
  4. Engine hook consumers — update every site that iterates Sources.*Hooks to materialize the factory (once, cached) before use. Grep for Sources.BeforeTestHooks, Sources.AfterTestHooks, Sources.BeforeClassHooks, Sources.AfterClassHooks, Sources.BeforeAssemblyHooks, Sources.AfterAssemblyHooks, Sources.BeforeEvery*, Sources.AfterEvery*.
  5. OrderingRegistrationIndex = HookRegistrationIndices.GetNext*() currently runs at registration. If ordering must reflect registration order rather than materialization order, compute the index eagerly at registration time and close over it in the factory, leaving only the heavy metadata lazy.
  6. Snapshots — regenerate all hook-related *.verified.txt snapshots in TUnit.Core.SourceGenerator.Tests/ (AssemblyBeforeTests, AssemblyAfterTests, BeforeTests, AfterTests, BeforeAllTests, AfterAllTests, Hooks1589, Hooks1594, ConflictingNamespaceTests.HooksTest_*, etc.).

Things to watch out for

  • Thread safety of materialization. Multiple tests may trigger the same hook factory concurrently; cache must be safe and ideally guarantee single construction (Lazy<T> with ExecutionAndPublication, or a double-checked Interlocked.CompareExchange).
  • Hook ordering / deduplication. Current code relies on RegistrationIndex and insertion order into ConcurrentBag. Ensure the lazy wrapper preserves both.
  • BeforeEvery* / AfterEvery* bags don't key by type — they're global. Same factory pattern applies.
  • AOT compatibility. Factories must be static lambdas with no captured state to avoid display-class allocations and keep AOT trimming happy.
  • Error surfacing. If a factory throws, it should surface at the hook-execution site with a clear message, not swallow into a silent no-op.

Out of scope

  • Changing the public hook attribute surface.
  • Touching reflection-mode hook discovery (separate code path; not affected by the generator change).

Acceptance criteria

  • TUnit_HookRegistration..cctor (as seen in the generated snapshot) contains only lightweight registration calls — no new MethodMetadata { ... }, no ClassMetadata.GetOrAdd, no parameter array allocations at the call site.
  • Heavy hook metadata is constructed at most once per hook, lazily, on first execution.
  • All existing hook tests pass across net472/net8/net9/net10.
  • No measurable regression in hook execution throughput (materialization cost is paid once).

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions