Skip to content

Windows GC: harden conservative stack scan (getRegisters, suspend retry, RegisterState) - #382

Open
robobun wants to merge 4 commits into
mainfrom
farm/c9105e62/win-gc-conservative-scan-hardening
Open

Windows GC: harden conservative stack scan (getRegisters, suspend retry, RegisterState)#382
robobun wants to merge 4 commits into
mainfrom
farm/c9105e62/win-gc-conservative-scan-hardening

Conversation

@robobun

@robobun robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Four targeted hardening changes to the Windows conservative-stack-scan path. Each is a separate commit.

1. Thread::getRegisters (ThreadingWin.cpp)

GetThreadContext was called without checking the return value, and sizeof(CONTEXT) (~1232 bytes on x64) was returned even though only CONTEXT_INTEGER | CONTEXT_CONTROL (~256 bytes) was filled.

  • On failure the caller would read a garbage Rsp and compute a bogus stack range, OOMing in growBuffer/fastMalloc. Now RELEASE_ASSERTs with GetLastError() in the crash info.
  • The ~976 bytes of uninitialized collector-thread stack after the integer/control block were being copied into the conservative-root buffer and scanned every cycle. Now returns only the populated prefix (offsetof(CONTEXT, Rip) + sizeof(Rip) on x64, offsetof(CONTEXT, Pc) + sizeof(Pc) on arm64).

2. MachineThreads::tryCopyOtherThreadStack (MachineStackMarker.cpp)

PlatformRegisters registers; is now value-initialized ({ }) so any bytes getRegisters does not populate are zero rather than stale collector-thread stack (typically last cycle's SlotVisitor::drain frames full of JSCell*). This is the platform-independent fix for the false-retention issue in (1); the size trim above is belt-and-braces. Value-initialization of an aggregate is malloc-free, preserving the no-allocation requirement while the target is suspended.

3. Thread::suspend retry + GC assert on persistent failure

SuspendThread and GetThreadContext can both transiently fail while the target is in early start, exit, or certain kernel transitions; CoreCLR and Go both retry for this reason. Thread::suspend now retries the SuspendThread/GetThreadContext pair with a short spin-then-sleep backoff (up to 100 attempts) before returning failure.

In MachineThreads::tryCopyOtherThreadStacks, on Windows, if suspend() still fails, RELEASE_ASSERT with the Win32 error instead of silently skipping that thread's stack for the cycle. A thread that has completed didExit() is already removed from the group under the lock held here, so any thread still in the iterated set is live; there is no exited-thread case to tolerate. Skipping a live mutator drops its roots and can sweep objects that are still referenced; a deterministic crash with a real signature is preferable to a use-after-free several GCs later.

4. RegisterState.h

The explicit callee-save capture was gated on !OS(WINDOWS), so clang-cl builds fell back to setjmp into a 256-byte jmp_buf (xmm6-15, mxcsr, ...) that all gets scanned. The gate is now COMPILER(GCC_COMPATIBLE) || COMPILER(CLANG) (clang-cl defines __clang__ but not __GNUC__, and accepts GNU inline asm). A Win64 x86_64 variant captures the Windows callee-saved GPRs (rbx, rbp, rdi, rsi, r12-r15; rdi/rsi are callee-saved on Win64 unlike SysV). rbp is also added to the SysV x86_64 list. MSVC proper keeps the setjmp fallback.

JSC uses SysV ABI for JIT operations on Windows x64, but the Win64 callee-saved set is a strict superset of SysV's, and a sysv_abi function calling Win64 C++ can keep a value in rdi/rsi across the call (clang knows the Win64 callee preserves them), so capturing the Win64 set is both sufficient and necessary. The previous setjmp fallback also captured rdi/rsi, so coverage is unchanged minus the xmm noise.

Expected impact

No behaviour change on macOS/Linux beyond the zero-init in (2) and the extra rbp word in (4). On Windows: fewer false roots per GC (addresses Windows-only flaky memory growth), and failures in the suspend/context path now crash with a signature instead of producing an incomplete root set (addresses Windows-heavy SlotVisitor/marking crash reports).

robobun added 2 commits August 2, 2026 07:57
…d return only the requested prefix

GetThreadContext can fail, leaving CONTEXT (including Rsp/Sp) undefined; the
GC caller would then compute a bogus stack range and OOM in growBuffer.
Crash immediately with the Win32 error code instead.

Only CONTEXT_INTEGER | CONTEXT_CONTROL is requested, so return a prefix that
ends after Rip (x64) / Pc (arm64) rather than sizeof(CONTEXT). The rest of
the struct is never populated and need not be copied into the conservative
root buffer.
…isters

Any bytes getRegisters() leaves untouched are now zero rather than whatever
was on the collector thread's stack (typically stale JSCell* from the
previous SlotVisitor::drain), so they no longer scan as false conservative
roots. Value-initialization of an aggregate is malloc-free, preserving the
no-allocation requirement while the target thread is suspended.
@robobun
robobun force-pushed the farm/c9105e62/win-gc-conservative-scan-hardening branch from bf27cb2 to f9c3527 Compare August 2, 2026 08:33
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The change expands Windows register capture, retries Windows thread context retrieval, returns architecture-specific register data, clears unpopulated registers, and asserts when stack scanning cannot process a live thread.

