Skip to content

crash_handler: encode fault pc + GP registers from ucontext into the trace string (v3) - #34814

Closed
robobun wants to merge 14 commits into
mainfrom
farm/c161bf75/crash-handler-ucontext-registers
Closed

robobun wants to merge 14 commits into
mainfrom
farm/c161bf75/crash-handler-ucontext-registers

Conversation

@robobun

@robobun robobun commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator

What

Capture the faulting pc and general-purpose register file from ucontext_t (POSIX) / CONTEXT (Windows) in the fault signal handler and encode them into the crash-report trace string.

Decoder: oven-sh/bun.report#28.

Why

For hardware faults (SIGSEGV/SIGILL/SIGBUS/SIGFPE, access violation) the register state at the fault is often the only way to reconstruct this, arguments, or the dereferenced pointer when the frame-pointer walk is short or corrupt. The handler was already reading pc/fp from ucontext_t to seed the walk, but none of it reached the trace string.

How

  • FaultRegisters holds pc, fp, and the GP register file in a fixed per-arch slot order (FaultRegisters::NAMES is the source of truth):
    • x86_64: rax rbx rcx rdx rdi rsi rbp rsp r8..r15 rip (17)
    • aarch64: x0..x28 fp lr sp pc (33)
  • fault_context_from_ucontext fills it on linux/android (glibc + musl) and macOS, both arches.
  • fault_context_from_windows_context reads CONTEXT by the documented winnt.h byte offsets on x64 and arm64.
  • TraceSeed::Fault borrows &FaultRegisters (lives on the signal handler's altstack; boxing would allocate inside a signal handler).
  • encode_trace_string appends a register block after the reason payload for hardware-fault reasons '2'..'7' only: one StackLine-encoded fault pc (ASLR removed, remappable), a VLQ register count, then count × two-VLQ register values. When the fault path has no saved context (unsupported arch) the block is _A (no pc, zero registers).

Trace-string format (v3)

<bun_ver>/<platform><cmd>3<sha7><build_flags vlq><features 2-vlq><frames><A><reason>[<reg block>]
  • VERSION_CHAR is now a single '3'. The even/odd split ('1'/'2' = release/canary) is replaced by a one-byte build_flags VLQ right after the 7-char sha; bit 0 = canary. Future build-variant flags pack into the same VLQ without a format bump.
  • Reasons '0'/'1'/'8'/'9' have no register block.
  • Reasons '2'..'7' are followed by the register block.
  • '1'/'2' strings (older builds) remain decodable.

Testing

test/cli/run/run-crash-handler.test.ts gains a trace string v3 describe block:

  • segfaultWithRegisters (new internal-for-testing hook) invokes the handler with a synthetic FaultRegisters so the encoding path is exercised deterministically, including under ASAN where our SIGSEGV handler is not installed. Asserts version char is '3', the build-flags byte follows the sha, and the payload ends with the exact VLQ encoding of the sentinel register values.
  • outOfMemory (non-fault reason '9') asserts no register block is appended.

cargo check -p bun_crash_handler passes on all 10 supported targets (linux gnu/musl, macos, windows, android, freebsd × x64/aarch64) and cargo clippy is clean.

Verified end-to-end with real segfaults on linux-x64, macos-x64, macos-arm64, windows-x64, windows-arm64 (see comments below for decoded output).


no test proof · iteration 7 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/run/run-crash-handler.test.ts

@robobun

robobun commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:54 PM PT - Jul 22nd, 2026

❌ @autofix-ci[bot], your commit 6ab1d4a has 2 failures in Build #78089 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34814

That installs a local version of the PR into your bun-34814 executable, so you can run:

bun-34814 --bun

@coderabbitai

coderabbitai Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Bun’s crash-handler trace strings now use v3/v4 formats with optional fault register blocks. Native handlers capture registers, JavaScript testing bindings expose a deterministic register-bearing crash, and CLI tests validate fault and non-fault encodings.

Possibly related PRs

  • oven-sh/bun#34024: Updates the crash-handler CLI test harness for intentional crash subprocesses.

Suggested reviewers: jarred-sumner, dylan-conway

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 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 is specific and accurately summarizes the main change: encoding fault PC and registers into crash trace strings.
Description check ✅ Passed The description covers what changed, why, how, and verification, which satisfies the repository template's required information.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/crash_handler/lib.rs (1)

1677-1834: 🎯 Functional Correctness | 🔵 Trivial | 🏗️ Heavy lift

Add direct tests for the raw register readers
The synthetic segfault path is covered, but the raw ucontext_t/CONTEXT readers still have no direct tests. Add a small test-only shim that feeds known register blobs into fault_context_from_ucontext / fault_context_from_windows_context and asserts FaultRegisters::values(); that would catch a bad offset on any supported platform.

🤖 Prompt for 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.

In `@src/crash_handler/lib.rs` around lines 1677 - 1834, Add platform-specific
direct tests for fault_context_from_ucontext and
fault_context_from_windows_context using test-only contexts or register blobs
populated with known values, then assert the resulting FaultRegisters::values()
in the documented register order. Cover every supported Unix and Windows
architecture without affecting production code, so incorrect ucontext/CONTEXT
offsets are detected.
🤖 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.

Outside diff comments:
In `@src/crash_handler/lib.rs`:
- Around line 1677-1834: Add platform-specific direct tests for
fault_context_from_ucontext and fault_context_from_windows_context using
test-only contexts or register blobs populated with known values, then assert
the resulting FaultRegisters::values() in the documented register order. Cover
every supported Unix and Windows architecture without affecting production code,
so incorrect ucontext/CONTEXT offsets are detected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c4d0b004-304f-431a-bd5b-3efc43379974

📥 Commits

Reviewing files that changed from the base of the PR and between 1ff1454 and 04a5bdb.

📒 Files selected for processing (5)
  • src/crash_handler/lib.rs
  • src/js/internal-for-testing.ts
  • src/runtime/api/crash_handler_jsc.rs
  • test/cli/run/fixture-crash.js
  • test/cli/run/run-crash-handler.test.ts

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed bf017c5 to address the clippy large_enum_variant / large_types_passed_by_value failures: TraceSeed::Fault now borrows &FaultRegisters instead of owning it. Boxing was not an option here since the value is constructed inside a signal handler where malloc is not async-signal-safe; the register file lives on the handler's altstack instead.

On the review suggestion to add direct unit tests for the raw ucontext_t/CONTEXT readers: on the POSIX side every register read goes through libc's own struct fields (mcontext_t.gregs[REG_*], __darwin_*_thread_state64.__r*), so the offsets are whatever libc says they are and the compiler type-checks them on every target. The Windows side does hand-code winnt.h offsets against the opaque CONTEXT blob; a typo there would be silent. Happy to add a small fabricated-CONTEXT unit test for that path if we want the extra guard, but the end-to-end test already covers the encoding pipeline and all ten targets cargo check clean, so leaving it as-is for now unless a maintainer wants it.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/crash_handler/lib.rs:2869-2885 — Appending the register block unconditionally after the reason payload breaks decodability for reasons '0' (Panic) and '8' (ZigError), whose payloads were end-of-string-terminated in v1/v2 with no length prefix. A v1/v2 decoder that (per this PR's stated compat contract) reads the reason payload to end-of-string will now see <b64>_A / <name>_A and fail; and for a v3 decoder, reason '8' is genuinely ambiguous because _ and A are valid identifier characters — which contradicts the "decoder does not have to branch on which reason codes carry one" comment. Consider restricting the register block to the self-delimiting fault reasons (2–5), or add an out-of-alphabet delimiter / length prefix before it.

    Extended reasoning...

    What the bug is

    encode_trace_string now unconditionally appends a register block after every reason payload (lib.rs:2869-2885). Two reason encodings have no self-delimiting length — in v1/v2 they were terminated by end-of-string:

    • CrashReason::Panic (reason byte '0', lib.rs:2795-2836) writes standard-alphabet base64 (A-Za-z0-9+/) of the zlib-compressed message, with = padding stripped at line 2836.
    • CrashReason::ZigError (reason byte '8', lib.rs:2861-2864) writes the raw error-name bytes with no length prefix or terminator.

    Both call sites pass regs: None today, so the appended suffix is _A (StackLine::write_encoded(None) → _ at lib.rs:2710, then VLQ::ZERO → A).

    Why this is a wire-format problem

    The v1/v2 backward-compat claim is false for these two reasons

    The VERSION_CHAR doc (lib.rs:2543-2548) and the PR description both say "a decoder that only knows '1'/'2' can treat '3'/'4' the same and stop at the end of the reason payload; everything after it is additive". That only works for reason payloads that are self-delimiting (1/6/7/9 have zero trailing bytes; 2-5 have exactly two VLQs). For '0' and '8', the v1/v2 contract is "read to end of string" — there is nothing else for a v1/v2 decoder to stop on. Such a decoder now reads <b64>_A as the panic base64 (invalid: _ is not in the standard alphabet, and the trailing A shifts the length mod 4) or <name>_A as the error name.

    Reason '8' is ambiguous even for a v3-aware decoder

    For a v3 decoder following the documented format (reason payload → StackLine → VLQ count → count×2 VLQs), '8' cannot be forward-parsed: _ and A are both valid Rust/Zig identifier characters, so 8DumpStackTrace_A cannot be split into name="DumpStackTrace" + regs=_A vs. name="DumpStackTrace_A" + without out-of-band knowledge. The only way to decode it is to hard-code that reason '8' always has regs: None and strip a fixed _A suffix — which directly contradicts the code comment at line 2869-2871 ("Always present so the decoder does not have to branch on which reason codes carry one") and is unenforced by types (nothing prevents a future call site from passing regs: Some with ZigError, at which point the format is unrecoverable — StackLine/VLQ bytes overlap the identifier alphabet).

    Panic is technically delimitable by a v3 decoder because _ is outside the standard base64 alphabet — but that still requires the decoder to special-case reason '0' ("scan for the first non-base64 byte"), again contradicting the "no branching" design goal.

    Step-by-step proof (ZigError)

    1. dump_stack_trace / handle_root_error construct TraceString { reason: CrashReason::ZigError(b"DumpStackTrace"), regs: None, .. } (lib.rs:2152-2156, 3288-3292).
    2. encode_trace_string writes … b'8' b"DumpStackTrace" (lib.rs:2861-2864).
    3. opts.regs is None → writes b"_" (lib.rs:2874, 2710) then VLQ::ZERO = b"A" (lib.rs:2875).
    4. Emitted tail: …8DumpStackTrace_A (plus optional /view).
    5. A v1/v2 decoder reading reason '8' payload as "rest of body" gets error name DumpStackTrace_A.
    6. A v3 decoder scanning forward for the register-block start cannot distinguish this from an error literally named DumpStackTrace_A with a missing register block.

    Impact

    The comment at lib.rs:2793 ("must be kept in sync with bun.report's decoder") confirms there is an external consumer of this wire format. Panic is the common Rust panic path and ZigError is emitted by handle_root_error / dump_stack_trace, so both are exercised in production. Shipping this format means either bun.report's decoder must special-case reasons '0'/'8' (contradicting the stated design), or those crash types become undecodable / mis-decoded.

    Suggested fix

    Either:

    • Emit the register block only for the self-delimiting fault reasons (2/3/4/5, optionally 6/7) — those are the only ones that ever carry a real fault context anyway; or
    • Prefix the register block with an out-of-alphabet delimiter (a byte that cannot appear in standard base64 or an identifier — e.g. = or ~), or length-prefix reason '8''s error name.

    Per REVIEW.md ("For protocols, derive behavior from the spec"), the wire format should be unambiguous by construction rather than relying on the current invariant that Panic/ZigError call sites happen to pass regs: None.

