Repository navigation
Conversation
|
Warning Review limit reached
Next review available in: 2 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
Comment |
|
Status: ready for review (scope widened once after self-review, see below). Reproduced on Windows Server 2019 x64 with unfixed debug builds: a fault in With the fix, all three walk through the LLInt frames and Self-review found that the panic/OOM capture ( CI on the current head (751e8e4, build 97700): 177 jobs passed, including all 16 Windows x64 / ARM64 test shards; the shard on each Windows architecture that ran |
There was a problem hiding this comment.
I re-reviewed after 06cc3c1 and 5e7752c — the stale CONTEXT doc comments and layout asserts are fixed, and the bug-hunting pass found nothing further. Given this is unsafe crash-time unwinding logic that encodes assumptions about JSC's CallFrame layout, offlineasm frame-pointer discipline, and Win32 unwind semantics across two architectures, a human look is still worthwhile.
What was reviewed:
accessible_page_protectionrefactor preserves the exactis_valid_memorypredicate (MEM_COMMIT, no PAGE_NOACCESS/PAGE_GUARD, then the READABLE mask).unwind_frame_pointerguards: alignment,frame >= sp,checked_addfor caller_sp, and both slot loads go throughMemoryAccessorso an unmapped frame page fails the walk rather than faulting the handler.- Loop termination: the new
is_executable_memory(next_pc)check applies to all three step sources, and then < out.len()cap remains the ultimate bound. - Test decoder cross-checked against the layout comment; the
isJitFramepredicate accepts both current_and #35440's"JIT"tag.
Extended reasoning...
Overview
The PR changes the Windows crash-handler stack walker in src/bun_core/debug.rs to correctly step through JSC offlineasm frames (LLInt opcode handlers and vmEntryToJavaScript) that have no .pdata unwind info. Previously these were treated as leaf functions, which either truncated the trace (interpreted callers) or padded it with garbage stack slots (JIT callers reaching vmEntryToJavaScript). The fix: (1) restrict the leaf-pop to frame 0 and only when [Rsp]/Lr holds a code address, (2) otherwise step via the frame pointer (Rbp/Fp), which offlineasm and the JIT-pool unwind info both maintain as a CallFrame chain, (3) stop the walk on any non-executable PC. Supporting changes: a shared accessible_page_protection helper factored out of is_valid_memory, a new is_executable_memory predicate, PAGE_EXECUTE added to bun_windows_sys, and the CONTEXT struct docs/layout-asserts extended to cover Rbp/Fp. Two new Windows-only tests decode the trace string and count bun-image frames below the JIT frames.
Security risks
None. This code runs only inside the crash handler after a fault has already occurred; its inputs are the kernel's fault CONTEXT and the process's own address space. All memory reads go through MemoryAccessor::load_usize (which VirtualQuerys first on Windows) or the new is_executable_memory predicate, so the walker cannot itself fault on unmapped pages. No user-controlled data reaches this path.
Level of scrutiny
High. This is unsafe Rust operating on raw register state at crash time, on two Windows architectures, with correctness depending on undocumented-in-code assumptions about JSC's CallFrame layout, offlineasm's frame-pointer discipline, and how RtlVirtualUnwind restores callee-saved registers across compiled frames. A mistake here does not corrupt user state (the process is already dying) but would degrade every Windows crash report. The PR description is unusually thorough in justifying each invariant, and the tests are well-constructed (they decode the actual trace-string format and assert structural properties, verified to fail on the unfixed build). Still, the depth of platform-specific reasoning warrants a human reviewer familiar with the Windows unwind path and prior PRs #35074/#35083.
Other factors
My earlier nit (stale CONTEXT doc comments and missing offset_of! pins for Rbp/Fp) was addressed in 06cc3c1. The comment-cop bot's length complaints were addressed in 5e7752c with a reasonable pushback — the remaining comments state invariants the code cannot express, not workaround justifications. The is_valid_memory refactor is behavior-preserving: the same MEM_COMMIT && !(PAGE_NOACCESS|PAGE_GUARD) && (Protect & READABLE) predicate, just factored through the shared helper. The new tests use test.concurrent, drain both pipes, use await using, and are gated describe.if(isWindows). The PR notes three open PRs touching adjacent lines (#32871, #35440, #37605) and the test already accepts #35440's "JIT" tag encoding.
|
Updated 11:05 PM PT - Aug 14th, 2026
❌ @robobun, your commit 5e7752c has some failures in 🧪 To try this PR locally: bunx bun-pr 38789That installs a local version of the PR into your bun-38789 --bun |
…path's walk RtlCaptureStackBackTrace stops at the first frame without static unwind info, which for anything reached from JS is the JIT thunk, so panic and out-of-memory reports carried none of the JS frames or the code that entered JS. Crash reports now seed the RtlVirtualUnwind walk from RtlCaptureContext; StoredTrace keeps the cheap capture since it runs on every debug-build refcount init. Also drops the incorrect claim that tinycc trampolines need the frame-pointer step (tccrun.c registers a function table for them) and notes that the step is only needed until the offlineasm blob gets .pdata.
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs (my earlier nit about the CONTEXT doc comments was addressed in 06cc3c1, and the new capture_current_for_crash path in 751e8e4 looks consistent with the fault-path walk). Because this is low-level Windows unwinding logic whose correctness rests on JSC/offlineasm invariants and runs inside a crash handler on a possibly-corrupt stack, a human look from someone familiar with the Windows unwind + JSC CallFrame layout would still be worthwhile.
What was reviewed:
unwind_leaf/unwind_frame_pointer/walk_context: the frame-0-only leaf gate, theis_executable_memorystop condition, and that every raw read goes throughMemoryAccessor/VirtualQuerybefore dereference.is_valid_memoryrefactor: confirmedaccessible_page_protectionpreserves the previousMEM_COMMIT/PAGE_GUARD/PAGE_NOACCESSsemantics.capture_current_for_crash:RtlCaptureContextseeding,out.is_empty()guard, and that non-Windows delegates unchanged tocapture_current.- New tests: the VLQ decoder against the documented
encode_trace_stringlayout, and that assertions distinguish fixed from unfixed behavior per the PR description.
Extended reasoning...
Overview
This PR reworks the Windows crash-handler stack walker in src/bun_core/debug.rs so it can step through JSC offlineasm frames (LLInt opcode handlers and vmEntryToJavaScript) that have no .pdata unwind info. The old code treated every no-.pdata PC as a leaf and popped [Rsp], which read a CallFrame slot instead of a return address — truncating traces at the first LLInt frame or padding them with garbage below vmEntryToJavaScript. The fix: (1) restrict the leaf step to frame 0 and validate its result is a code address, (2) otherwise step through rbp/x29 (unwind_frame_pointer), (3) stop the walk on any non-executable PC. It also extracts the walk loop into walk_context and adds capture_current_for_crash so panic/OOM reports (not just faults) use the same walk via RtlCaptureContext. Supporting changes: PAGE_EXECUTE constant + RtlCaptureContext extern + CONTEXT field-offset asserts in src/windows_sys/externs.rs; capture_crash_stack_trace re-export in src/bun_core/lib.rs; the crash_handler crate's internal capture_stack_trace now calls the thorough variant. Two new Windows-only tests decode the trace string and assert bun-image frames appear below the JIT frames.
Security risks
None user-facing. The code runs only inside the crash handler after a fatal fault/panic, reads process-local memory through VirtualQuery-guarded loads, and has no external inputs. The worst-case failure mode is a truncated or wrong crash trace, which is what the PR is fixing.
Level of scrutiny
High. This is (a) crash-handler code executing in a possibly-corrupt process, (b) unsafe FFI to RtlVirtualUnwind/RtlLookupFunctionEntry/RtlCaptureContext with hand-maintained CONTEXT layouts, (c) dual-arch #[cfg]-gated code where only one branch is checked per target, and (d) a correctness argument that depends on how JSC's offlineasm maintains the frame-pointer register and how RtlVirtualUnwind restores callee-saved registers across compiled frames. The PR description argues each invariant carefully and the tests demonstrably fail on the unfixed build, but validating that the frame-pointer step is sound for every offlineasm entry path (and that no other in-image code without .pdata violates it) needs domain knowledge I can't verify from the diff alone.
Other factors
- My earlier nit (stale
CONTEXTdoc comments listing which fields bun reads) was addressed in 06cc3c1 with theRbp/Fpadditions and matchingoffset_of!asserts. - The refactor of
is_valid_memoryintoaccessible_page_protectionis behavior-preserving: sameMEM_COMMIT+ not-PAGE_NOACCESS/PAGE_GUARDgate, sameREADABLEmask. - All raw memory reads in the new fallback steps go through
MemoryAccessor::load_usize, which itself gates onis_valid_memory;frame < sp, alignment, andchecked_addguards are present. - The comment-cop bot has re-flagged several multi-line doc comments after 751e8e4; the author already argued (and I agree) that these state the invariant the code depends on rather than justify a workaround, so I don't consider them blocking.
- CI on 5e7752c passed all Windows shards; the head commit 751e8e4 adds the panic-path capture and is what the fresh bot comments are on. The new tests are Windows-only and cannot run on the Linux gate, same as the prior PRs in this area.
- Open PRs #32871, #35440, #37605 touch adjacent lines; the test already accepts #35440's
"JIT"tag encoding.
|
Heads-up: #38838 changes the trace string to format 4, which inserts a header right after the 7-character sha (a VLQ field count, then per field a VLQ tag, a VLQ char count and that many chars) before the two feature VLQs. The test decoder added here reads the features directly after the sha, so whichever of the two lands second needs to skip that header first; the header loop in the |
|
The root fix this PR's description points at is now up: oven-sh/WebKit#442 emits one Interaction with this PR: once the pin lands, the PCs |
|
oven-sh/WebKit#442 is on main since #35343, so the LLInt and Two points for the rebase of this PR:
|
Problem
vmEntryToJavaScript. Both of bun's Windows stack captures mishandle them (src/bun_core/debug.rs):capture_from_contexttreats any PC without aRUNTIME_FUNCTIONas a leaf and pops[Rsp]as the return address. Those frames are not leaves, so the pop reads aCallFrameslot. Native function called from interpreted JS (host function orbun:fficall in a script): the slot is the null CodeBlock slot and the trace ends one frame below the JIT thunk (... __jsc_host_js_segfault, <JIT thunk>, llint_entry, end). JIT-compiled callers get as far asvmEntryToJavaScript, after which the slots of its frame are reported as frames until the 20-entry buffer is full: the second handoff trace decodes tointlSegmenterPrototypeFuncSegment, 7 JIT frames,vmEntryToJavaScript, 5 frames in no module (pkg:"?"on bun.report); the shipped8bb8d04c4binary gives 11 of them for the same shape.RELEASE_ASSERTs, OOM (everyTraceSeedother thanFault, viacapture_current):RtlCaptureStackBackTracestops at the first frame without static unwind info, which is the JIT thunk itself.crash_handler.panic()called through three JS functions reports the panic machinery, the thunk, and nothing else.Fix
walk_context, used by both captures.capture_from_contextseeds it from the faultCONTEXTas before; crash reports that are not faults now go throughcapture_current_for_crash(bun_core::capture_crash_stack_trace, which is what the crash handler'sdebug::capture_stack_tracewrapper now calls), seeding it withRtlCaptureContextand trimming with the samefirst_addresslogic.capture_currentitself keepsRtlCaptureStackBackTrace:StoredTracecaptures it on every debug-build refcount init (ThreadLock::lock), which cannot afford aVirtualQueryper frame, and those dumps are diagnostics, not reports. That is the one Windows capture deliberately left as is.RtlFillMemoryboth are), so[Rsp](Lron ARM64) is still taken there, but only when it holds a code address (unwind_leaf); otherwise, and for every later frame, step through the frame-pointer register, caller frame at[fp], return address at[fp+8], callersp = fp + 16(unwind_frame_pointer).RtlVirtualUnwind, has to be in executable memory (VirtualQuery), or the walk stops.is_valid_memoryand the newis_executable_memoryshare the query;PAGE_EXECUTEandRtlCaptureContextare added tobun_windows_sys, and theCONTEXTdocs and layout asserts now coverRbp/Fp, which the step reads.rbp/x29on aCallFrame, whose first two slots are exactly the caller frame and return address. It is the same layout the unwind info JSC registers for the JIT pool describes (registerJITUnwindInfo, crash_handler(windows): let foreign first-chance AVs reach SEH via JSC unwind info #35083), so the two compose, and it is not a frame-pointer walk: the prebuilt C++ keeps no frame pointer, and it isRtlVirtualUnwindstepping through those frames that restoresrbp/x29by the time an offlineasm return address comes up. Offlineasm is the only such code: tinycc registers a function table for everything it compiles (tccrun.c,win64_add_function_table), and thebun:fficall stubs are JIT-pool code; an earlier revision of this PR wrongly listed tinycc here.[Rsp], and the code-address check routes that case to the frame-pointer step too..text, JIT pool, FFI stubs; frame 1 of the FFI test is a pool address produced by the leaf step, so that case is exercised). Anything else means the chain is lost, and continuing is what produced the padding..pdata/.xdataentry over the offlineasm blob (jsc_llint_begin..jsc_llint_end, the SEH twin of the.cfi_*block LowLevelInterpreter.cpp already emits for DWARF), which is the follow-up ExecutableAllocator.cpp and crash_handler(windows): let foreign first-chance AVs reach SEH via JSC unwind info #35083 already name; it would also give LLInt faults an SEH catch point and fix ETW/WinDbg. It is a WebKit change plus a pin bump, so it is tracked separately; when it lands,unwind_frame_pointerbecomes dead and its doc comment says to delete it. The frame-0 rule, the executable check and the report-path capture stay useful after it.Windows: crash trace continues below the JS frames(3 tests, Windows only):segfaultandpaniccalled through three interpreted JS functions, and anRtlFillMemoryfault reached through JIT-compiled JS. Unfixed, they report 1, 0 and 0 bun frames below the JIT frames (thepaniccase measured on a build that already had the fault-path fix, so it isolates thecapture_current_for_crashchange); fixed, all three pass. Fixed traces symbolized with the debug PDBs give the same chain on x64 and ARM64 for both the fault and the panic:llint_entryx3,llint_entry(module body),vmEntryToJavaScript,JSC::Interpreter::executeProgram,JSC::evaluate,Bun::evaluateCommonJSModuleOnce, ... to the 20-frame cap, no unknown frames; the panic trace's frames above the thunk are byte-for-byte the ones the old capture produced, so trimming is unchanged.cargo check -p bun_crash_handlerclean onx86_64-pc-windows-msvc,aarch64-pc-windows-msvcand Linux; debug.rs is clippy-clean on both Windows targets (the fourborrow_as_ptrhits main has in the moved loop are fixed in passing, which is also what bun_core: fix clippy on the Windows and FreeBSD targets and lint them in CI #37605 does to those lines).capture_from_context's signature), crash_handler: tag JSC JIT-pool frames instead of discarding them #35440 (tags JIT-pool frames"JIT"; the new tests accept either encoding), bun_core: fix clippy on the Windows and FreeBSD targets and lint them in CI #37605 (the clippy lines above).Background
RUNTIME_FUNCTION(.pdata) for every compiled function that uses the stack;RtlLookupFunctionEntryfinds it for a PC andRtlVirtualUnwindapplies it to a registerCONTEXT, restoring callee-saved registers includingrbp/x29. Leaf functions (no stack use) get no entry; for them the return address is still at[rsp](inlron ARM64). For a PC inside a loaded imageRtlLookupFunctionEntryconsults only that image's static.pdata, so dynamic function tables (what crash_handler(windows): let foreign first-chance AVs reach SEH via JSC unwind info #35083 registers for the JIT pool and tinycc registers for its output, both outside the image) cannot cover in-image code.RtlCaptureContextfills aCONTEXTwith the calling function's registers;RtlCaptureStackBackTraceis ntdll's own walker over the same tables, with no way to step a frame the tables do not describe.vmEntryTo*trampolines. Its output is linked into bun's.textwithout.seh_*directives, so it has no.pdata, but it keeps the frame-pointer register pointing at the currentCallFrame.CallFrame: JSC's JS stack frame. On both architectures the frame-pointer register points at it; slot 0 is the caller's frame pointer and slot 1 the return address, which is what every JIT tier's prologue and LLInt'sfunctionProloguepush.TraceSeed(src/crash_handler/lib.rs): how a report's trace is obtained.Faultcarries the signal/exception context; the other seeds (panic, OOM, C++Bun__crashHandler) capture the current stack from inside the handler and trim everything above a caller-supplied return address._for an address in no module. The tests decode that and count bun-image frames below the last JIT-pool frame.What the handed-off traces actually were
The handoff's two bun.report traces (build
8bb8d04c4) have mid-instruction addresses in every frame including frame 0, which a real fault PC cannot be. That commit has two separate x64 links,bun-windows-x64-profileandbun-windows-x64-baseline-profile(different PDB GUIDs,.textdiffers by 40 KB), and a trace string identifies a build only by platform character plus 7-character sha. Symbolized against the baseline link, every frame of both traces is an instruction boundary directly after acallinto the previous frame's function:JSC::SlotVisitor::visitChildren(the fault ismov rax, [rax+0x50]withrax = 0x20200000000, matching the reported fault address0x20200000050),SlotVisitor::drain,drainFromShared, theHeap::runBeginPhaselambda,ParallelHelperClient::runTask,AutomaticThread,WTF::Thread::entryPoint,wtfThreadEntryPoint, ucrtthread_start,BaseThreadInitThunk,RtlUserThreadStart. A GC marking thread hitting a corrupt cell, not date code.icu_73::RuleBasedBreakIterator::BreakCache::resetentered with a nullthis(fault address0x10, PC at the function's first instruction, no.pdata, so this is the leaf step working),RuleBasedBreakIterator::operator=,RuleBasedBreakIterator::clone,ubrk_clone,JSC::cloneUBreakIterator(IntlWorkaround.cpp) inlined intoIntlSegmenter::segment,intlSegmenterPrototypeFuncSegment, 7 JIT frames,vmEntryToJavaScript, then 5 frames of padding. The padding is what this PR fixes; the rest of the trace was right.So the walker was sound for those crashes; the fix here is for what happens below
vmEntryToJavaScript, plus the interpreted-caller truncation. The build-identity problem (which link aw+ sha7 trace string came from) is a release/bun.report matter and is not changed here.