Skip to content

Design native COM lifetime integration test harness#496

Description

@jozefizso

Problem Statement

NetOffice does not have a deterministic integration harness that can prove its COM lifetime behavior against real native objects. Existing managed unit tests can validate wrapper and event plumbing, but they cannot observe native IUnknown identity, AddRef/Release, destruction, connection-point ownership, callback nesting, or apartment entry. This leaves memory leaks, dangling RCWs, premature disconnection, double disposal, event-sink leaks, and release-during-call races vulnerable to regression.

The problem is especially difficult when one native identity appears through multiple NetOffice wrappers, tear-off interfaces, callbacks, collections, raw RCWs, dynamic, or early-bound C# [ComImport] interfaces. CLR interface caching, marshaling, and finalization also make exact native reference-count sequences unstable. A useful suite must therefore test consumer-visible safety and native invariants without pretending that private RCW entry counts are a supported contract.

Solution

Build a Windows-only lifetime integration harness around one primary behavioral seam: an external test controller launches an isolated scenario host, the host exercises an actual generated NetOffice API against an instrumented in-process C++ COM fixture, and the controller evaluates native and managed traces after the scenario. This is the highest seam that covers generated invocation, event sinks, RCW/CCW interop, apartments, disposal, finalization, and native lifetime behavior end to end.

Each native scenario runs in a fresh architecture-matched child process contained in a Windows Job Object. The native fixture records identity, reference operations, calls, callbacks, connection points, apartment entry, barriers, and destruction through an ownership-neutral memory-mapped trace. NetOffice emits correlated lifecycle diagnostics without retaining wrappers or RCWs. Deterministic barriers control race ordering; sleeps are not synchronization.

Use registration-free COM activation through side-by-side manifests. Do not require elevation, regsvr32, registry mutation, or CoRegisterClassObject. Build explicit x86 and x64 hosts and fixture DLLs. Mark every native-dependent NUnit test with IntegrationTests, preserving the existing default CI exclusion and running the harness only through an explicit integration selection or scheduled workflow.