Comment thread src/crash_handler/lib.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/crash_handler/lib.rs (1)

1799-1884: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Hand-coded Windows CONTEXT offsets are correct, but remain untested.

I independently verified these offsets against the documented winnt.h layouts: for x64 CONTEXT, Rax at 0x78 through Rip at 0xF8 match Microsoft's published field order; for ARM64_NT_CONTEXT, Fp/X29 at 0xF0, Sp at 0x100, and Pc at 0x108 also match. So the current values are accurate.

That said, per the PR discussion this path has no unit test (a fabricated CONTEXT test was explicitly deferred), meaning a future edit to these magic offsets could silently corrupt register capture with nothing to catch it — the only coverage is the POSIX-only segfaultWithRegisters fixture and cross-target cargo check. Worth tracking as follow-up test debt even though the offsets check out today.

🤖 Prompt for 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.

In `@src/crash_handler/lib.rs` around lines 1799 - 1884, Add targeted Windows
architecture-specific tests for fault_context_from_windows_context using
fabricated CONTEXT memory layouts, validating the documented register offsets,
extracted pc/fp values, register ordering, and count for both x86_64 and
aarch64. Keep the existing fallback behavior and ensure the tests are gated to
their respective Windows targets.
🤖 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.

Outside diff comments:
In `@src/crash_handler/lib.rs`:
- Around line 1799-1884: Add targeted Windows architecture-specific tests for
fault_context_from_windows_context using fabricated CONTEXT memory layouts,
validating the documented register offsets, extracted pc/fp values, register
ordering, and count for both x86_64 and aarch64. Keep the existing fallback
behavior and ensure the tests are gated to their respective Windows targets.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 54813686-1457-4848-a39b-aff2ce4e82d9

📥 Commits

Reviewing files that changed from the base of the PR and between 04a5bdb and bf017c5.

📒 Files selected for processing (3)
  • src/crash_handler/lib.rs
  • src/runtime/api/crash_handler_jsc.rs
  • test/cli/run/run-crash-handler.test.ts

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed in f354cdb:

  • Wire-format ambiguity for reasons '0'/'8' (claude bot 🔴): correct, and a real bug. The register block is now emitted only for hardware-fault reasons '2'..'7', whose payloads are self-delimiting (two VLQs or empty). Panic/Unreachable/ZigError/OOM ('0'/'1'/'8'/'9') are now byte-identical to v1/v2, so a v1 decoder reads them unchanged and there is no identifier/base64 boundary to disambiguate. Those variants never reach the handler with a fault context anyway (debug_assert! added). PR description and VERSION_CHAR doc comment updated.
  • FaultRegisters::NAMES dead code (claude bot 🟡): kept and made live. FaultRegisters::COUNT = NAMES.len() is now the single source of truth; every ucontext/CONTEXT reader assigns r.count = FaultRegisters::COUNT, so adding or reordering a slot without updating NAMES fails at compile time on that arch.
  • Windows CONTEXT offset unit test (coderabbit): noted. The offsets were independently verified against winnt.h; adding a fabricated-CONTEXT unit test is a reasonable follow-up. Bun's test pipeline drives bun:test rather than cargo test, so the right home would be a small #[test] wired into the Windows CI lane; leaving that for a follow-up so this PR stays focused on the wire format.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/cli/run/run-crash-handler.test.ts (1)

158-171: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Decode and assert the deterministic register block.

The length check and incidental "I" search do not prove the count or values: "I" may occur in the encoded PC StackLine, and malformed register data can still meet the size threshold. Decode the v3 tail and assert the fixture PC, count 4, and all four synthetic values.

As per coding guidelines, tests must “assert the strongest invariant” and “prove they fail for the intended reason.”

🤖 Prompt for 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.

In `@test/cli/run/run-crash-handler.test.ts` around lines 158 - 171, Replace the
length and incidental “I” assertions around reasonIdx/afterReason with
deterministic decoding of the v3 tail. Use the existing payload/stack-trace
decoder to parse the fault StackLine and register block, then assert the fixture
PC, register count 4, and all four expected synthetic register values. Keep the
segfault-reason presence check, and ensure assertions fail when the encoded
count or register values are malformed.

Source: Coding guidelines

🤖 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 `@src/crash_handler/lib.rs`:
- Around line 2617-2624: Condense the changed comments to three lines or fewer
while preserving their meaning: in src/crash_handler/lib.rs lines 2617-2624,
summarize the v3 format description; in lines 673-676, summarize the
hardware-fault classification rationale; in lines 2946-2951, summarize encoder
compatibility; and in test/cli/run/run-crash-handler.test.ts lines 194-197,
summarize the non-fault suffix rationale. Make no code changes.

---

Outside diff comments:
In `@test/cli/run/run-crash-handler.test.ts`:
- Around line 158-171: Replace the length and incidental “I” assertions around
reasonIdx/afterReason with deterministic decoding of the v3 tail. Use the
existing payload/stack-trace decoder to parse the fault StackLine and register
block, then assert the fixture PC, register count 4, and all four expected
synthetic register values. Keep the segfault-reason presence check, and ensure
assertions fail when the encoded count or register values are malformed.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6136bad5-ebc5-43fc-ab2d-bf4d69d534b7

📥 Commits

Reviewing files that changed from the base of the PR and between bf017c5 and f354cdb.

📒 Files selected for processing (2)
  • src/crash_handler/lib.rs
  • test/cli/run/run-crash-handler.test.ts

Comment thread src/crash_handler/lib.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/crash_handler/lib.rs (2)

2950-2955: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Do not include raw register contents in default crash reports.

Line 2953 serializes every captured GP register into a trace string that is printed and reported. Registers can retain secrets, user data, and ASLR-sensitive pointers, contradicting the surrounding “redacted crash report” behavior. Make full register capture explicitly opt-in and emit the empty context block by default.

🤖 Prompt for 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.

In `@src/crash_handler/lib.rs` around lines 2950 - 2955, Change the crash-report
serialization around StackLine::write_encoded so captured register values are
emitted only when full register capture is explicitly enabled. By default, write
the empty context block instead of iterating over regs.values(), preserving the
existing trace format while preventing raw register contents from entering
redacted reports.

868-871: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep changed comments to three lines or fewer.

  • src/crash_handler/lib.rs#L868-L871: condense the FaultRegisters overview.
  • src/crash_handler/lib.rs#L920-L923: condense the testing-constructor rationale.
  • src/crash_handler/lib.rs#L940-L943: condense the borrowed-register lifetime rationale.
  • src/crash_handler/lib.rs#L1703-L1707: condense the POSIX context-extraction description.

As per coding guidelines, “Keep code comments to three lines or fewer.”

🤖 Prompt for 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.

In `@src/crash_handler/lib.rs` around lines 868 - 871, Condense the comments for
FaultRegisters and the related testing-constructor, borrowed-register lifetime,
and POSIX context-extraction descriptions to no more than three lines each.
Apply the comment-only changes at src/crash_handler/lib.rs lines 868-871,
920-923, 940-943, and 1703-1707, preserving their existing meaning.

Source: Coding guidelines

🤖 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.

Outside diff comments:
In `@src/crash_handler/lib.rs`:
- Around line 2950-2955: Change the crash-report serialization around
StackLine::write_encoded so captured register values are emitted only when full
register capture is explicitly enabled. By default, write the empty context
block instead of iterating over regs.values(), preserving the existing trace
format while preventing raw register contents from entering redacted reports.
- Around line 868-871: Condense the comments for FaultRegisters and the related
testing-constructor, borrowed-register lifetime, and POSIX context-extraction
descriptions to no more than three lines each. Apply the comment-only changes at
src/crash_handler/lib.rs lines 868-871, 920-923, 940-943, and 1703-1707,
preserving their existing meaning.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 17222066-a81e-4c39-9cbd-82e51681da03

📥 Commits

Reviewing files that changed from the base of the PR and between f354cdb and fabdf9d.

📒 Files selected for processing (2)
  • src/crash_handler/lib.rs
  • test/cli/run/run-crash-handler.test.ts

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