Changes

Windows stack scanning

Layer / File(s) Summary
Register capture support
Source/JavaScriptCore/heap/RegisterState.h
Clang builds, including clang-cl, use inline-assembly register capture. Windows x86_64 captures the Win64 callee-saved registers, and the existing x86_64 path also captures rbp.
Windows thread context retrieval
Source/WTF/wtf/win/ThreadingWin.cpp
Thread::suspend retries suspension and context reads with backoff. Thread::getRegisters asserts on context-read failure and returns the populated context size for x86-64 and ARM64.
Stack marker integration
Source/JavaScriptCore/heap/MachineStackMarker.cpp
PlatformRegisters is value-initialized. Failed processing of a non-exited Windows thread now triggers a release assertion.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the four changes and their expected impact, but it omits the required bug link, review status, and template-formatted changed-file list. Add the Bugzilla URL, include “Reviewed by NOBODY (OOPS!).”, and provide the required template-formatted explanation and changed-file entries.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: hardening Windows conservative stack scanning through register capture and suspension handling.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Source/WTF/wtf/win/ThreadingWin.cpp`:
- Around line 220-253: Update the retry loop in Thread::suspend so SuspendThread
failures are retried with the same bounded backoff as GetThreadContext failures
instead of returning immediately. Record GetLastError, apply SwitchToThread or
Sleep according to the attempt count, and only return the final error after
maxAttempts is exhausted; preserve cleanup for attempts where suspension
succeeded but GetThreadContext fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 671c2b50-64d7-4e4a-b5e0-69cd47292d52

📥 Commits

Reviewing files that changed from the base of the PR and between 9f8f24d and bf27cb2.

📒 Files selected for processing (3)
  • Source/JavaScriptCore/heap/MachineStackMarker.cpp
  • Source/JavaScriptCore/heap/RegisterState.h
  • Source/WTF/wtf/win/ThreadingWin.cpp

Comment thread Source/WTF/wtf/win/ThreadingWin.cpp
Comment thread Source/JavaScriptCore/heap/MachineStackMarker.cpp Outdated
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
87d87c14 autobuild-preview-pr-382-87d87c14 2026-08-02 09:59:08 UTC
f9c35273 autobuild-preview-pr-382-f9c35273 2026-08-02 09:18:51 UTC

robobun added 2 commits August 2, 2026 09:22
…text failures; crash if a live thread still cannot be suspended during GC

SuspendThread and GetThreadContext can both transiently fail while the
target is in early start, exit, or certain kernel transitions. Retry the
SuspendThread/GetThreadContext pair with a short spin-then-sleep backoff
(up to 100 attempts) before returning failure.

In MachineThreads::tryCopyOtherThreadStacks, on Windows, if suspend()
still fails, RELEASE_ASSERT with the Win32 error instead of silently
skipping that thread's stack for the current GC cycle. Any thread still
in the iterated set is live at the WTF level (didExit() removes the
thread from the group under the same lock we hold before setting
m_didExit), so there is no exited-thread case to tolerate here. Skipping
a live mutator's stack drops its roots and can sweep objects that are
still referenced.
…in64 variant

Gate the inline-asm capture on COMPILER(GCC_COMPATIBLE) || COMPILER(CLANG)
instead of !OS(WINDOWS). clang-cl accepts GNU inline asm (it defines
__clang__ but not __GNUC__), so Windows clang-cl builds now take the
explicit path instead of falling back to setjmp into a 256-byte jmp_buf
that is scanned in full.

Add a Win64 x86_64 RegisterState capturing the Windows x64 callee-saved
GPRs (rbx, rbp, rdi, rsi, r12-r15; rdi/rsi are callee-saved on Win64
unlike SysV). Add rbp to the existing SysV x86_64 list. The setjmp
fallback is kept for MSVC proper.
@robobun
robobun force-pushed the farm/c9105e62/win-gc-conservative-scan-hardening branch from f9c3527 to 87d87c1 Compare August 2, 2026 09:22

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Source/JavaScriptCore/heap/MachineStackMarker.cpp`:
- Around line 181-190: Update the Windows failure handling in
tryCopyOtherThreadStacks() so an exit-related Thread::suspend() failure skips
that thread instead of reaching RELEASE_ASSERT_NOT_REACHED. Check the thread’s
exit state while iterating m_threads, preserve the existing assertion for other
suspension failures, and retain normal stack scanning for successfully suspended
live threads.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4bd2382c-3b55-444b-8a66-2ef927a66e83

📥 Commits

Reviewing files that changed from the base of the PR and between bf27cb2 and 87d87c1.

📒 Files selected for processing (3)
  • Source/JavaScriptCore/heap/MachineStackMarker.cpp
  • Source/JavaScriptCore/heap/RegisterState.h
  • Source/WTF/wtf/win/ThreadingWin.cpp

Comment thread Source/JavaScriptCore/heap/MachineStackMarker.cpp

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — 87d87c1 addresses both prior comments (SuspendThread now retried; the dead hasExited() guard replaced with an unconditional RELEASE_ASSERT_NOT_REACHED). I didn't find further issues, but this touches the GC conservative-root path and Windows thread suspension, so it's worth a human look.

What was reviewed:

  • Suspend/resume count stays balanced across the retry loop (Resume before backoff on GetThreadContext failure; no Resume on SuspendThread failure).
  • Truncated getRegisters return sizes are 8-byte aligned on both x64 (Rip+8) and arm64 (Pc+8), so the copyMemory CPURegister-alignment asserts still hold; Rsp/Sp remain covered for MachineContext::stackPointer.
  • Win64 callee-save set (rbx rbp rdi rsi r12–r15) matches the MS x64 ABI; ARM64 clang-cl now hits the existing x19–x28 branch, which matches Win ARM64 AAPCS.
  • Backoff runs while earlier threads in the set are suspended, but only SwitchToThread/Sleep are called (no allocation/locking), so no new deadlock vector.
Extended reasoning...

Overview

Four related hardening changes to the Windows conservative-stack-scan path: (1) Thread::getRegisters now asserts on GetThreadContext failure and returns only the populated CONTEXT_INTEGER|CONTEXT_CONTROL prefix instead of sizeof(CONTEXT); (2) tryCopyOtherThreadStack value-initializes PlatformRegisters so unpopulated bytes are zero rather than stale collector-stack; (3) Thread::suspend retries transient SuspendThread/GetThreadContext failures with spin-then-sleep backoff, and tryCopyOtherThreadStacks now RELEASE_ASSERT_NOT_REACHED on persistent Windows suspend failure instead of silently skipping the thread; (4) RegisterState.h gates the explicit callee-save capture on COMPILER(GCC_COMPATIBLE) || COMPILER(CLANG) instead of !OS(WINDOWS), adds a Win64 x86_64 variant, and adds rbp to the SysV list.

Security risks

This is squarely in memory-safety territory. Conservative root scanning is what keeps the GC from sweeping live objects; getting it wrong yields use-after-free (missed roots) or unbounded retention (false roots). The PR moves in the safe direction on both axes — zero-init and size-trim reduce false roots; the retry + assert convert a silent root-drop into a bounded retry then deterministic crash. The new RELEASE_ASSERT_NOT_REACHED paths are a deliberate behavioral change (previously silent skip → now crash) that could surface in production if a Windows environment has a persistently unsuspendable mutator; that's the stated intent, but it's a policy call a human should ratify. No auth/crypto/injection surface.

Level of scrutiny

High. This is core GC + Win32 threading, where correctness depends on CONTEXT struct layout, MS x64 / Win-ARM64 calling conventions, and SuspendThread semantics — none of which are exercised by non-Windows CI, and errors manifest as rare heisenbugs. The retry loop also runs while other mutator threads are already suspended, so any call made during backoff must be allocation- and lock-free; SwitchToThread/Sleep satisfy that, but it's the kind of invariant that deserves a second pair of eyes.

Other factors

Both prior review comments (mine on the dead hasExited() guard, CodeRabbit's on SuspendThread not being retried) were addressed in 87d87c1 and the current diff reflects those fixes. I re-checked the updated retry loop for suspend-count balance, verified the truncated getRegisters sizes remain sizeof(CPURegister)-aligned so copyMemory's RELEASE_ASSERTs don't fire, confirmed Rsp/Sp are within the returned prefix so MachineContext::stackPointer still works, and checked the Win64 callee-save GPR list against the MS x64 ABI and the ARM64 branch against Win-ARM64 AAPCS. Everything looks correct, but given the blast radius (every Windows GC cycle) and the platform-specific subtlety, I'm deferring rather than approving.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 2, 2026
Picks up oven-sh/WebKit#382 (87d87c14), which is on top of WebKit main
9f8f24db19. Four changes to the Windows conservative stack scan:

- Thread::getRegisters: RELEASE_ASSERT on GetThreadContext failure and
  return only the populated CONTEXT_INTEGER|CONTEXT_CONTROL prefix
  (256 bytes on x64 instead of sizeof(CONTEXT)=1232).
- MachineThreads::tryCopyOtherThreadStack: value-initialize
  PlatformRegisters so unpopulated bytes scan as zero, not stale
  collector stack.
- Thread::suspend: retry transient SuspendThread/GetThreadContext
  failures (spin then Sleep, up to 100 attempts). tryCopyOtherThreadStacks
  now RELEASE_ASSERTs if a live thread still cannot be suspended rather
  than silently skipping its roots.
- RegisterState: use the explicit callee-save inline-asm capture on
  clang-cl (Win64 variant with rbx/rbp/rdi/rsi/r12-r15) instead of the
  256-byte setjmp fallback. Also adds rbp to the SysV x86_64 list.

Also pulls in the intervening WebKit-main commits 34c01d13..9f8f24db
(shorthand-in-arrow arguments-capture fix, CodeBlock-aging gating,
Dockerfile.windows CI fixes).
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.

2 participants