User Stories

  1. As a NetOffice maintainer, I want a native COM lifetime harness, so that lifetime changes are verified against actual IUnknown behavior rather than managed fakes.
  2. As a NetOffice maintainer, I want the suite to exercise generated wrappers, so that it covers the same invocation paths consumers use.
  3. As a NetOffice maintainer, I want generated event sinks exercised end to end, so that callback argument and connection-point regressions are detectable.
  4. As a test author, I want each scenario isolated in a fresh process, so that RCW caches, finalizers, static registries, and leaked connections cannot contaminate later tests.
  5. As a test author, I want crashing scenarios contained by a Job Object, so that an access violation does not terminate the test runner.
  6. As a test author, I want startup, progress, and total deadlines, so that activation failures and deadlocks are classified precisely.
  7. As a test author, I want deterministic call and callback barriers, so that disposal races can be replayed without timing sleeps.
  8. As a test author, I want complete failure traces and dumps, so that the first violated lifetime ordering can be diagnosed.
  9. As a CI maintainer, I want registration-free COM activation, so that tests run without administrator rights or machine mutation.
  10. As a CI maintainer, I want exact process and DLL bitness matching, so that x86 and x64 behavior is tested deliberately rather than through accidental activation.
  11. As a CI maintainer, I want native tests categorized as IntegrationTests, so that the existing default test workflow remains independent of native fixtures.
  12. As a CI maintainer, I want an explicit integration workflow, so that native scenarios can be opted into locally, manually, and on a schedule.
  13. As a NetOffice consumer, I want one wrapper to remain usable until I dispose it, so that wrapper lifetime is predictable.
  14. As a NetOffice consumer, I want repeated disposal of one wrapper to be harmless, so that cleanup is idempotent.
  15. As a NetOffice consumer, I want calls through a disposed wrapper rejected before native entry, so that stale wrappers cannot invoke COM accidentally.
  16. As a NetOffice consumer, I want forgotten wrappers to become collectible while Core remains alive, so that diagnostic tracking does not create leaks.
  17. As a NetOffice consumer, I want disposing one of several wrappers over the same native object to leave the others callable, so that independent wrappers do not disconnect a shared RCW.
  18. As a NetOffice consumer, I want repeated method, property, indexer, and enumeration returns of one object to share canonical identity, so that wrapper behavior does not depend on ingress path.
  19. As a NetOffice consumer, I want different interface pointers with the same controlling IUnknown to coalesce, so that tear-off interfaces do not create competing cleanup authorities.
  20. As a NetOffice consumer, I want aggregated interfaces to use the outer controlling identity, so that legal COM aggregation is handled correctly.
  21. As a NetOffice consumer, I want distinct sibling objects to remain distinct, so that identity coalescing never merges unrelated native objects.
  22. As a NetOffice consumer, I want pointer reuse after destruction handled safely, so that a new object cannot inherit stale registry state.
  23. As a NetOffice event subscriber, I want callback objects treated as borrowed on ingress, so that NetOffice does not release an object owned elsewhere.
  24. As a NetOffice event subscriber, I want a callback argument retained by my code to remain callable after the callback returns, so that event arguments have normal managed retention semantics.
  25. As a NetOffice event subscriber, I want an unretained callback wrapper to become collectible, so that callback delivery does not leak wrappers.
  26. As a NetOffice event subscriber, I want a callback with no subscriber to leave existing aliases valid, so that generated sinks never release raw callback arguments.
  27. As a NetOffice event subscriber, I want ref and out primitive and COM parameters copied back correctly, so that lifetime safety does not break event semantics.
  28. As a NetOffice event subscriber, I want handler exceptions to unwind callback activity safely, so that later events and teardown do not deadlock.
  29. As a NetOffice event subscriber, I want reentrant calls and disposal during a callback to complete safely, so that Unadvise never overlaps active delivery.
  30. As a NetOffice event subscriber, I want subscription cookies balanced exactly once, so that event-sink CCWs neither leak nor receive duplicate releases.
  31. As a NetOffice event subscriber, I want finalization of a subscribed source to schedule Unadvise on the owning apartment, so that COM is never called from the finalizer thread.
  32. As a NetOffice maintainer, I want unresolved event teardown reported when the dispatcher is unavailable, so that failure remains safe and diagnosable.
  33. As a C# interop consumer, I want an early-bound [ComImport] alias to remain callable after the corresponding NetOffice wrapper is disposed, so that mixed interop code is safe.
  34. As a C# interop consumer, I want raw object and dynamic aliases to remain valid after NetOffice disposal, so that exposing an RCW does not create false cleanup authority.
  35. As a C# interop consumer, I want NetOffice to record raw RCW escape, so that diagnostics acknowledge aliases outside Core accounting.
  36. As a C# callback implementer, I want native retention and release of my CCW tested independently of RCW behavior, so that RCW and CCW ownership are not conflated.
  37. As a NetOffice consumer, I want retained children to survive parent disposal, so that object-model provenance is not treated as ownership.
  38. As a NetOffice consumer, I want enumerator disposal or abandonment not to invalidate retained items, so that collection traversal is lifetime-safe.
  39. As a NetOffice consumer, I want mixed SAFEARRAY results to preserve repeated identity, distinct objects, nulls, and scalars, so that bulk marshaling follows the same rules as single results.
  40. As a NetOffice consumer, I want COM failures to preserve HRESULT and IErrorInfo while unwinding active-call state, so that error handling does not poison later use.
  41. As a NetOffice consumer, I want an in-flight call to finish when disposal starts concurrently, so that cleanup cannot race native execution.
  42. As a NetOffice consumer, I want calls from an MTA or another STA dispatched to the captured owner STA or rejected before native entry, so that apartment affinity is explicit.
  43. As a NetOffice consumer, I want reentrant shutdown on the owner STA to avoid self-wait, so that orderly shutdown cannot deadlock.
  44. As a NetOffice maintainer, I want an IL-level prohibition on ReleaseComObject and FinalReleaseComObject, so that runtime traces are not asked to attribute an arbitrary native Release to managed source code.
  45. As a NetOffice maintainer, I want the harness qualified with controlled faulty implementations, so that every claimed oracle proves it can detect its target regression.
  46. As a NetOffice maintainer, I want native reference telemetry interpreted as facts rather than a fixed golden sequence, so that legitimate CLR and JIT differences do not create false failures.
  47. As a release engineer, I want Release-JIT coverage on x86 and x64, so that liveness behavior hidden by Debug builds is exercised.
  48. As a release engineer, I want actual .NET Framework 4.6.2 and 4.8 runtime rows on separately pinned Windows images, so that target-framework compilation is not mistaken for runtime coverage.
  49. As a release engineer, I want Office smoke tests kept in a separate compatibility lane, so that Office availability and process exit are not used as native reference-count oracles.