On the two latest coderabbit findings:

  • Comment length (🟡): done in 374930a; all flagged doc comments tightened to three lines.

  • "Do not include raw register contents in default crash reports" (🟠 security): not changing this without maintainer sign-off. Encoding the register file is the purpose of this PR. The existing trace string already carries ASLR-adjusted instruction addresses from the stack walk and the raw si_addr fault address (two VLQs, unmodified), so ASLR-sensitive pointers are already part of the report today. Uploading is gated by is_reporting_enabled() (off in CI and unless BUN_ENABLE_CRASH_REPORTING=1), same as before. If we do want register capture to be a separate opt-in, the natural hook is FaultRegisters::empty() when an env var is unset; happy to add that if a maintainer wants it.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/crash_handler/lib.rs:2921-2937 — Appending the register block after the reason payload breaks the two variable-length reasons: for '8' (ZigError) the raw err_name has no length prefix and _ is a valid identifier byte, so a v3 decoder cannot locate the name/register-block boundary; for '0' (Panic) a v1 decoder reading base64-to-end now sees <b64>_A and _ is not in the standard base64 alphabet, so the "treat '3' as '1'" doc claim at VERSION_CHAR is false. Length-prefix err_name (and the Panic base64) in v3, or place the register block before the reason byte, before this format ships to bun.report.

    Extended reasoning...

    What

    encode_trace_string appends the new register block (src/crash_handler/lib.rs:2921-2937) immediately after the existing reason payload with no delimiter or length prefix. Two reason codes have variable-length payloads that in v1/v2 were defined as "read to end of body":

    • '0' (CrashReason::Panic, lines 2847-2888): '0' + standard-alphabet base64 of zlib(message), = padding stripped.
    • '8' (CrashReason::ZigError, lines 2913-2915): '8' + err_name raw bytes.

    All other reason codes ('1'..'7', '9') have zero-length or self-terminating (two-VLQ) payloads and are unaffected.

    Why the v1/v2-compat claim is false

    The VERSION_CHAR doc comment (lines 2595-2597) and the PR description both state that a decoder that only knows '1'/'2' "can treat '3' as '1' and stop at the end of the reason payload; everything after it is additive". But '0' and '8' never had an "end of the reason payload" — v1 decoders read to end-of-body (before the optional /view). Feeding them a v3 body:

    • ZigError: js_root_error calls handle_root_error("Unexpected", None) → v3 body ends …8Unexpected_A. A v1 decoder reads the error name as Unexpected_A.
    • Panic: bun_base64::encode uses the simdutf standard alphabet (A-Za-z0-9+/, url_safe=false). v3 body ends …0<b64>_A. _ is not in that alphabet, so a strict v1 decoder rejects the base64 and the panic message is lost; a URL-safe-tolerant one decodes garbage into the zlib stream.

    Why v3 is ambiguous for '8'

    The comment at lines 2921-2923 says the block is "Always present so the decoder does not have to branch on which reason codes carry one". But for '8' a v3 decoder cannot locate the boundary without branching:

    • _ is a valid identifier byte. ErrName is impl'd for &str/&[u8] and handle_root_error accepts any error name; Rust variant names and the existing b"DumpStackTrace" happen not to contain _, but nothing enforces that.
    • If regs were Some, the block would begin with a StackLine (VLQ bytes from A-Za-z0-9+/) — every one of which is also a valid identifier byte. 8SomeErrorBabc… gives no way to tell whether B starts the pc StackLine or continues the error name.

    Today it happens to work only because every ZigError call site (lines 2205, 3271, 3341) passes regs: None, so the suffix is always exactly _A and a decoder could special-case "for reason '8', strip the last two bytes". That directly contradicts the stated design and is not enforced anywhere in the encoder.

    Step-by-step proof

    Take rootError from fixture-crash.js:

    1. js_root_error → handle_root_error("Unexpected", None) → CrashReason::ZigError(b"Unexpected"), regs: None.
    2. encode_trace_string writes …A (frames terminator) + 8 + Unexpected (line 2915).
    3. opts.regs == None → writes _ (StackLine::write_encoded(None)) + A (VLQ::ZERO) (lines 2925-2928).
    4. Body ends …8Unexpected_A.
    5. A v1 decoder for reason '8' reads bytes after '8' until end-of-body → error name = "Unexpected_A". Wrong.
    6. A v3 decoder that follows the "decoder does not have to branch" comment scans for the register block after the reason payload — but has no length for the name and no sentinel outside the identifier charset to stop at.

    For Panic, the same trailing _A follows the base64; _ is outside A-Za-z0-9+/, so v3 can re-delimit it, but v1 cannot, and the doc comment promises it can.

    Impact

    This is a wire-format defect being introduced by this PR. bun.report is the consumer; whatever workaround it adopts ("for '8' strip trailing _A", "for '0' stop base64 at first non-alphabet byte") is baked in for the life of v3/v4 and constrains future changes (e.g., ever passing regs: Some on a ZigError path becomes silently undecodable). REVIEW.md, One source of truth: "any change to cached/serialized output bumps the format version constant" — the version was bumped, but the format itself is not self-delimiting for two of its ten reason codes.

    Fix

    Any of:

    • In v3, write a VLQ length before err_name (and before the Panic base64), matching how the fault-address and register-count fields are already self-delimiting.
    • Move the register block to before the reason byte (immediately after the frames terminator A), so every reason payload is again terminal.
    • Emit an explicit sentinel byte between reason payload and register block that is outside both the standard base64 alphabet and identifier charset (e.g. ~ or !), and drop the v1-compat claim for '0'/'8' from the VERSION_CHAR comment.

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

The latest claude bot 🔴 finding (wire-format ambiguity for reasons '0'/'8') is stale: it quotes the "Always present so the decoder does not have to branch" comment and the unconditional append, both of which were removed in f354cdb. Since that commit the register block is emitted only when opts.reason.is_fault() (reasons '2'..'7'), with debug_assert!(opts.regs.is_none()) on the other path, so Panic/Unreachable/ZigError/OOM are byte-identical to v1 and there is no err_name/base64 boundary to locate. The second outOfMemory test asserts the body ends exactly at '9' with no suffix.

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Closing the loop on the register-privacy finding: register values stay encoded unconditionally. Upload is already gated by the existing telemetry opt-in (is_reporting_enabled(): DO_NOT_TRACK, BUN_ENABLE_CRASH_REPORTING, BUN_CRASH_REPORT_URL), same as for the stack addresses and fault address that v1 already carries. When reporting is disabled the trace string is only printed to the user's own stderr.

Comment thread src/crash_handler/lib.rs Outdated
Comment thread test/cli/run/run-crash-handler.test.ts Outdated
Comment thread src/crash_handler/lib.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No bugs found this run; the earlier nits are all addressed in 263cd47. Deferring to a human for the wire-format bump (bun.report needs a matching v3/v4 decoder) and the register-privacy question you flagged as needing maintainer sign-off.

What was reviewed:

  • Wire-format ambiguity: register block is now gated on is_fault() so reasons '0'/'1'/'8'/'9' stay byte-identical to v1 — verified against encode_trace_string and the OOM test.
  • Signal-handler safety: TraceSeed::Fault borrows the stack-resident FaultRegisters, no allocation on the fault path; buffer bumped to 1536 for the aarch64 33-register tail.
  • FaultRegisters::COUNT/NAMES are tied at compile time via the module-scope const _ assert; per-arch readers assign r.count = COUNT.
  • Windows CONTEXT offsets checked against winnt.h layout (x64 Rax@0x78..Rip@0xF8; arm64 X[0]@0x8, Sp@0x100, Pc@0x108) — no fabricated-CONTEXT unit test yet, as the author noted.
Extended reasoning...

Overview

This PR extends the crash handler to capture the full GP register file from ucontext_t (POSIX) / CONTEXT (Windows) at fault time and encode it into the trace string, bumping the wire format from v1/v2 to v3/v4. Five files touched: src/crash_handler/lib.rs (~250 lines net), a new segfaultWithRegisters internal-for-testing hook in crash_handler_jsc.rs + internal-for-testing.ts, and two new tests in run-crash-handler.test.ts.

Security risks

The register file can contain live pointers (ASLR-sensitive) and, in principle, secret material resident in registers at the fault. The author's position is that v1 already encodes ASLR-adjusted stack addresses and the raw si_addr, and upload is already gated by is_reporting_enabled() (DO_NOT_TRACK, BUN_ENABLE_CRASH_REPORTING). That's reasonable, but the author explicitly said "not changing this without maintainer sign-off" and offered an env-var opt-out — that's a policy call for a maintainer, not a bot.

