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
- Make existing
Dispose() implementations idempotent.
- Add an ownership flag to
COMProxyShare or introduce a replacement control block.
- Add borrowed wrapping APIs without changing existing behavior immediately.
- Update add-in infrastructure to use
Borrowed for host-provided objects.
- Add scoped helper APIs for event handlers and temporary COM objects.
- Add diagnostics to identify wrappers disposed without ownership and wrappers leaked outside scopes.
- 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.
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
COMProxyShareas a NetOffice-level reference counter and releases the underlying RCW withMarshal.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:
OnConnectionreceives the hostApplicationobject.IRibbonUIor similar Ribbon object.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
The incoming
applicationobject 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
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
presis borrowed for the callback. Objects reached from it, such asShapesand the createdShape, 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:
Semantics:
Borrowed: NetOffice may dispose wrapper state, but must not callMarshal.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:
Native RCW release:
Marshal.ReleaseComObject()only if ownership policy allows itThis 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 fromOnConnectiontoOnDisconnection.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:
If a handler needs to keep an object:
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
IUnknownidentity. Multiple NetOffice wrappers for the same COM object should share that control block.The control block should track:
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:
Dispose()on the same wrapper is a no-op after first cleanup.COMProxyShare.Release()or its replacement cannot decrement below zero.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
Coreinstance.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
Borrowed.ReleaseComObject()for the host Application RCW by default.OnRibbonUI / Ribbon callbacks
Borrowed.Office event handlers
Borrowedfor the handler invocation.OnDisconnection
Migration Plan
Dispose()implementations idempotent.COMProxyShareor introduce a replacement control block.Borrowedfor host-provided objects.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:
ReleaseComObjectbehavior.Test Plan
Add tests for:
Dispose()on one wrapper does not decrement shared state twice.Marshal.ReleaseComObject().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.