worker: fix cross-thread HandleSet race in getHeapSnapshot#30185
Conversation
…etHeapSnapshot worker.getHeapSnapshot() captured a Strong<JSPromise> (allocated in the parent VM's HandleSet) by value in a lambda that runs on the worker thread. Strong<T> has no move constructor, so on the worker thread the inner lambda's capture copy-constructs a new HandleSet slot and the outer lambda's destruction deallocates one — both mutating the parent VM's HandleSet::m_strongList (a non-thread-safe SentinelLinkedList) without the parent VM's lock. When the parent VM's GC runs the "Sh" (Strong Handles) marking constraint concurrently, visitStrongHandles iterates m_strongList while the worker thread is mid-push/remove; it follows a transiently-null next pointer and faults reading HandleNode::m_value at (nullptr + 0x10). Heap-allocate the Strong once on the parent thread and pass only the raw pointer across. The worker thread never dereferences it; the parent-side completion lambda resolves the promise and frees the handle. Make postTaskToWorkerGlobalScope return whether the task was accepted so a lost race to Closing rejects cleanly instead of leaking the handle.
|
Updated 10:56 AM PT - May 3rd, 2026
❌ @robobun, your commit 5659245 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 30185That installs a local version of the PR into your bun-30185 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughWorker heap-snapshot promise lifetime now uses a heap-allocated Strong passed across threads; ChangesWorker Heap Snapshot GC Safety
Linux Build Unwind Section Stripping
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/js/node/worker_threads/worker_threads.test.ts`:
- Around line 504-507: Replace the three separate assertions with a single
crash-oriented assertion that preserves all subprocess metadata: after awaiting
Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited,
proc.signalCode]) (or reading proc.signalCode after exit), build an object
containing stdout, stderr, exitCode and signalCode and assert them together
(e.g. expect({ stdout, stderr, exitCode, signalCode }).toEqual({ stdout: "ok\n",
stderr: "", exitCode: 0, signalCode: null })). Update references to proc,
proc.stdout.text(), proc.stderr.text(), proc.exited and proc.signalCode in the
test to ensure stdout/stderr and exit/signal are asserted in one combined
expectation.
🪄 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: 7724ae22-e5c7-465d-b66e-fdbd7755aa7a
📒 Files selected for processing (5)
src/bun.js/bindings/webcore/JSWorker.cppsrc/bun.js/bindings/webcore/Worker.cppsrc/bun.js/bindings/webcore/Worker.htest/js/node/worker_threads/heap-snapshot-gc-race-fixture.jstest/js/node/worker_threads/worker_threads.test.ts
There was a problem hiding this comment.
I didn't find any issues, but this is subtle cross-thread GC/HandleSet code with manual raw-pointer ownership transfer across VMs, so it's worth a human pass.
Extended reasoning...
Overview
This PR fixes an intermittent segfault in worker.getHeapSnapshot() by changing how the parent-VM Strong<JSPromise> is kept alive across the parent→worker→parent round-trip. Instead of capturing the Strong by value in cross-thread lambdas (which copy-constructs/destroys it on the worker thread and races the parent VM's HandleSet), it heap-allocates the Strong once on the parent thread and threads only a raw pointer through the lambdas. Worker::postTaskToWorkerGlobalScope is changed from void to bool so the caller can clean up on a lost race to Closing/Closed. A stress fixture and test are added.
Security risks
None identified. No untrusted input parsing, auth, or crypto. The intentional leak when postTaskTo(parentId, …) fails on the return trip is bounded to parent-VM teardown and is well-justified (deleting a parent-VM Strong from the worker thread is the bug being fixed).
Level of scrutiny
High. This is concurrency code at the JSC GC boundary: it relies on non-obvious invariants (Strong<T> has no move ctor; HandleSet::m_strongList is a non-thread-safe SentinelLinkedList; lambda capture lifetimes across ScriptExecutionContext::postTaskTo). The fix uses raw new/delete with ownership handed across threads via raw pointer, which is correct here but warrants a second set of eyes from someone familiar with WebKit/JSC conventions — e.g., whether a different pattern (holding the VM lock, or a thread-safe promise wrapper) would be preferred.
Other factors
- I verified
postTaskToWorkerGlobalScopehas no other callers, so thevoid→boolchange is safe. - The
Pending-state path (task queued inm_pendingTasks, worker terminated beforeRunning) would leakpromiseHandlewhen the deque is destroyed, but that's a pre-existing edge case where the promise hung forever anyway, and destruction happens on the parent thread. - The root-cause analysis is thorough and the 40-run stress verification (15/40 → 0/40) is convincing.
- No prior reviews on the timeline; bug-hunting system found nothing.
…rip on LTO The non-LTO release strip step was removing .eh_frame_hdr while the linker (which only passes --no-eh-frame-hdr under LTO) had already emitted a PT_GNU_EH_FRAME phdr pointing at it. GNU strip does not rewrite the program header table, so the phdr was left pointing at unmapped memory and any stack unwind during worker teardown faulted. Gate the section removal on c.lto to match the linker flag. Move the stress test out of worker_threads.test.ts so it runs in isolation, and group the subprocess result into one assertion so a crash shows stdout/stderr/exitCode/signalCode together.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/js/node/worker_threads/worker_heap_snapshot_gc.test.ts`:
- Around line 25-32: The test unconditionally asserts stdout/stderr alongside
exitCode which makes crash failures noisy; change the assertion after awaiting
Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]) to branch on
exitCode: if exitCode === 0 then assert the full success contract (stdout ===
"ok\n" and stderr === "" and exitCode === 0 and signalCode === null), otherwise
assert only exitCode and proc.signalCode (still including stdout/stderr in the
thrown expectation so they appear in the failure diff) — update the existing
expect that compares { stdout, stderr, exitCode, signalCode: proc.signalCode }
to perform this conditional assertion logic.
🪄 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: b62e2bd9-9de1-447f-b50a-68753a232ef6
📒 Files selected for processing (1)
test/js/node/worker_threads/worker_heap_snapshot_gc.test.ts
There was a problem hiding this comment.
I didn't find any bugs, but this touches cross-thread JSC Strong handle lifetime with manual new/delete and an intentional-leak path, plus a drive-by build-flags change — worth a human look (and CI build is currently red on 125dcec).
Extended reasoning...
Overview
This PR fixes a cross-thread data race in worker.getHeapSnapshot() where a JSC::Strong<JSPromise> belonging to the parent VM was copy-constructed/destroyed on the worker thread, mutating the parent VM's HandleSet::m_strongList without the heap lock while the parent's GC was iterating it. The fix heap-allocates the Strong once on the parent thread and passes only a raw pointer through the cross-thread lambdas; postTaskToWorkerGlobalScope now returns bool so a lost race to Closing/Closed cleans up on the parent thread. It also adds a stress-test fixture + test, and includes a drive-by gating of .eh_frame strip flags on c.lto in scripts/build/flags.ts.
Security risks
No new attack surface — this is internal lifetime management. The main risk class is memory safety: raw new/delete of a Strong<JSPromise>, an explicitly documented intentional leak when the parent context is gone on the return trip, and correctness of the accepted == false cleanup path. None of these are security-sensitive in the auth/crypto sense, but they are crash-safety-sensitive.
Level of scrutiny
High. This is concurrency + GC-adjacent C++ with manual memory management and subtle ownership transfer across threads. The reasoning in the PR description is thorough and the fix looks correct to me (the worker thread now only carries an opaque pointer; allocation and destruction both happen on the parent thread), but this is exactly the category of change that benefits from a second pair of human eyes — particularly the State::Pending queue path (task is moved into m_pendingTasks and later run on the worker thread, which is fine since the lambda only captures the raw pointer) and the intentional-leak rationale.
Other factors
- CI build #50612 against 125dcec shows widespread build-zig/build-cpp failures; unclear if related to this change or infra, but it should be green before merge.
- The
scripts/build/flags.tschange is a separate drive-by fix (orphanPT_GNU_EH_FRAMEon non-LTO local builds) bundled into this PR — small and well-explained, but adds scope. - One outstanding coderabbit nitpick on the test assertion shape is purely stylistic and not blocking.
A single 300-iteration run only trips the race ~30-50% of the time on this hardware; loop the subprocess 15× in release so the without-fix case fails reliably (0/20 false negatives observed, max 12 runs before crash). Debug keeps a single short functional pass.
There was a problem hiding this comment.
I didn't find any bugs, but this is a cross-thread GC/lifetime fix in JSC bindings with manual new/delete of a Strong<> handle plus an unrelated build-system strip change — worth a human pass on the ownership/leak edge cases (e.g. task queued in Pending then dropped) and the flags.ts drive-by.
Extended reasoning...
Overview
This PR fixes a CI segfault in worker.getHeapSnapshot() by changing how the parent-VM Strong<JSPromise> is carried across the parent→worker→parent round-trip. Instead of capturing the Strong by value (which copy-constructs/destructs on the worker thread and races the parent VM's HandleSet::m_strongList against the GC's Strong Handles constraint), it heap-allocates the Strong once on the parent thread and passes only a raw pointer through the lambdas. Worker::postTaskToWorkerGlobalScope is changed from void to bool so the caller can clean up when the worker has already closed. A new stress fixture + test are added, and there's a separate drive-by change to scripts/build/flags.ts gating .eh_frame_hdr stripping on c.lto.
Security risks
None identified. No user-controlled input flows into the changed code paths beyond what was already there; the change is purely about which thread allocates/frees a GC handle. The flags.ts change narrows when sections are stripped (strictly less aggressive), so no new attack surface.
Level of scrutiny
High. This touches:
- JSC GC internals (
Strong<T>,HandleSet) and cross-VM thread safety - Manual memory management (
new/delete/std::unique_ptradoption of a raw pointer) across thread boundaries - The Worker lifecycle state machine, with a public-ish API signature change (
void→bool) - An intentional-leak path on the return trip when the parent context is gone
The diagnosis and fix are very well argued in the PR description and the 15/40 → 0/40 repro is convincing, but the correctness depends on subtle invariants (e.g. ~Worker running on the parent thread, postTaskTo move semantics, what happens if the outer task is queued in State::Pending and then dropped without executing). These are exactly the kinds of edge cases a human familiar with the recent worker-lifetime rewrites (#29957, #29937) should sanity-check.
Other factors
- The PR bundles an unrelated build-system change (
stripFlagsgating on LTO). It's well-explained and looks correct, but it changes how release Linux binaries are post-processed and deserves its own glance. - The new test runs the fixture 15×300 iterations in release with a 120s timeout — reasonable, but adds non-trivial CI time.
- CI failures on the latest commit appear unrelated (bake/dev, http keepalive timeouts, a pre-existing ASAN assertion in 26225.test.ts).
- All coderabbit nitpicks are resolved; no outstanding human review comments.
…olves to baked bun)
There was a problem hiding this comment.
I didn't find any bugs, but this touches cross-thread JSC GC/HandleSet lifetime with manual new/delete and an intentional-leak path, plus a drive-by build-flag change — worth a human pass on the ownership/leak tradeoffs.
Extended reasoning...
Overview
Fixes a cross-thread race in worker.getHeapSnapshot() where capturing a JSC::Strong<JSPromise> by value caused the worker thread to copy-construct/destroy it against the parent VM's HandleSet::m_strongList without the parent VM lock, racing the "Sh" (Strong Handles) GC marking constraint and segfaulting at 0x10. The fix heap-allocates the Strong once on the parent thread and passes only the raw pointer through the cross-thread lambdas; the parent-side completion lambda adopts it into a std::unique_ptr and resolves the promise. Worker::postTaskToWorkerGlobalScope now returns bool so a lost race to Closing/Closed rejects with ERR_WORKER_NOT_RUNNING instead of leaking. There is also an unrelated drive-by in scripts/build/flags.ts gating .eh_frame_hdr stripping on c.lto to avoid an orphan PT_GNU_EH_FRAME phdr in non-LTO local builds. New stress fixture + test added.
Security risks
None identified. No untrusted input handling, auth, or crypto changes — this is internal VM/GC lifetime management. The intentional leak of promiseHandle when postTaskTo(parentId, …) fails on the return trip is bounded (parent VM is tearing down) and is the safe choice vs. cross-thread HandleSet mutation.
Level of scrutiny
High. This is concurrency-sensitive C++ at the JSC GC boundary with manual new/delete ownership across two threads and three lambda hops. The root-cause analysis is thorough and the fix matches it, but the design choices (raw pointer through lambdas, intentional leak when the parent context vanishes mid-round-trip, void→bool API change) are exactly the kind of thing a human reviewer familiar with Bun's worker/VM lifecycle should sign off on. The flags.ts change is logically separate and affects release binary stripping behavior.
Other factors
- Bug-hunting system found no issues; coderabbit nits were addressed/resolved.
- Stress verification in the PR description (15/40 → 0/40 segfaults) is convincing, and a regression test is included.
- The
postTaskToWorkerGlobalScopereturn-type change is safe (only caller isgetHeapSnapshot, per the in-code comment), but that invariant is worth a human confirming. - No CODEOWNERS for the touched paths.
The x64-asan CI lane is release+ASAN (~3x slower per heap snapshot) but isDebug is false there, so it was running the full 15x300 workload and timing out at 120s after 7 clean attempts. Treat ASAN like the debug build and run a single shorter functional pass; plain release lanes keep the full 15-attempt stress.
…-trip leak On x64-baseline/musl/aarch64 CI the worker has been observed to exit on its own after a few hundred heap snapshots, which surfaces as a clean ERR_WORKER_NOT_RUNNING rejection — not the process-level 0x10 segfault this fixture is designed to catch. Recreate the worker and continue so the round-trip count (and thus the number of HandleSet race opportunities) is preserved. Also expand the JSWorker.cpp comment to cover the 'task accepted but never run' leak window alongside the existing return-trip note.
There was a problem hiding this comment.
Both of my earlier notes (outbound-trip leak comment, linkerFlags typo) are addressed and I found nothing further — deferring to a human for the cross-thread Strong<JSPromise> ownership change and the stripFlags LTO gating, which are subtle enough to warrant a second pair of eyes.
Extended reasoning...
Overview
This PR fixes a cross-thread race in worker.getHeapSnapshot() where a parent-VM Strong<JSPromise> was being copy-constructed/destroyed on the worker thread, racing the parent VM's GC "Strong Handles" marking constraint and segfaulting at 0x10. The fix heap-allocates the Strong once on the parent thread and threads only a raw pointer through the cross-thread lambdas, with postTaskToWorkerGlobalScope now returning bool so a lost race to Closing/Closed can be cleaned up synchronously. It also includes an unrelated drive-by: gating .eh_frame_hdr removal in stripFlags on c.lto so non-LTO local builds don't end up with an orphan PT_GNU_EH_FRAME phdr. Two new test files exercise the race under GC pressure.
Security risks
None identified. No auth, crypto, permissions, or user-input parsing is touched. The change is internal lifetime management for a profiling/debugging API and a build-script strip-flag predicate.
Level of scrutiny
High. The core change is cross-thread ownership of a JSC GC handle via raw pointer, with two explicitly documented (and now commented) leak windows accepted as a crash→leak tradeoff. This is exactly the kind of subtle concurrency/GC-lifetime reasoning where a human reviewer familiar with the recent Worker lifetime rewrites (#29957/#29937) should confirm the invariants — particularly that the parent-thread completion lambda is the only place the Strong is dereferenced/freed, and that the !accepted cleanup path is sound. The stripFlags change is small but affects release-binary layout on linux-gnu and is bundled as a drive-by; it deserves a glance from someone who owns the build scripts.
Other factors
- My two prior inline comments (documenting the outbound-trip leak window;
linkFlags→linkerFlagstypo) were both addressed in 164fe32 and 557a6b2. - The bug-hunting system found no issues in this pass.
- CodeRabbit's nits on test assertion shape were resolved/declined with reasonable justification.
- robobun reports musl
build-bunfailures on 557a6b2; the author has since pushed a CI retrigger (5659245). Theflags.tschange is gated onc.abi === "gnu"so it shouldn't affect musl, but CI should be green before merge. - Stress verification (15/40 segfaults pre-fix → 0/40 post-fix) is compelling.
…0185) `test/js/node/worker_threads/worker_threads.test.ts` occasionally segfaults in CI with ``` panic: Segmentation fault at address 0x10 ``` on a GC helper thread: ``` wtfThreadEntryPoint AutomaticThread::start ParallelHelperPool::Thread::work Heap::runBeginPhase(GCConductor)::$_1 SlotVisitor::drainFromShared MarkingConstraintSolver::runExecutionThread MarkingConstraint::execute ← "Sh" Strong Handles HandleSet::visitStrongHandles *(nullptr + offsetof(HandleNode, m_value)) = *(0x10) ``` (decoded from the `bun.report` trace on [build 50529 / 🐧 13 x64](https://buildkite.com/bun/bun/builds/50529#019dec3e-4d11-4651-b908-84e601bc2db3) and symbolized against that build's `bun-profile`). ## Cause `jsWorkerPrototypeFunction_getHeapSnapshotBody` does: ```cpp Strong<JSPromise> strong(vm, promise); // parent VM's HandleSet worker.postTaskToWorkerGlobalScope([strong, parentId](auto& workerCtx) { ... ScriptExecutionContext::postTaskTo(parentId, [strong, snapshot = ...](auto& parentCtx) { ... }); // runs on worker thread }); ``` `JSC::Strong<T>` has **no move constructor**. Capturing it by value copy-constructs it, which calls `HandleSet::allocate()` + `m_strongList.push()`; destroying it calls `HandleSet::deallocate()` + `NodeList::remove()`. Both happen on the **worker thread** against the **parent VM's** `HandleSet`, without the parent VM's lock. `HandleSet::m_strongList` is a `SentinelLinkedList<HandleNode>` — not thread-safe. `push`/`remove` transiently null `m_next`/`m_prev`. The parent VM's "Sh" (Strong Handles) marking constraint (`Heap::addCoreConstraints`) iterates that list during GC; when it follows a null `m_next` it reads `*((HandleNode*)nullptr)->slot()` → `*(0x0 + 0x10)`. The `heapHelperPool()` is process-global, so the crashing helper thread belongs to the parent VM's collector even though the worker VM's `BunV8HeapSnapshotBuilder` full GC is in progress at the same time. This has been there since `getHeapSnapshot` was added — the recent worker lifetime rewrites (oven-sh#29957, oven-sh#29937) didn't introduce it. ## Fix Heap-allocate the `Strong<JSPromise>` once on the parent thread and pass only the raw pointer through the cross-thread lambdas. The worker thread never dereferences it, so it never touches the parent VM's `HandleSet`. The parent-side completion lambda resolves the promise and frees the handle. `Worker::postTaskToWorkerGlobalScope` now returns `bool` so a lost race to `Closing`/`Closed` (worker exited between `isOnline()` and the post) rejects with `ERR_WORKER_NOT_RUNNING` instead of silently leaking the handle. If `postTaskTo(parentId, …)` on the return trip fails (parent context gone), the handle intentionally leaks — deleting a parent-VM `Strong` from the worker thread is exactly the bug we're fixing, and the parent VM is tearing down anyway. ## Verification Stress fixture (`heap-snapshot-gc-race-fixture.js`, 300 iterations of `await worker.getHeapSnapshot(); Bun.gc(true)`), 40 runs each on linux-x64 release: | build | segfault at `0x10` | | --- | --- | | `52bdf47` (CI artifact, no fix) | 15 / 40 | | this branch | 0 / 40 | The new `worker_heap_snapshot_gc.test.ts` runs the fixture — 300 iters in release, 5 in debug (a single debug heap snapshot takes ~1.6s so the race window, which is a handful of instructions after each snapshot, is impractical to hit there; the debug pass is a functional check). ## Drive-by: non-LTO strip leaves orphan `PT_GNU_EH_FRAME` While reproducing I hit a second, unrelated crash in locally-built (non-LTO) release binaries: `stripFlags` removed `.eh_frame_hdr` on linux-gnu unconditionally, but the linker only passes `--no-eh-frame-hdr` when LTO is on. GNU strip doesn't rewrite the program header table, so the `PT_GNU_EH_FRAME` phdr was left pointing at unmapped memory and any stack unwind (e.g. WTF::Thread teardown after a worker exits) faulted. CI release builds always have LTO on so they weren't affected. Gated the section removal on `c.lto` to match the linker flag. --------- Co-authored-by: robobun <robobun@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
test/js/node/worker_threads/worker_threads.test.tsoccasionally segfaults in CI withon a GC helper thread:
(decoded from the
bun.reporttrace on build 50529 / 🐧 13 x64 and symbolized against that build'sbun-profile).Cause
jsWorkerPrototypeFunction_getHeapSnapshotBodydoes:JSC::Strong<T>has no move constructor. Capturing it by value copy-constructs it, which callsHandleSet::allocate()+m_strongList.push(); destroying it callsHandleSet::deallocate()+NodeList::remove(). Both happen on the worker thread against the parent VM'sHandleSet, without the parent VM's lock.HandleSet::m_strongListis aSentinelLinkedList<HandleNode>— not thread-safe.push/removetransiently nullm_next/m_prev. The parent VM's "Sh" (Strong Handles) marking constraint (Heap::addCoreConstraints) iterates that list during GC; when it follows a nullm_nextit reads*((HandleNode*)nullptr)->slot()→*(0x0 + 0x10).The
heapHelperPool()is process-global, so the crashing helper thread belongs to the parent VM's collector even though the worker VM'sBunV8HeapSnapshotBuilderfull GC is in progress at the same time.This has been there since
getHeapSnapshotwas added — the recent worker lifetime rewrites (#29957, #29937) didn't introduce it.Fix
Heap-allocate the
Strong<JSPromise>once on the parent thread and pass only the raw pointer through the cross-thread lambdas. The worker thread never dereferences it, so it never touches the parent VM'sHandleSet. The parent-side completion lambda resolves the promise and frees the handle.Worker::postTaskToWorkerGlobalScopenow returnsboolso a lost race toClosing/Closed(worker exited betweenisOnline()and the post) rejects withERR_WORKER_NOT_RUNNINGinstead of silently leaking the handle. IfpostTaskTo(parentId, …)on the return trip fails (parent context gone), the handle intentionally leaks — deleting a parent-VMStrongfrom the worker thread is exactly the bug we're fixing, and the parent VM is tearing down anyway.Verification
Stress fixture (
heap-snapshot-gc-race-fixture.js, 300 iterations ofawait worker.getHeapSnapshot(); Bun.gc(true)), 40 runs each on linux-x64 release:0x1052bdf47(CI artifact, no fix)The new
worker_heap_snapshot_gc.test.tsruns the fixture — 300 iters in release, 5 in debug (a single debug heap snapshot takes ~1.6s so the race window, which is a handful of instructions after each snapshot, is impractical to hit there; the debug pass is a functional check).Drive-by: non-LTO strip leaves orphan
PT_GNU_EH_FRAMEWhile reproducing I hit a second, unrelated crash in locally-built (non-LTO) release binaries:
stripFlagsremoved.eh_frame_hdron linux-gnu unconditionally, but the linker only passes--no-eh-frame-hdrwhen LTO is on. GNU strip doesn't rewrite the program header table, so thePT_GNU_EH_FRAMEphdr was left pointing at unmapped memory and any stack unwind (e.g. WTF::Thread teardown after a worker exits) faulted. CI release builds always have LTO on so they weren't affected. Gated the section removal onc.ltoto match the linker flag.