Level of scrutiny

High. This is signal-handler code (async-signal-safe constraints), it changes a wire format consumed by an external service (bun.report must ship a matching decoder or v3/v4 traces are opaque), and it spans six cfg-gated arch/OS arms including hand-coded winnt.h byte offsets that the compiler cannot check. The Windows path in particular has no unit test (author deferred that as a follow-up). The 1024→1536 trace buffer bump looks sufficient for aarch64's worst case (33 regs × ~14 VLQ bytes ≈ 462 bytes on top of the existing payload), and the encoder writes through a bounded writer that fails gracefully on overflow rather than corrupting.

Other factors

All prior review threads (mine and coderabbit's) are resolved. The encoding path is exercised deterministically via the new segfaultWithRegisters hook so it works under ASAN. cargo check on all 10 targets and clippy are reported clean. But the combination of external-service coordination, an open maintainer question, and untested hand-coded Windows offsets puts this well outside "simple/mechanical" — a human should sign off on the format bump and the privacy default.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The three nits from my earlier pass are all addressed in 263cd47. I didn't find further issues, but this needs a maintainer's eyes: it bumps the trace-string wire format (bun.report decoder coordination), hand-codes winnt.h CONTEXT byte offsets without a unit test, and the author explicitly deferred the register-privacy opt-in question to maintainer sign-off.

What was reviewed:

  • Wire-format compatibility — register block now gated on is_fault() so '0'/'1'/'8'/'9' payloads are byte-identical to v1; verified against encode_trace_string.
  • Signal-handler safety — TraceSeed::Fault borrows a stack-resident FaultRegisters, no allocation in the handler; buffer bumped 1024→1536 to fit 33×2-VLQ regs.
  • NAMES/COUNT/MAX are now tied by a module-scope const _ assert; per-arch readers assign r.count = COUNT.
  • Test's JS vlq/u64AsTwoVlqs matches write_u64_as_two_vlqs (hi-then-lo, u32-as-i32 bit reinterpretation).
Extended reasoning...

Overview

Adds a FaultRegisters struct capturing pc, fp, and the full GP register file from ucontext_t (Linux/Android/macOS × x64/aarch64) and the Windows CONTEXT record, threads it through TraceSeed::Fault into encode_trace_string, and appends it as a new v3/v4 trace-string block after the reason payload for hardware-fault reasons '2'..'7'. Bumps VERSION_CHAR from '1'/'2' to '3'/'4'. Adds a segfaultWithRegisters internal-for-testing hook and two tests in run-crash-handler.test.ts. ~350 net-new lines across 5 files.

Security risks

The register file can contain live pointers (ASLR-revealing) and occasionally secret material that happened to be in a register at fault time. The author's position is that the existing v1 trace already carries ASLR-adjusted stack addresses and the raw si_addr, and upload is gated by the same is_reporting_enabled() opt-in — but they explicitly said they won't add a separate register opt-in without maintainer sign-off. That is an open design decision a human needs to make.

Level of scrutiny

High. This is signal-handler code where a secondary panic is catastrophic, it changes a wire format consumed by an external service (bun.report), and it adds six cfg-gated per-platform register readers — the Windows arms read raw byte offsets into an opaque CONTEXT blob with no compile-time check and no unit test (author noted this as a reasonable follow-up). None of that is auto-approvable.

Other factors

  • All three of my prior 🟡 findings (inert associated-const assert, 5-line doc comment, vacuous endsWith("_A") assertion) are fixed in 263cd47; the current diff reflects those fixes.
  • The earlier 🔴 wire-format ambiguity for reasons '0'/'8' was fixed in f354cdb by gating the block on is_fault(); I re-verified the encoder branch and the outOfMemory test asserts the body ends exactly at '9'.
  • cargo check reportedly passes on all 10 targets and CI build #76268 is running; the PR body notes local test proof was deferred to CI as platform-specific.
  • The trace_str_buf bump to 1536 bytes covers the worst case (aarch64: 33 regs × ~14 VLQ chars ≈ 462 bytes plus pc StackLine).

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Manual verification on all targets

Triggered a real segfault (*(volatile u64*)0xDEADBEEF = 0xDEADBEEF via bun:internal-for-testing's segfault() on non-ASAN builds) on each platform using the CI-built binaries from build #76268 (or an equivalent local release build where S3 was blocked), then decoded the v3 register block from the emitted trace string.

target host version_char reg_count sentinel reg rip/pc notes
linux x64 workspace 4 17 rax=0xDEADBEEF 0x2f864f5 r14/r15 = JSC NaN-box tags
macos x64 darwin-cornbread-x64 (14.5) 4 17 rax=0xDEADBEEF 0x104d70f45 r14/r15 = JSC NaN-box tags
macos arm64 darwin-sourdough-arm64 (15.1) 4 33 x8=0xDEADBEEF 0x105477488 x27/x28 = JSC NaN-box tags; lr/sp/fp sane
windows x64 azure dev vm (2019) 4 17 rax/rcx=0xDEADBEEF 0x7ff7518a416d rip - r10(module base) == pc_stackline
windows arm64 azure dev vm (11) 4 33 x1/x8=0xDEADBEEF 0x7ff63e14e6e8 lr = pc-0xC

On every target the decoder consumed the entire payload (leftover: ""), the register count matches FaultRegisters::COUNT for the arch, and the values look like real CPU state: the sentinel 0xDEADBEEF lands in the expected scratch register, rbp/rsp/fp/sp point into the thread stack, and r14/r15 (x64) / x27/x28 (arm64) hold JSC's NaN-boxing constants 0xfffe000000000000/0xfffe000000000002, confirming the slot ordering matches NAMES.

Linux x64 decoder output
version_char: 4
reason: 2
fault_addr: 0xDEADBEEF
pc_stackline: 0x2f864f4
reg_count: 17
regs:
  [0] 0x00000000deadbeef
  [1] 0x000005eb3e780000
  [2] 0x0000737c5c8663c1
  [3] 0x00007fffaa383300
  [4] 0x0000000000000000
  [5] 0x0000000000000004
  [6] 0x00007fffaa383310
  [7] 0x00007fffaa383300
  [8] 0x0000000000000000
  [9] 0x00000000ffffffff
  [10] 0x0000000000000000
  [11] 0x0000000000000246
  [12] 0x000005eb3dff0e40
  [13] 0x000005eb3efe1e80
  [14] 0xfffe000000000000
  [15] 0xfffe000000000002
  [16] 0x0000000002f864f5
leftover: ""
macOS arm64 decoder output
version_char: 4
reason: 2
fault_addr: 0xDEADBEEF
pc_stackline: 0x1063487
reg_count: 33
regs:
  [0] 0x0000000000000000
  [1] 0x0000000000000000
  [2] 0x0000039e4265ddf0
  [3] 0x0000039e423c2888
  [4] 0x0000000000000029
  [5] 0x00000001084a4000
  [6] 0x000000016b9e8c08
  [7] 0x000000016b9e8de8
  [8] 0x00000000deadbeef
  [9] 0x0000000000000000
  [10] 0x00000000ffffffff
  [11] 0x0000000000000001
  [12] 0x0300039e42ef4320
  [13] 0x0000000000000000
  [14] 0x0000039e42ef4320
  [15] 0x0000000000000040
  [16] 0x00000000000000c2
  [17] 0x0000039e42680020
  [18] 0x0000000000000000
  [19] 0x0000039e42680000
  [20] 0x0000000000000000
  [21] 0x0000039e42321350
  [22] 0x000000016b9e9af8
  [23] 0x0000000000000002
  [24] 0x0000039e42301c08
  [25] 0x0000039e425b0c80
  [26] 0x0000039e42336dc0
  [27] 0xfffe000000000000
  [28] 0xfffe000000000002
  [29] 0x000000016b9e9820
  [30] 0x0000000105477464
  [31] 0x000000016b9e9810
  [32] 0x0000000105477488
leftover: ""
Windows arm64 decoder output
version_char: 4
reason: 2
fault_addr: 0xDEADBEEF
pc_stackline: 0x444e6e8
reg_count: 33
regs:
  [0] 0x0000000000000001
  [1] 0x00000000deadbeef
  [2] 0x0000000000000001
  [3] 0x0000000000000008
  [4] 0x0000000000000008
  [5] 0x00007ff6435196e0
  [6] 0x0000000000000008
  [7] 0x000000e471d7ff40
  [8] 0x00000000deadbeef
  [9] 0x00000000deadbeef
  [10] 0x0000000000000008
  [11] 0x00007ff645f80500
  [12] 0x0000000000000000
  [13] 0x0101010101010100
  [14] 0x8080808080808080
  [15] 0xfff8fff8fffffeff
  [16] 0x9f98a1988d9f8e9d
  [17] 0x8d96a18e9b949c92
  [18] 0x0000325c433d6ba5
  [19] 0x0000000000000000
  ...
  [27] 0xfffe000000000000
  [28] 0xfffe000000000002
  [29] 0x000000e471d7fe00
  [30] 0x00007ff63e14e6dc
  [31] 0x000000e471d7fdd0
  [32] 0x00007ff63e14e6e8
leftover: ""

@robobun robobun changed the title crash_handler: encode fault pc + GP registers from ucontext into the trace string (v3/v4) crash_handler: encode fault pc + GP registers from ucontext into the trace string (v3) Jul 21, 2026
@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

CI at 6e6e3a9: the only failure is Failed to create agent for :windows: 11 aarch64 - test-bun (Azure VM provisioning, unrelated to this diff). All other lanes passed including run-crash-handler.test.ts on every target. The previous full green run was build #76268 on 33c2410; since then only the version-char collapse ('3'/'4' -> '3' + build-flags VLQ) landed, which I verified locally on linux-x64 release and all 12 crash-handler tests pass.

Decoder PR: oven-sh/bun.report#28.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No new issues found, but this should get a human look: it bumps the trace-string wire format ('1'/'2' → '3' + build-flags VLQ + register block) which needs bun.report decoder coordination, and the author explicitly deferred the register-privacy opt-in question to a maintainer. The Windows path also hand-codes winnt.h CONTEXT byte offsets in unsafe code.

What was reviewed:

  • Wire-format ambiguity for reasons '0'/'8' — fixed; register block is now gated on is_fault() and non-fault encodings end at the reason byte.
  • Signal-handler safety — TraceSeed::Fault borrows the stack-local FaultRegisters (no allocation); the local outlives the borrow on both POSIX and Windows call sites.
  • NAMES.len() <= MAX assert hoisted to module scope so it actually evaluates; COUNT = NAMES.len() ties every reader's slot count to the table.
  • Note: the manual all-targets verification table was captured before 6e6e3a9 (shows version_char: 4), so the collapsed '3' + build-flags layout hasn't been end-to-end verified on real faults yet — the automated test does cover it.
Extended reasoning...

Overview

This PR extends the crash-handler trace string to carry the faulting pc and general-purpose register file for hardware faults. It touches src/crash_handler/lib.rs (~350 lines: new FaultRegisters struct, per-target ucontext_t/CONTEXT readers for linux/android/macos/windows × x64/aarch64, encoder changes, version bump to '3' with a new build-flags VLQ), a new segfaultWithRegisters internal-for-testing hook in crash_handler_jsc.rs, and two new tests in run-crash-handler.test.ts.

Security risks

Register values at a fault can contain heap/stack pointers and occasionally data-derived values. CodeRabbit flagged this and the author declined to gate it behind a separate opt-in without maintainer sign-off, arguing the existing v1 format already carries ASLR-sensitive stack addresses and the raw si_addr, and upload is already gated by is_reporting_enabled(). That reasoning is sound but the decision was explicitly punted to a maintainer, so it should not be auto-approved.

Level of scrutiny

High. This is (a) a wire-format change to a string consumed by an external service (bun.report) — the decoder side is not in this repo and must be updated in lockstep; (b) unsafe code inside a signal handler across six platform/arch combos, with hand-coded winnt.h byte offsets on Windows (the author acknowledged a fabricated-CONTEXT unit test would be a reasonable follow-up); and (c) the most recent commit (6e6e3a9) is a design change — collapsing the '1'/'2' canary split into a build-flags VLQ — that adds a byte to every trace string, not just fault ones, and postdates the author's manual all-targets verification table.

Other factors

All prior inline findings from earlier runs (dead NAMES, inert associated-const assert, vacuous test assertion, 5-line doc comment, wire-format ambiguity for '0'/'8') have been addressed and the threads are resolved. The bug-hunting pass on the current head found nothing new. Tests look solid: the encoder is exercised deterministically via for_testing, pipes are drained concurrently, and the non-fault case asserts no trailing block. But given the external-decoder coordination requirement and the explicitly-deferred privacy question, a maintainer should be the one to approve.

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Build #76508 update: the darwin-14-x64 lane had a bad run. Three tests timed out there, including the pre-existing automatic crash reporter > panic should report in run-crash-handler.test.ts. That test's failure is curl: (28) Failed to connect to localhost port 62447 after 26127 ms (the child couldn't reach the test's own Bun.serve({port:0})), and two unrelated test/bake/ suites timed out on the same lane in the same run. The crash-handler encoder output visible in the log is correct for the new format (m_36e6e3a9C... = platform m, version 3, sha 6e6e3a9, build-flags C=canary, then features/frames/reason/regs).

The two trace string v3 tests this PR adds passed on every lane. run-crash-handler.test.ts was green on every other target (linux x64/aarch64/musl, macos aarch64, windows x64/baseline). The windows-11-aarch64 lane is still the Azure agent-provisioning failure from earlier.

Re-roll already spent; needs a maintainer to re-run the darwin-14-x64 job or merge past it.

Comment thread src/crash_handler/lib.rs Outdated
Comment thread src/crash_handler/lib.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No bugs found this pass, but this defines a new crash-report wire format (v3) coordinated with bun.report#28 and reads raw CONTEXT/ucontext register state inside the signal handler across six OS/arch arms — worth a maintainer's eyes on the format bump and the register-privacy question the author flagged for sign-off.

What was reviewed:

  • Encoder: register block only appended for is_fault() reasons '2'..'7'; '0'/'1'/'8'/'9' unchanged, so v1 payloads for Panic/ZigError/OOM stay end-of-string terminated.
  • Signal-handler safety: FaultRegisters is stack-local on the altstack and borrowed via TraceSeed::Fault(&regs), no allocation; trace_str_buf bumped 1024→1536 covers the worst-case aarch64 33×2-VLQ tail.
  • Per-arch readers: linux/macOS ucontext field access matches libc struct layouts; Windows x64/arm64 byte offsets match winnt.h _CONTEXT/_ARM64_NT_CONTEXT; all readers set r.count = COUNT and the module-level NAMES.len() <= MAX assert is live.
  • Prior 🟡 findings (inert _CHECK const, vacuous endsWith("_A"), NAMES doc overclaim, 0x80000000 VLQ collapse) all addressed in 263cd47/694156b3 or resolved decoder-side.
Extended reasoning...

Overview

Adds GP register capture to Bun's crash-report trace string. src/crash_handler/lib.rs gains a FaultRegisters struct plus per-platform readers (fault_context_from_ucontext on linux/android/macOS × x64/aarch64, new fault_context_from_windows_context on Windows x64/arm64) that lift the register file from the kernel-supplied context, and encode_trace_string appends a StackLine-encoded pc + VLQ count + count×2-VLQ register block after fault-reason payloads. VERSION_CHAR moves from '1'/'2' to a single '3' with a new one-byte build-flags VLQ (bit0 = canary) after the sha. A segfaultWithRegisters internal-for-testing hook and two new tests exercise the encoder deterministically. ~300 net lines, mostly cfg-gated unsafe reads.

Security risks

Register values can carry pointers, secrets held in GPRs, and ASLR-revealing addresses. The author's position (timeline, 12:18 UTC) is that upload is already gated by is_reporting_enabled() and that v1 already carries ASLR-adjusted stack addresses plus the raw si_addr, so this is not a new class of exposure — but they explicitly deferred the "should register capture be a separate opt-in" question to a maintainer. That's a design call, not a bug. No injection/auth surface; the data flows outward only.

Level of scrutiny

High. This code runs inside a fatal-signal handler where a secondary fault loses the original crash entirely; it does raw pointer reads at hardcoded byte offsets on Windows; and it's a versioned wire format shared with an external decoder repo, so mistakes are sticky. It also changes what every existing crash report looks like (version char '3', extra build-flags byte), which the pre-existing "automatic crash reporter" tests would catch if broken — they still pass per the CI notes.

Other factors

The PR has been through several bot review rounds; every prior inline finding is resolved. Manual verification tables in the thread show real-fault decodes on all five shipped targets with sane-looking register state (JSC NaN-box tags in the expected slots, sentinel in the right scratch reg). CI is green except for known-flaky infra (darwin-14-x64 localhost connect timeout, win-arm64 agent provisioning). The paired decoder is in a separate PR that a maintainer will need to land in lockstep. Given the format bump, cross-repo coupling, and the open privacy opt-in question, this should be human-reviewed rather than bot-approved.

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Build #76686 (694156b): run-crash-handler.test.ts passed on every lane. The one hard failure is test/js/bun/http/bun-server.test.ts on darwin-14-aarch64 (unrelated to this diff, reported to main-break triage); everything else is [flaky] and passed on retry. Diff is green; ready to merge after oven-sh/bun.report#28 lands.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Adversarial review findings addressed in f005bdc:

  • Bumped bun-tracestrings pin to oven-sh/bun.report#1a48fb0 (includes the v3 decoder from parser: replace unused v3 with the fault-register trace-string format bun.report#28). Without this, scripts/runner.node.mjs's ci-remap-server (which sets BUN_CRASH_REPORT_URL for every CI test) would report "Trace string failed to parse" for every crash once VERSION_CHAR is '3'.
  • Added a non-ASAN real-reader test (.skipIf(isASAN)) that triggers a real *0xDEADBEEF write and decodes the register block produced by the actual ucontext_t/CONTEXT reader: asserts count matches the host arch's FaultRegisters::COUNT (17/33), 0xDEADBEEF appears in at least one GP register, rip/pc is non-zero, and no bytes trail the block. Runs on all release CI lanes across the six cfg-gated reader bodies.
  • Tightened the build-flags assertion to the exact byte for the running build (is_canary ? 'C' : 'A' via crash_handler.getFeatureData().is_canary) instead of accepting either.

One finding left as-is: the pc StackLine in the register block is byte-identical to frame 0 (both come from StackLine::from_address(regs.pc)). True, but oven-sh/bun.report#28 is already merged expecting it and the cost is ~6 bytes on fault reasons only. Can drop in a follow-up if wanted.

Comment thread src/crash_handler/lib.rs
Comment thread src/js/internal-for-testing.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No new findings — the two nits from the last pass (stale v1/v2 in the GIT_SHA comment, and the getFeatureData type gap) are both addressed in e33ded3. Deferring to a maintainer for final sign-off: this is a wire-format bump (VERSION_CHAR '1'/'2' → '3' + build-flags VLQ) that must land in lockstep with oven-sh/bun.report#28, adds ~200 lines of unsafe per-arch ucontext_t/CONTEXT register reads inside the signal handler across six cfg-gated targets, and pulls ~60 new transitive dev-deps into bun.lock via the bun-tracestrings pin bump.

What was reviewed:

  • v3 encoding: register block gated on is_fault() only ('2'..'7'), so '0'/'8' end-of-string payloads stay unambiguous; debug_assert!(regs.is_none()) on non-fault paths; trace_str_buf grown 1024→1536 to fit 33 aarch64 regs.
  • FaultRegisters lifetime: borrowed on the handler's stack (no allocation in signal context); TraceSeed::Fault(&regs) outlives the crash_handler call at every call site.
  • Windows CONTEXT byte-offset reads use read_unaligned; offsets match the winnt.h layout the comment cites and were verified end-to-end on both win-x64 and win-arm64 per the manual-verification table.
  • i32::MIN VLQ collapse for register halves confirmed lossless on the bun.report decoder side (round-trip test added there).
Extended reasoning...

Overview

The PR extends Bun's crash-report trace string to v3: a single '3' version char replaces the '1'/'2' release/canary pair (canary moves into a new one-byte BUILD_FLAGS VLQ after the sha), and hardware-fault reasons ('2'..'7') gain a trailing register block — one StackLine-encoded fault pc, a VLQ count, then count × two-VLQ GP register values. FaultRegisters is populated from ucontext_t (linux/android/macOS × x64/aarch64) and Windows CONTEXT (x64/arm64, via documented winnt.h byte offsets). TraceSeed::Fault now borrows &FaultRegisters so nothing allocates inside the signal handler. A new segfaultWithRegisters internal-for-testing hook, three new tests in run-crash-handler.test.ts, and a bun-tracestrings pin bump (to the commit carrying the v3 decoder) round it out. Seven files, ~+560/-30 lines plus lockfile churn.

Security risks

Register values are process-local CPU state and are appended only for hardware faults; upload remains gated by the existing is_reporting_enabled() telemetry opt-in (DO_NOT_TRACK, BUN_ENABLE_CRASH_REPORTING, BUN_CRASH_REPORT_URL) — the same gate that already covers stack addresses and the fault si_addr. No new external inputs are parsed. The unsafe blocks read kernel-provided ucontext_t/CONTEXT at documented offsets and are guarded by null checks where the pointer can be null (macOS uc_mcontext, Windows ContextRecord). No injection, auth, or data-exposure surface beyond what v1 already carried.

Level of scrutiny

High. The crash handler runs in async-signal context on the faulting thread's altstack; a mistake here means either a secondary crash that loses the report entirely or a corrupted trace string. The change also bumps a wire format that an external service (bun.report) decodes, so merge ordering with oven-sh/bun.report#28 matters — the author explicitly noted "ready to merge after oven-sh/bun.report#28 lands" and the CI ci-remap-server depends on the pinned decoder. Six cfg-gated platform bodies mean five of them are invisible to any single-target cargo check; the author ran cargo check on all ten targets and manually verified real segfaults on five, but that's exactly the kind of cross-platform unsafe surface a maintainer should eyeball.

Other factors

This PR has been through five rounds of review (coderabbit + three of mine), all resolved: the inert _CHECK associated const → module-level const _, vacuous endsWith("_A") assertion dropped, 5-line struct doc trimmed and de-duplicated against NAMES, the overstated "fails at compile time" claim on NAMES softened, the i32::MIN VLQ collapse confirmed recoverable on the decoder side with a round-trip test in bun.report#28, the stale v1/v2 GIT_SHA comment updated, and getFeatureData/getFeaturesAsVLQ typed in internal-for-testing.ts. Test coverage is solid — a deterministic sentinel-register test that runs everywhere including ASAN, a real-fault reader test skipped under ASAN, and a negative test that non-fault reasons carry no register block. CI was green on run-crash-handler.test.ts across all lanes on the last full run. The bun.lock churn (~60 new transitive packages from archiver/tar in the bumped bun-tracestrings) is dev-only but worth a maintainer glance. Given the wire-format design decision, the coordinated external-repo dependency, and the volume of platform-gated unsafe, this should get a human sign-off rather than an auto-approve.

robobun and others added 14 commits July 22, 2026 20:06
… trace string

The signal/exception handler already reads pc/fp from ucontext_t (POSIX)
and ExceptionAddress (Windows) to seed the frame-pointer walk, but none
of that reached the trace string. For hardware faults the register state
is often the only way to recover 'this'/arguments when the stack walk is
short or corrupt.

- FaultRegisters holds pc, fp, and the full GP register file in a fixed
  per-arch slot order (x86_64: rax..r15,rip; aarch64: x0..x28,fp,lr,sp,pc).
  fault_context_from_ucontext now fills it for linux/android/macos on
  both arches; fault_context_from_windows_context reads CONTEXT by the
  documented winnt.h offsets on x64/arm64.
- encode_trace_string appends a register block after the existing reason
  payload: one StackLine-encoded fault pc (ASLR removed), a VLQ count,
  then count * two-VLQ register values. When there is no fault context
  the block is '_A' (no pc, zero registers).
- VERSION_CHAR bumped to '3' (release) / '4' (canary). '3'/'4' is a
  strict superset of '1'/'2' with the register block appended, so a
  decoder that only knows '1'/'2' can parse the prefix and ignore the
  tail; a decoder that knows '3'/'4' reads both old and new strings.
- internal-for-testing gains segfaultWithRegisters so the encoding path
  can be exercised deterministically under ASAN (where our SIGSEGV
  handler is not installed).
Addresses clippy::large_enum_variant / clippy::large_types_passed_by_value.
The register file lives on the signal handler's (alt)stack; boxing it would
call malloc from inside a signal handler, so borrow instead.
The register block was appended for every reason, but Panic ('0') and
ZigError ('8') have end-of-string-terminated payloads (base64 / raw
identifier) so a trailing '_A' made them ambiguous: '_' and 'A' are
valid identifier characters, and '_' shifts the base64 length.

Only emit the block for reasons '2'..'7' (segfault/ill/bus/fpe/
misalign/stackoverflow), the only variants that ever arrive via a
signal/exception handler and whose payloads are self-delimiting.
Reasons '0'/'1'/'8'/'9' are now byte-identical to v1/v2.

Also: make FaultRegisters::NAMES the source of truth for the per-arch
register count (COUNT = NAMES.len()) so the ucontext/CONTEXT readers
and the name table cannot drift.
Test now computes the exact VLQ encoding of the synthetic register
values and asserts the payload ends with it, and locates the segfault
reason by its exact two-VLQ encoding rather than a substring scan.

Also condenses four new comments to three lines per the coding
guideline.
…t doc/assert

- const _: () = assert!(..) at module scope is always evaluated; the
  associated-const version was lazy and never fired.
- FaultRegisters doc points to NAMES for slot order instead of
  duplicating it inline.
- drop vacuous endsWith("_A") assertion already implied by
  endsWith("A9").
…lags VLQ

Drop the '3'/'4' split (which mirrored '1'/'2') and emit a single
version char '3'. The canary bit moves into a one-byte build-flags VLQ
written right after the 7-char sha and before the packed-features VLQs
(bit 0 = canary). Future build-variant flags can pack into the same VLQ
without burning another version char.

Format (v3): <ver>/<platform><cmd>3<sha7><build_flags vlq>
             <features 2-vlq><frames><A><reason>[<reg block>]

Register block emission and slot order are unchanged.
r.count = COUNT ties the encoded count to NAMES.len(), but the per-reader
slot-write bodies are not length-checked against NAMES; only the
module-scope NAMES.len() <= MAX is a compile-time guard.
- Bump bun-tracestrings to bun.report#1a48fb0 (includes the v3 decoder
  from oven-sh/bun.report#28) so scripts/runner.node.mjs's ci-remap-server
  can parse v3 trace strings.
- Add a .skipIf(isASAN) test that triggers a real *0xDEADBEEF write and
  decodes the register block from the actual ucontext/CONTEXT reader:
  asserts count matches the host arch's FaultRegisters::COUNT, 0xDEADBEEF
  appears in at least one GP register, rip/pc is non-zero, and no bytes
  trail the block.
- Tighten the build_flags byte assertion to the exact value for the
  running build (via crash_handler.getFeatureData().is_canary) instead of
  accepting either 'A' or 'C'.
@robobun
robobun force-pushed the farm/c161bf75/crash-handler-ucontext-registers branch from 54b118f to 6ab1d4a Compare July 22, 2026 20:11
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Build #78089 (6ab1d4a, rebased onto main): run-crash-handler.test.ts passed on every lane including the new non-ASAN real-reader test. binary-size passed after the rebase. Only remaining failures are two [pre-existing] node-compat tests on ubuntu-25.04-x64 (test-net-connect-memleak.js, test-gc-http-client-connaborted.js, both also red on main) plus unrelated flakes. Diff is green; ready to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants