I did some more digging on this one, and here is what my testing suggests:
* native ChangeD3DDevice() *just* reconnects the filter, i.e. it doesn't directly call any of the same methods.
* This reconnection is done on a separate thread, for some reason. IPin methods and IVMRSurfaceAllocator::TerminateDevice() are called from that thread.
* The filter graph documentation claims that IFilterGraph::Reconnect() and IFilterGraph2::ReconnectEx() execute on a separate thread, but according to my tests this is not true.
* ChangeD3DDevice() can be called while the filter is running, in which case it will stop the graph, reconnnect, and then run. This is all done on the same thread. Note that ReconnectEx() *cannot* be called while the filter is running.
* ChangeD3DDevice() can be called while the filter is not connected.
* InitializeDevice() is not called immediately on connection. Rather, it seems to be deferred until IMemAllocator::SetProperties().
VFW_E_NOT_COMMITTED happens because reconnecting evidently decommits the allocator, and we manage to race exactly such that the pins have been disconnected but not yet reconnected when trying to send a frame.
As far as I can tell there is no actual way to synchronize this thread.
I suspect that in practice ChangeD3DDevice() is not expected to be called while connected and stopped. I don't know if it's meant to be called while disconnected or while running, or both. Either will avoid this race, although ChangeD3DDevice() while running will definitely run into other races surrounding state change (e.g. what happens if you ChangeD3DDevice() and then decide to stop the graph?)
I think the only case we can really reliably test is the stopped case.