Stop the size-tracker resizer before clearing objectPages in ObjectAllocatorImpl.Dispose - #2070
Conversation
…locatorImpl.Dispose
| // tearing down the resources it owns: a resizer already inside ShiftAddresses can reach EvictRecordsInRange, | ||
| // which dereferences objectPages, and the resulting NullReferenceException is escalated to FailFast by | ||
| // OnPagesClosedWorker. base.Dispose() below stops it again, which is a no-op once it has stopped. | ||
| logSizeTracker?.Stop(wait: true); |
Calling Stop(wait: true) from the override ran it before AllocatorBase.Dispose() set disposed = true, which is the condition the resizer's eviction spin-waits in ShiftAddressesWithWait bail out on. A resizer already parked there would not reach OnStopped(), so the wait loop in Stop could spin instead of returning. Move both statements into a protected StopSizeTrackerForDispose() on AllocatorBase and call that from both places. Dispose() keeps the exact ordering it had, and the override now gets disposed = true first as well. It is idempotent: disposed is already true on the second call, and Stop compare-exchanges runState from Running, so it returns without signalling or waiting once the resizer has stopped.
|
Good catch, that is a real problem with the first version and I have pushed a fix.
Fixed as suggested. Both statements now live in one protected helper on protected void StopSizeTrackerForDispose()
{
disposed = true;
logSizeTracker?.Stop(wait: true);
}
It is idempotent for the second call from
Rebuilt |
|
I believe you have completed addressing the Copilot comment; please Resolve if so |
Fixes #2069.
ObjectAllocatorImpl.Dispose()clearsobjectPagesfirst and callsbase.Dispose()last:AllocatorBase.Dispose()is where the resizer is stopped, and it deliberately does that first, with the reason written out above the call:objectPagesis the same kind of resource, but it is cleared before that call runs, so the invariant does not hold for it. A resizer still insideResizeIfNeeded->ShiftAddressesreachesEvictRecordsInRange, which dereferences it directly:so the NullReferenceException in the issue's stack trace, which
OnPagesClosedWorkerescalates toEnvironment.FailFast, taking the whole process down.This moves the stop to the top of the override, so the resizer is stopped and awaited before the array it uses is cleared:
Notes on why this is safe:
base.Dispose()still callsStop(wait: true)afterwards.Stopcompare-exchangesrunStatefromRunning, so once the resizer has stopped the second call takes neither the signal path nor the wait loop, and returns immediately.Interlocked.ExchangeonobjectPagesstill guards the rest of the method, so the dispose-once behaviour is unchanged.Stopis safe to reach on a secondDispose()for the same reason as above.logSizeTrackerisinternalonAllocatorBase, so no accessibility change was needed.I did not add a test. The failure is a shutdown race, and the observable outcome is
Environment.FailFast, which terminates the test host rather than failing an assertion, so a test that reproduces it would take the run down with it rather than report. What is testable is the ordering itself, and after this change that ordering is enforced by construction in the override.Built
Tsavorite.coreclean, and ran the in-memory allocator tests (Name~NativeInMem, 11 passed / 3 skipped) to check for fallout on the normal dispose path.