Skip to content

Design: memory-safe COM object lifetime management for Office add-ins #483

@jozefizso

Description

@jozefizso

Summary

NetOffice needs an explicit, memory-safe lifetime model for Runtime Callable Wrappers (RCWs), especially in Office add-ins where COM objects are received from the host through callbacks and event handlers.

The key change is to separate wrapper lifetime from RCW ownership. NetOffice should always be able to clean up its own wrappers, event bridges, child relationships, and diagnostics, but it should only call Marshal.ReleaseComObject() when the RCW is explicitly owned by NetOffice.

For Office add-ins, most incoming COM objects are borrowed from Office and must not be released by NetOffice.

Related background:

Problem

The current model uses COMProxyShare as a NetOffice-level reference counter and releases the underlying RCW with Marshal.ReleaseComObject() when the count reaches zero. This works only if NetOffice exclusively owns the RCW.

Office add-ins do not have that guarantee. The host passes COM objects into add-in callbacks, for example:

  • OnConnection receives the host Application object.
  • Ribbon callbacks receive an IRibbonUI or similar Ribbon object.
  • Office events receive objects such as Presentation, Workbook, Document, Range, Shape, etc.

Those objects are host-owned and may also be represented by the same CLR RCW elsewhere in the process. Releasing them from NetOffice can disconnect other managed references to the same COM object.

The current model also makes missed Dispose() calls risky because global strong references keep wrappers and RCWs alive until explicit cleanup.

Use Cases

Add-in connection

void OnConnection(object application)
{
    _application = new PowerPoint.Application(application);
}

The incoming application object is owned by Office. The add-in may retain a wrapper for the whole add-in lifetime, but NetOffice should not treat the RCW as exclusively owned.

Ribbon callback

void OnRibbonUI(object ribbon)
{
    _ribbon = new Office.IRibbonUI(ribbon);
}

The Ribbon object is also host-owned. The wrapper may be retained so the add-in can call Invalidate, but releasing the underlying RCW is not safe by default.

Event handler object graph

void OnPresentationOpen(Presentation pres)
{
    var name = pres.Name;
    var shape = pres.Shapes.Add(/* args */);
}

pres is borrowed for the callback. Objects reached from it, such as Shapes and the created Shape, need scoped wrapper cleanup. Whether their RCWs should be released depends on explicit ownership policy, not on the fact that NetOffice created a wrapper.

Proposed Design

1. Introduce explicit COM ownership

Add an ownership concept to wrapper/proxy creation:

enum ComOwnership
{
    Borrowed,
    ScopeOwned,
    RetainedOwned
}

Semantics:

  • Borrowed: NetOffice may dispose wrapper state, but must not call Marshal.ReleaseComObject().
  • ScopeOwned: NetOffice owns the wrapper for a short scope and may release according to configured policy.
  • RetainedOwned: NetOffice owns the wrapper for a longer explicit lifetime and releases when that owner scope ends.

For add-in callbacks, incoming objects should default to Borrowed.

2. Split wrapper cleanup from RCW release

Dispose() should always be safe and idempotent, but it should perform two logically separate operations:

  • Wrapper cleanup:

    • remove from parent/child relationship
    • remove from tracking list/scope
    • dispose child wrappers
    • unadvise event bridges
    • clear caches and managed references
  • Native RCW release:

    • call Marshal.ReleaseComObject() only if ownership policy allows it

This avoids using Dispose() as a synonym for “release the COM object.”

3. Add lifetime scopes

Introduce scopes as the primary lifetime owner instead of relying on one global strong object list.

Recommended scopes:

  • AddinScope: lifetime from OnConnection to OnDisconnection.
  • RibbonScope: lifetime of Ribbon UI object references.
  • HandlerScope: lifetime of one Office event/callback invocation.
  • RetainedObjectScope: explicit lifetime for objects the add-in chooses to keep beyond a handler.

Example shape:

void OnConnection(object application)
{
    _addinScope = ComScope.Create("Addin");

    _application = _addinScope.Wrap<Application>(
        application,
        ComOwnership.Borrowed);

    _application.PresentationOpenEvent += OnPresentationOpen;
}
void OnDisconnection()
{
    _application.PresentationOpenEvent -= OnPresentationOpen;

    _ribbon = null;
    _application = null;

    _addinScope.Dispose();
}
void OnPresentationOpen(Presentation pres)
{
    using var scope = _addinScope.BeginHandlerScope();

    var name = pres.Name;

    var shapes = scope.Track(pres.Shapes);
    var shape = scope.Track(shapes.Add(/* args */));
}

If a handler needs to keep an object:

void OnPresentationOpen(Presentation pres)
{
    _activePresentation = _addinScope.Retain(pres);
}

Retention should be explicit so the add-in author can later release the retained wrapper on close or shutdown.

4. Track COM identity separately from wrapper instances

NetOffice should have a control block per COM identity where practical, keyed by IUnknown identity. Multiple NetOffice wrappers for the same COM object should share that control block.

The control block should track:

  • underlying RCW
  • ownership policy
  • wrapper count
  • scope owner(s)
  • release state
  • diagnostics metadata

This avoids treating each wrapper as an independent owner of the same RCW.

5. Make disposal idempotent and underflow-safe

All wrapper disposal paths should tolerate repeated calls.

Required behavior:

  • Repeated Dispose() on the same wrapper is a no-op after first cleanup.
  • COMProxyShare.Release() or its replacement cannot decrement below zero.
  • Releasing one wrapper cannot release a shared RCW while another live wrapper still depends on it.

6. Replace global strong tracking with scoped ownership plus diagnostics

The global object list currently keeps strong references to wrappers. This makes missed disposal calls permanent for the lifetime of the Core instance.

The proposed model should use scopes as the strong owners. Diagnostic lists can use weak references, object IDs, and creation stack metadata without extending object lifetime unnecessarily.

Add-in Lifecycle Rules

OnConnection

  • Incoming Application object is Borrowed.
  • It can be retained for add-in lifetime by the add-in scope.
  • Do not call ReleaseComObject() for the host Application RCW by default.
  • Event subscriptions are owned by the add-in scope and must be unadvised on disconnect.

OnRibbonUI / Ribbon callbacks

  • Incoming Ribbon object is Borrowed.
  • It may be retained for Ribbon lifetime.
  • Dispose wrapper state on disconnect, but do not release the host-provided RCW by default.

Office event handlers

  • Incoming event arguments are Borrowed for the handler invocation.
  • Handler-created temporary wrappers should be tracked in a handler scope.
  • Temporaries should be disposed in reverse order at handler end.
  • Objects retained past handler end must be explicitly retained by a longer-lived scope.

OnDisconnection

  • Unsubscribe/unadvise all events.
  • Dispose add-in scope.
  • Clear retained wrapper fields.
  • Do not release borrowed host RCWs.

Migration Plan

  1. Make existing Dispose() implementations idempotent.
  2. Add an ownership flag to COMProxyShare or introduce a replacement control block.
  3. Add borrowed wrapping APIs without changing existing behavior immediately.
  4. Update add-in infrastructure to use Borrowed for host-provided objects.
  5. Add scoped helper APIs for event handlers and temporary COM objects.
  6. Add diagnostics to identify wrappers disposed without ownership and wrappers leaked outside scopes.
  7. Consider changing defaults in a major version so host-provided callback objects are borrowed by default.

Compatibility Considerations

This is behavior-sensitive because some applications may rely on NetOffice eagerly releasing RCWs. To avoid breaking existing automation scenarios, the change should likely be introduced as an opt-in policy first, then considered for default behavior in a major version.

Suggested compatibility modes:

  • Legacy mode: current ReleaseComObject behavior.
  • Add-in-safe mode: incoming host/callback objects are borrowed.
  • Strict ownership mode: raw RCW wrapping requires an explicit ownership argument.

Test Plan

Add tests for:

  • Repeated Dispose() on one wrapper does not decrement shared state twice.
  • Disposing one wrapper does not disconnect another wrapper sharing the same COM identity.
  • Borrowed wrappers never call Marshal.ReleaseComObject().
  • Scope-owned wrappers are cleaned in reverse order.
  • Add-in connection/disconnection unadvises events and clears wrappers without releasing borrowed host RCWs.
  • Event handler scopes dispose temporary wrappers while preserving incoming borrowed arguments.
  • Retained event arguments survive handler scope cleanup and are later released by their owner scope.

Expected Outcome

NetOffice add-ins can safely manage wrapper lifetimes at each lifecycle stage without accidentally releasing host-owned or shared RCWs. Deterministic cleanup remains available, but native COM release becomes an explicit ownership decision instead of an implicit side effect of wrapper disposal.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions