The lamp worker clears its factory cache before the apartment goes

A tester's second race died on an access violation with nothing in the
log after the monitor setup, which is only where the MAIN thread had got
to - the fault was on another thread entirely, and the crash filter
writes no line of its own, so the truncation named the wrong suspect.

The dump named the right one. Thread 19, inside the Dynamic Lighting
worker, calling through a vtable at an address that lm shows falling in
the GAP between two loaded modules - an unloaded DLL, not corruption:

  rpl4opt!...ILampArrayStatics::GetDeviceSelector+0x23
    [inlined in rpl4opt!`anonymous namespace'::Worker+0x121]
  call dword ptr [eax+18h]  ds:002b:6fd72eb8=????????

C++/WinRT caches an activation factory the first time a type is used and
that cache is PROCESS-wide. The apartment is not: the worker init'd one,
asked LampArray for its device selector, and exited without clearing the
cache, so COM tore the apartment down at thread detach and unloaded the
Lights server with it - nothing else in the process held a reference.
The cached pointer stayed, aimed at an address range that no longer had
a module in it. The next race started a fresh worker, which found the
cache populated, did not re-activate, and called straight through it.

So this could only ever fire on the second race, and only because the
worker is started per race - KeyLight_Start() runs from the PadRIO
constructor. A machine with no Dynamic Lighting keyboard is not spared:
asking for the device selector is enough to populate the cache, and the
tester's log says plainly that nothing was found.

The guard is RAII and declared BEFORE the DeviceWatcher, so it runs LAST
- the watcher's COM release still happens inside a live apartment. It
also covers the early return when Dynamic Lighting is unavailable, which
was the other way out of the function.

Confirmed both directions with a standalone reproducer of the same
pattern - worker thread, init_apartment, GetDeviceSelector, exit, thrice.
As shipped it dies on pass 2 with 0xC0000005, matching the dump. With
this, three passes clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-08 19:55:21 -05:00
co-authored by Claude Opus 5
parent aa294071f9
commit de9a163f37
+33
View File
@@ -142,15 +142,48 @@ namespace
//---------------------------------------------------------------
void Worker()
{
bool ownsApartment = false;
try
{
init_apartment();
ownsApartment = true;
}
catch (...)
{
// apartment already set on this thread; carry on
}
//
// C++/WinRT caches an activation factory the first time a type
// is used, and that cache is PROCESS-wide - it outlives this
// thread. The apartment does not: COM tears it down when the
// worker exits and unloads the Lights server with it, because
// by then nothing holds a reference.
//
// So the cached factory is left pointing into an address range
// that no longer has a module in it, and the NEXT race's worker
// calls straight through it - the crash was a call through the
// stale vtable, on the second race, every time. A machine with
// no Dynamic Lighting keyboard is not spared: asking for the
// device selector is enough to populate the cache.
//
// Clear it before the apartment goes, and on every way out of
// here rather than only the tidy one - the watcher setup below
// returns early when Dynamic Lighting is unavailable.
//
struct WinRTExit
{
bool owns;
~WinRTExit()
{
clear_factory_cache();
if (owns)
{
uninit_apartment();
}
}
} winrtExit{ ownsApartment };
std::mutex claimedLock;
std::vector<ClaimedArray> claimed;
bool anySeen = false;