Implementation Decisions

  • Use one dominant behavioral seam: controller to isolated scenario host to generated NetOffice API to native C++ COM fixture. Assertions are evaluated by the controller from observable results and out-of-band traces.
  • Keep a second, narrow enforcement seam only for static IL analysis of forbidden manual RCW-release calls. It complements behavioral tests because native telemetry cannot identify the managed origin of every Release.
  • Build a native in-process C++ COM DLL for x86 and x64. Build architecture-specific .NET Framework scenario hosts; do not use AnyCPU for a host that loads the fixture.
  • Activate through a process activation context and side-by-side manifest declaring CLSIDs, ProgIDs, type library, threading model, and architecture. Do not use regsvr32, machine or user registry mutation, or CoRegisterClassObject.
  • Generate a small NetOffice-style fixture API from the native type library through the production generator and templates. It must include late-bound properties and methods, multiple interfaces, _NewEnum, connection points, callbacks, and ref/out parameters.
  • Provide a separate early-bound C# interop assembly from the same type library. [ComImport], raw object, and dynamic references are RCW views; managed callback and event-sink objects cross the boundary as CCWs.
  • Implement a native identity family containing a root, distinct-address tear-off interfaces sharing one controlling unknown, an aggregated inner object, a distinct sibling, repeated self returns, shared and newly created children, and null results.
  • Implement graph and collection fixtures for cycles, parent returns, indexed access, IEnumVARIANT, repeated identities, scalars, VT_UNKNOWN, VT_DISPATCH, mixed SAFEARRAY values, early enumeration termination, cloning, and controlled enumeration failure.
  • Implement blocking, failure, and reentrant fixtures. Native failures return controlled HRESULTs and IErrorInfo; C++ exceptions never cross COM.
  • Implement a real IConnectionPoint that retains one sink reference for each successful Advise cookie and releases it once on successful Unadvise. Support deterministic Unadvise blocking and failure.
  • Implement a callback driver for managed CCWs and event sources that can send held or new native objects, multiple interface views, ref/out values, nested callbacks, reentrant calls, and worker-apartment callbacks.
  • Assign every native object a monotonic object ID distinct from its address. Log a destruction tombstone before memory is freed. Keep telemetry facts without retaining interface pointers.
  • Send native telemetry through a preallocated versioned memory-mapped ring buffer owned by the controller. Overflow, corruption, schema mismatch, or sequence loss fails the scenario.
  • Record run, scenario, epoch, sequence, timestamp, process, thread, apartment, object, controlling object, interface, pointer token, operation, reference count where meaningful, HRESULT, call depth, callback depth, cookie, barrier, and correlation identifiers.
  • Emit managed lifecycle diagnostics for wrapper, identity entry, lease, ingress, dispatcher, call, callback, connection, escape, pending removal, teardown, weak collection, and registry-reference drop. Diagnostic state must not strongly reference wrappers, RCWs, delegates, sinks, or exceptions.
  • Use deterministic barriers for call entry/exit, callback entry/exit, Unadvise, pending removal, destruction, and dispatcher draining. Random stress is supplemental and always records a replayable seed and schedule.
  • Run the owner apartment on a dedicated pumped STA. Provide a pumped second STA and explicit MTA workers. Verify the thread and apartment at native method entry.
  • Run finalizer cases through non-inlined helpers returning only weak references and immutable IDs. Use GC.KeepAlive for intended survivors and bounded collect/finalize/pump/drain rounds for eventual collection.
  • Never use ReleaseComObject or FinalReleaseComObject in test cleanup. Every explicit GetIUnknownForObject or QueryInterface identity probe must balance its temporary reference in finally with Marshal.Release.
  • Treat exact native reference operations as unstable across CLR, JIT, interface caching, reflection, apartments, and finalization. Assert non-underflow, single destruction, known fenced probe balance, connection-cookie balance, alias liveness, and required event ordering.
  • Place each host in a Job Object and distinguish activation failure, managed assertion, unexpected HRESULT, native invariant failure, access violation, CLR fail-fast, barrier timeout, deadlock, apartment violation, trace corruption, and unresolved teardown.
  • Preserve seed, schedule, command line, architecture, CLR/OS versions, module hashes, traces, last barrier, output, exit code, oracle explanation, and crash/hang dump for every failure.
  • Mark every native-dependent NUnit test with IntegrationTests. Preserve the default CI filter that excludes this category. Provide an opt-in integration workflow for manual and scheduled execution without elevation or registry mutation.
  • Run P0 deterministic scenarios in the minimum x64 Release integration row. Run full x86/x64, P0/P1, pairwise axes, and seeded stress on the scheduled gate. Keep real Office smoke tests separate.

Testing Decisions

  • Good tests assert consumer-visible behavior and externally observable native invariants: live aliases remain callable, disposed wrappers reject before native entry, canonical identity agrees, teardown follows calls and callbacks, cookies balance, apartment entry is correct, and eligible objects eventually become collectible and destructible.
  • Bad tests assert private RCW entry counts, fixed AddRef/Release sequences, raw equality of different interface pointers, immediate destruction on Dispose, process exit as cleanup proof, or success forced by manual RCW release.
  • The primary integration seam is the complete scenario host process. It covers generated invocation, generated event sinks, NetOffice identity/lease behavior, native COM identity and reference counting, RCW/CCW coexistence, apartment dispatch, and shutdown in one end-to-end path.
  • Managed unit tests continue to cover isolated event-sink and connection-point behavior. Existing NUnit event tests and the managed connection-point stub are prior art for handler plumbing, but the native harness is required for ownership and destruction claims.
  • Native fixture self-tests validate direct C++ identity, aggregation, connection-point, barrier, and telemetry behavior before NetOffice participates.
  • Baseline managed cohorts use early-bound [ComImport] only and raw RCW only. They validate activation and marshaling but do not establish a golden sequence for NetOffice.
  • NetOffice cohorts cover one wrapper; multiple wrappers for one identity; repeated ingress; tear-offs and aggregation; callbacks with no subscriber, retention, replacement, exceptions, and reentrancy; connection finalization; early-bound/raw aliases; parent-child graphs; enumerators; arrays; call/dispose races; apartments; scopes; and shutdown.
  • Static analysis scans NetOffice Core and generated fixture assemblies for any call to Marshal.ReleaseComObject or Marshal.FinalReleaseComObject.
  • Harness mutation tests must reject interface-pointer identity keys, strong diagnostic roots, recursive child invalidation, double lease release, removal during an active call, borrowed callback disposal, Unadvise during delivery, connection leaks, finalizer-thread COM calls, and a qualification assembly containing a forbidden release call.
  • Critical two-party races run every explicitly defined legal schedule. Compatible secondary dimensions use deterministic pairwise selection rather than an unbounded Cartesian product.
  • Release builds are mandatory because JIT liveness differs from Debug. x86 and x64 are separate rows. Execution on 4.6.2 versus 4.8 requires separately pinned Windows images because .NET Framework 4.x is an in-place runtime family.
  • Native-dependent tests are opt-in under IntegrationTests; pure managed controller/oracle tests and static analysis remain in the default lane when they do not load native code.
  • Office smoke tests cover installed applications, matching bitness, event-heavy and enumeration-heavy paths, mixed interop aliases, explicit Quit, and session shutdown. They are compatibility evidence, not precise reference-balance tests.

Out of Scope

  • Reimplementing or inspecting CLR RCW internal bookkeeping.
  • Guaranteeing one RCW per identity across AppDomains, processes, or separate CLR instances.
  • Protecting consumers that explicitly force-release a shared RCW outside NetOffice.
  • Using manual RCW release as either production cleanup or test teardown.
  • Requiring immediate native destruction when a NetOffice wrapper is disposed.
  • Treating application or COM server process termination as balanced-reference evidence.
  • Making the default CI test job depend on native activation, administrator rights, registry changes, or installed Microsoft Office.
  • Replacing the separate real-Office compatibility lane.
  • Exhaustively running every possible Cartesian combination of architecture, apartment, ingress, interface, error, release path, and alias type.

Further Notes

  • The suite validates the proposed AppDomain-wide identity registry, independent wrapper leases, weak diagnostics, retainable callback arguments, complete-delegate invocation gate, apartment dispatcher, event-connection teardown, scope transfer, and CLR-managed RCW cleanup.
  • Native AddRef and Release records remain important evidence: counts must not underflow, known fixture-owned holds must balance, and an eligible object must publish exactly one destruction tombstone. They are not a stable whole-scenario golden sequence.
  • Pointer values are diagnostic tokens only. Canonical identity is established through QueryInterface(IID_IUnknown) while the object is retained; stable object IDs and epochs prevent address reuse from corrupting assertions.
  • The selected single behavioral seam is intentionally higher than direct COMObject construction. Direct model tests may remain, but they cannot replace proof through generated wrappers and sinks.
  • Because this command synthesizes without an interview, the highest-seam choice above is the recorded expectation: end-to-end scenario process behavior is authoritative, with only the static forbidden-call scan as a necessary complementary seam.
  • The Epic is ready for decomposition into implementation tickets only after the native protocol, generated fixture API, controller/host contract, deterministic P0 scenarios, and oracle mutation set remain consistent with this specification.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentReady for implementation by an agent

    Type

    Projects

    • Status
      Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions