Skip to content

Fix interior pointer resolution for medium blocks on 16K pages - #7062

Open
gaberudy wants to merge 1 commit into
chakra-core:masterfrom
goldenhelix:fix-interior-pointer-16k-pages
Open

gaberudy wants to merge 1 commit into
chakra-core:masterfrom
goldenhelix:fix-interior-pointer-16k-pages

Conversation

@gaberudy

Copy link
Copy Markdown

Summary

SmallHeapBlockT::GetRealAddressFromInterior narrows the byte offset of an interior pointer within its heap block to ushort before shifting it down to an object index. A medium block is PageCount = 8 pages, so the largest offset is 32767 with 4K pages but 131071 with the 16K pages used on Apple Silicon (AutoSystemInfo::PageSize = 16384 since 015b05d). Any offset past 64K silently loses its top bit and the function returns the object 64K lower in the same block.

This is reached from conservative stack scanning: HeapBlockMap32::MarkInterior sets the candidate's own mark bit first, then resolves the pointer and queues the resolved object. For a medium object in the upper half of its block the wrong object gets queued, and the real one keeps its mark bit without ever being scanned. When the marker later reaches it through an exact heap pointer, MarkInternal reports it as already marked, so nothing it references gets marked. The sweep then frees live objects.

Async function and generator frames live on the heap as medium objects, and the interpreter executes inside them, so a collection during an async function leaves its locals, including the function's await object, pointing at freed memory after the next resume. In an embedding this showed up on native Apple Silicon as DynamicObject::SetInlineSlot writing through a null type, StrictEqual and instanceof on zeroed objects, and JSONStringBuilder length mismatches. The same binary under Rosetta, and x64 Linux and Windows, never hit it because their blocks are 32K.

The sibling GetAddressBitIndex was already fixed for the same overflow in 015b05d by shifting before narrowing. This change does the same here: keep the byte offset as uint, shift, then take the ushort index.

Reproduction

On an arm64 macOS build of ch (./build.sh --no-jit --test-build), an async function with enough locals to put its frame in a medium block, called enough times that some frames land in the upper half of their block, with allocation pressure before the first await:

medium_frame.js
function echo(s) { WScript.Echo(s); }
async function big(id) {
  // 120 locals so the frame is a medium block object
  var v0 = { id: id, k: 0 }, v1 = { id: id, k: 1 } /* ... through v119 ... */;
  var junk = [];
  for (var i = 0; i < 6000; i++) junk.push({ i: i, s: 'x' + i, a: [i] });
  await null;
  if (v0.id !== id || v0.k !== 0) throw new Error('corrupt v0 in call ' + id + ': ' + JSON.stringify(v0));
  /* ... same check for every local ... */
  return id;
}
async function main() {
  var ps = [];
  for (var c = 0; c < 600; c++) ps.push(big(c));
  var r = await Promise.all(ps);
  echo('ok ' + r.length);
}
main().catch(function (e) { echo('FAIL ' + e.message); });

Before the fix, every run reports a local whose object was freed and reused, for example FAIL corrupt v0 in call 138: "4132", or crashes under -RecyclerStress. After the fix it prints ok 600, including under -RecyclerStress and -RecyclerVerifyMark.

Testing

  • test/runtests.py on arm64 macOS (Test build, disable_jit variant): 1927 passed, 29 failed. The 29 are the Intl, Unicode and timezone tests that need ICU, which these macOS builds do not embed, and the failure set is identical with and without this change.
  • The reproducer above and an async-heavy stress script pass under -RecyclerStress and -RecyclerVerifyMark.
  • The embedding that hit the crash ran its previously crashing workload under a verifying engine without a single missed mark.

🤖 Generated with Claude Code

SmallHeapBlockT::GetRealAddressFromInterior narrowed the byte offset of
an interior pointer within its block to ushort before shifting it down
to an object index. A medium block is eight pages. With 4K pages that is
32K and every offset fits. On Apple arm64 the page is 16K, the block is
128K, and any offset past 64K lost its top bit and resolved to the
object 64K lower in the same block.

The marker reaches objects on the native stack through this function.
HeapBlockMap32::MarkInterior sets the candidate's own mark bit first,
then resolves and queues the object it believes the pointer belongs to.
For a medium object in the upper half of its block that queued the
wrong object, and the real one kept its mark bit without ever being
scanned. When the same object was later reached through an exact heap
pointer it was skipped as already marked, so everything referenced only
from it was swept while still live.

Async function and generator frames live on the heap as medium objects
and the interpreter executes inside them, so a collection during an
async function left its locals, including the await object, pointing at
freed memory after the next resume. An embedder saw this as crashes in
SetInlineSlot, StrictEqual, instanceof and JSON building on native
Apple Silicon only; x64 and Rosetta use 4K pages and never truncate.

The sibling GetAddressBitIndex was already fixed for this in the Apple
Silicon page size change (015b05d) by shifting before narrowing. Do
the same here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant