Repository navigation
Fix the GC freeing objects rooted only by an interior pointer - #3261
Merged
Merged
Conversation
An object whose only root was a byref or span could be freed. The lookup started from the brick table, which never records objects inside a TLAB and is never cleared; the segment enumerator's end check did GCObject* arithmetic, scaling the size by 16, so it stopped after one object; and a miss handed the raw pointer to TryMarkRoot, which dropped it. GetParentObject now finds the segment by address in both the SOH and pinned lists, since GC_CALL_PINNED describes the stack slot and not the heap, and walks it from Start with the sweep's rules: a FreeBlock advances by its Size, a non-MethodTable word by one pointer, an object by its aligned size. A miss marks nothing. The enumerator and FindClosestObjectBelow, whose only caller this was, are removed.
Three cells that fail without the fix on x64 and arm64 whatever the boot heap layout: a byref into an array behind another object in its TLAB, a byref into a pinned-heap array, and a fixed byref into a regular-heap array. Each observes a weak reference, so the result does not depend on the freed block being reused. expectedTests now matches the 50 registered cells.
🧪 Graphic Tests
Skipped
📎 Artifacts
|
🧪 Interrupts Tests
Skipped
📎 Artifacts
|
🧪 Storage Tests
Skipped
Failures
📎 Artifacts
|
This was referenced Sep 26, 2026
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.
An object whose only root is an interior pointer (a byref or a span) could be freed while still in use.
GC_InteriorPointerRoot(#384) passes ongen3only because of where boot allocations happen to land: the driver-kit stack (#3260) shifts them and the cell fails on arm64 every time, with[Test] interior byref after collect: 5A5A0008, after churn: 0. This PR fixes the lookup so the result no longer depends on heap layout.Problem
PreciseRootTrampolineresolves aGC_CALL_INTERIORslot to its object throughGetParentObject. That lookup had four defects:GCSegment.FindClosestObjectBelow). Only bump carves write the table: objects allocated inside a TLAB, and TLABs refilled from the free list, are never recorded. Entries are never cleared either, so one can point inside a live object. In the failing run the lookup returned the segment's first object, not theint[2100]the byref pointed into.GCSegment.Enumerator.MoveNextcheckedCurrent + size >= (GCObject*)_endon aGCObject*, which scales the byte size bysizeof(GCObject)(16). From a 242-byte string at 0x59ac6038 the check reads 0x59ac6f58, already past the byref at 0x59ac61d8.ComputeSize(the allocator rounds sizes up to 8 bytes), and it read a MethodTable from free blocks and zeroed filler. On the failing layout an aligned walk reaches a zero word before the array.TryMarkRootthen read array data as a MethodTable and rejected it, so the array was swept, and the next TLAB refill zeroed it. Had that data looked like a kernel pointer, the mark would have set its low bit and corrupted it.It also picked the segment list from
GC_CALL_PINNED. That flag describes the stack slot (afixedlocal), not the heap the object lives on, so a byref into a pinned-heap array searched the wrong list.Fix
GarbageCollector.PreciseStack.cs:GetParentObjectfinds the segment by address in the SOH list, then in the pinned list (GCSegmentManager.GetSegmentContaining), and ignoresGC_CALL_PINNED.FindObjectContainingwalks that segment fromStartup toBumpwithSweepSegment's rules:FreeBlockadvances by itsSize;Align(ComputeSize()).It returns the object whose extent covers the pointer, or null when it lies in a free block, filler or unallocated space.
ComputeSizeis unchanged:EnumerateReferencesneeds the unaligned size for its GCDesc series math.GCSegment.cs: the defectiveEnumeratorandFindClosestObjectBeloware removed; this lookup was their only caller. The brick table is still written but no longer read (see known gaps).Cost. One walk of one segment per precise interior root. Segments are sized for one TLAB refill or one large allocation, about 12 KiB for TLAB segments.
Docs.
garbage-collector.mdandgarbage-collector-gcinfo.mddescribe the new lookup and the state of the brick table.Verification
Build.
make setup: 0 errors, no new warnings.New cells (GarbageCollector suite, 47 to 50 tests;
expectedTestswas 46 and now matches the registered count):GC_InteriorPointerRootMidTlab: a byref into an array placed behind another object in its TLABGC_InteriorPointerRootPinnedHeap: a byref intoGC.AllocateArray<int>(8, pinned: true)GC_InteriorPointerRootFixed: afixedbyref into a regular-heap arrayEach cell observes a weak reference, so its result does not depend on whether a later allocation reuses the freed block. The "without the fix" runs used this branch's tests with the two GC sources reverted to
gen3.The original failure. On #3260's tree (the driver-kit engine; it does not touch the GC):
GC_InteriorPointerRootfails on arm64 (after churn: 0);after churn: 5A5A0008).Test suites on this branch, QEMU. Every suite matches
gen3CI run 35973349716 case by case, apart from the three new cells:Real hardware. The runs above are QEMU only.
Known gaps.
MarkObjectbut never read. Removing it moves each segment'sStartand so changes heap layout; making it sound means recording every TLAB start and rebuilding entries in both sweeps. Either is a follow-up.SweepSegment's stepping rules rather than sharing a helper with it, so the sweep code is untouched here.Found here but not changed:
ReorderPinnedSegmentsAndFreeEmptynever updatess_pinnedSegmentManager.TailSegment(the SOH version does,Sweep.cs:270). The next pinnedAllocateSegmentcan then append through a freed or mid-list tail.SweepPinnedSegmenthas noFreeBlockbranch. It reads a free block as a 24-byte object and then walks the block's stale bytes, which miscounts freed objects and can skip a live one.StampUnusedTlab's small-gap branch zeroes up toAllocLimit, including the header slot of the object that starts there.ExpectedTestCount = 74.