Fix over-release of caller-owned interface pointer in CreateObject - #2511
Open
Sergio Pedri (Sergio0694) wants to merge 3 commits into
Open
Fix over-release of caller-owned interface pointer in CreateObject#2511Sergio Pedri (Sergio0694) wants to merge 3 commits into
Sergio Pedri (Sergio0694) wants to merge 3 commits into
Conversation
Sergio Pedri (Sergio0694)
requested a review
from Manodasan Wignarajah (manodasanW)
August 5, 2026 22:23
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
'CreateObject' captured the caller-supplied 'IInspectable' pointer from the 'CreateObjectTargetInterfacePointer' thread-static, but then decided in its 'finally' block whether it owned that pointer by re-reading the same field and checking it for 'null'. That inference is only valid if nothing resets the field in between. Re-entrant marshalling on the same thread does exactly that: 'GetOrCreateObjectForComInstanceUnsafe' resets the field to 'null' when it returns, so any nested marshalling operation performed while the outer 'CreateObject' is still running leaves the field 'null'. The outer 'finally' then calls 'Release' on an interface pointer that was supplied by the caller and that it does not own, dropping the reference count too many times. This is reachable today: marshalling a 'NotifyCollectionChangedEventArgs' resolves through the most-derived-type lookup to its ComWrappers marshaller, whose 'CreateObject' calls 'NotifyCollectionChangedEventArgsMarshaller.ConvertToManaged'. That in turn marshals the 'NewItems'/'OldItems' collections through 'IListMarshaller.ConvertToManaged', which re-enters 'GetOrCreateObjectForComInstanceUnsafe'. Track the acquisition in a local instead, so the release decision no longer depends on shared thread-static state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 15b56f71-7292-4049-bf75-17f6198d7446
'GetOrCreateObjectForComInstanceUnsafe' communicates the static type callbacks and the caller-supplied interface pointer to 'CreateObject' through thread-statics, and unconditionally cleared them once the call returned. Clearing is only correct for a top-level marshalling operation. When a marshaller also marshals nested objects, this method is re-entered while an outer marshalling operation is still in flight, and clearing the fields destroys the outer operation's state. Once control returns to the outer 'CreateObject', it observes callbacks that are no longer there: the fallback that would have produced a wrapper specialized to the statically known type sees a 'null' callback, and silently degrades to an opaque 'IInspectable' wrapper instead. Save the previous values and restore them, instead of always writing 'null'. For a top-level call the saved values are 'null', so the existing invariant that these fields are 'null' outside a marshalling operation is preserved, and the 'WeakReference<T>' rehydration path keeps behaving as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 15b56f71-7292-4049-bf75-17f6198d7446
Adds a test covering the case that was broken: a native object whose marshalling re-enters the marshalling infrastructure on the same thread while the outer 'CreateObject' call is still running. The shipping example of this is 'NotifyCollectionChangedEventArgs', but that type requires the WinUI runtime to activate, which is not available in this test host. The test instead uses a minimal native 'IInspectable' with a hand-written vtable, whose 'GetRuntimeClassName' marshals a second native object. That callback runs inside the outer 'CreateObject', reproducing the same re-entrancy. Rather than hard-coding how many references the marshalling infrastructure takes internally, the test marshals twice (once re-entrant, once not) and asserts that both have the same net effect on the reference count of the caller-owned interface pointer. Verified against the parent commits: the test fails before the fix with 'Expected:<1>. Actual:<0>' (the caller-owned pointer is released one extra time, cancelling out the reference the wrapper takes) and passes after it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 15b56f71-7292-4049-bf75-17f6198d7446
Sergio Pedri (Sergio0694)
force-pushed
the
user/sergiopedri/comwrappers-reentrant-marshalling
branch
from
August 5, 2026 22:35
85c97d1 to
5720ceb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a reference count over-release in
WindowsRuntimeComWrappers.CreateObject, which released an interface pointer owned by the caller whenever a marshalling operation re-entered the marshalling infrastructure on the same thread.Motivation
CreateObjectreceives theIInspectableinterface pointer through theCreateObjectTargetInterfacePointerthread-static. That pointer is normally supplied (and owned) by the caller, but in theWeakReference<T>rehydration path there is no caller context, soCreateObjecthas toQueryInterfacefor it itself and release it afterwards.To tell those two cases apart, the
finallyblock re-readCreateObjectTargetInterfacePointerand treatednullas "I acquired this myself". That inference is only valid as long as nothing resets the field whileCreateObjectis running, andGetOrCreateObjectForComInstanceUnsaferesets it tonullon exit. Any nested marshalling operation therefore left the fieldnull, so the outerfinallyreleased a pointer it did not own, dropping the reference count one time too many.This is reachable today. Marshalling a
NotifyCollectionChangedEventArgsresolves through the most-derived-type lookup to its ComWrappers marshaller, whoseCreateObjectcallsNotifyCollectionChangedEventArgsMarshaller.ConvertToManaged. That in turn marshals theNewItems/OldItemscollections throughIListMarshaller.ConvertToManaged, which re-entersGetOrCreateObjectForComInstanceUnsafe.While fixing that, the same shared state turned out to have a second, independent problem: because
GetOrCreateObjectForComInstanceUnsafeunconditionally cleared the callbacks rather than restoring them, a nested marshalling operation also destroyed the outer operation's static type information. Once control returned to the outerCreateObject, the fallback that would have produced a wrapper specialized to the statically known type saw anullcallback and silently degraded to an opaqueIInspectablewrapper.Changes
src/WinRT.Runtime2/InteropServices/WindowsRuntimeComWrappers.cs: track whetherCreateObjectacquired the interface pointer itself in a local, instead of inferring it in thefinallyblock from shared thread-static state. This is the actual fix for the over-release.src/WinRT.Runtime2/InteropServices/WindowsRuntimeComWrappers.cs: save and restore the three marshalling thread-statics inGetOrCreateObjectForComInstanceUnsafeinstead of always writingnull, so a nested marshalling operation no longer destroys the outer operation's state. For a top-level call the saved values arenull, so the existing invariant that these fields arenulloutside a marshalling operation is preserved, and theWeakReference<T>rehydration path keeps behaving as before.src/Tests/UnitTest/ComWrappersTests.cs: new regression test covering re-entrant marshalling. The shipping trigger (NotifyCollectionChangedEventArgs) needs the WinUI runtime to activate, which is not available in the unit test host, so the test instead uses a minimal nativeIInspectablewith a hand-written vtable whoseGetRuntimeClassNamemarshals a second native object. That callback runs inside the outerCreateObject, reproducing the same re-entrancy without any activation dependency. Rather than hard-coding how many references the marshalling infrastructure takes internally, the test marshals twice (once re-entrant, once not) and asserts that both have the same net effect on the reference count of the caller-owned pointer, which keeps it robust to unrelated internal changes.Validation
The new test was verified against each state of the fix:
Expected:<1>. Actual:<0>Actual:<0>is exactly the expected symptom: the spuriousReleasecancels out the reference that the created wrapper takes.Running the full
UnitTestsuite before and after the change goes from432 failed / 81 passedto431 failed / 82 passed, so exactly one test flips and there are no regressions. The remaining failures are pre-existing and environmental: the C++ test component is not registered in the local test host, so those tests fail withREGDB_E_CLASSNOTREGwhile constructing the test class.