Repository navigation
Bug: workerd segfaults intermittently on shutdown under vitest-pool-workers (linux-x64 and macOS-arm64) #6763
Description
Summary
workerd intermittently segfaults (Received signal #11) when miniflare tears down a worker between vitest test files. Tests inside the file run to completion and report passing before the crash, so the failure mode is purely shutdown-time noise — but vitest's cloudflare-pool sees the unexpected WebSocket close and raises Worker exited unexpectedly, failing the run with exit 1 despite 100% test pass rate.
Reproduces on linux-x64 (GitHub Actions self-hosted runner, Ubuntu) and macOS-arm64 (Darwin 25.4.0, M-series) with the same workerd binary. Confirmed with both workerd-linux-64@1.20260507.1 and workerd-linux-64@1.20260508.1 — not a recent regression.
Versions
@cloudflare/workerd-linux-641.20260508.1(also1.20260507.1)@cloudflare/vitest-pool-workers0.16.4miniflare4.20260508.0wrangler4.90.1vitest4.1.6node25.6.2
Symptoms
Run summary from a CI failure:
Test Files 34 passed (39)
Tests 400 passed (400)
Errors 5 errors
Duration 63.70s
Exit status 1
Every test that executed reported as passed; the run still fails because 5 of 39 worker shutdowns segfaulted. Each segfault produces:
*** Received signal #11: Segmentation fault
stack: …/workerd-linux-64@1.20260508.1/…/workerd@<addr> <addr> <addr> …
The crash addresses differ across the 5 crashes within a single run — some start at workerd@24e613f, others at workerd@5359d1b → 52a32e9 → 52ce098 → 52b3716 → 24e64a3, others at workerd@52a32c7 → 52ce0ef → 52b46bf → 52b25b5 → 52b2ed4 → 24e666f. All chains converge on the same deep frames (workerd@24e66cf repeated many times), which look like the event-loop entry. Suggests multiple distinct call sites all reaching the same crashing teardown path.
Vitest then reports:
⎯⎯⎯⎯⎯⎯ Unhandled Error ⎯⎯⎯⎯⎯⎯⎯
Error: [vitest-pool]: Worker cloudflare-pool emitted error.
Caused by: Error: Worker exited unexpectedly
❯ _WebSocket.emitUnexpectedExit …/vitest/dist/chunks/cli-api.B5majYba.js:3012:33
❯ _WebSocket.[kClose] …/miniflare/dist/src/index.js:58868:37
Frequency
About 5 segfaults per 39 sequential test files (~13% per shutdown) in our integration suite. Each crashed file gets dropped from the file-pass counter (34 passed (39)), even though its 30-odd test cases already reported back as passed inside the 400 passed (400).
What does NOT help
maxWorkers: 1+fileParallelism: falseinvitest.config.ts(sequential execution — workers still segfault on the per-file teardown)- Downgrading the workerd binary one patch (May-7 → same shape)
- Single-file runs (
vitest run path/to/one.test.ts) — never reproduce. Only the multi-file sequential run hits the threshold.
Difference from #6583
#6583 is a deterministic startup-time crash in kj::Path::evalPart → ModuleRegistryImpl::resolve triggered by importing node:sqlite on macOS. This is a stochastic shutdown-time crash that doesn't depend on test content (different files crash across runs), reproduces on linux-x64 too, and only manifests after the per-file teardown — test bodies run to completion first.
Ask
Any guidance on:
- A graceful-shutdown knob in miniflare/workerd we could enable to sidestep the crash path?
- Whether the addresses (
workerd@24e66cf,workerd@52ce098,workerd@52b3716) can be resolved to source lines against the published binary so we can narrow down the call site? - Whether this looks like a known fixed-in-main issue we should expect in a later release.
Happy to gather more diagnostics (ulimit -c unlimited + coredumpctl, MINIFLARE_WORKERD_PATH with a debug build, etc.) if useful.
Activity
trafgals commentedon Jul 21, 2026
Confirming this on WSL2 (linux-x64, Ubuntu 22.04 inside Windows) with a slightly older toolchain, and offering one piece of additional root-cause / mitigation insight that may help triage:
Versions that reproduce locally:
@cloudflare/vitest-pool-workers0.12.21wrangler4.71.xvitest2.0.xnode20.xSymptom shape (matches yours, but with a useful wrinkle): every test reports passing in stdout (e.g.
Tests 794 passed (794)), but the vitest process exits 1 and prints[vpw:debug] Disposing remote proxy sessions...followed by[vitest-pool]: Worker cloudflare-pool emitted error … Worker exited unexpectedly. The "Worker exited unexpectedly" comes from miniflare's WebSocket close handler reacting to the workerd subprocess dying — not from a test failure.Insight that may be the actual blocker for upstream: in
pool.close()insidenode_modules/@cloudflare/vitest-pool-workers/dist/pool/index.mjs(lines ~8050–8072 at the 0.12.21 bundled version, the same shape survives into 0.16.x), both dispose promises are pushed onto an array and awaited viaPromise.all(promises)with no.catch()wrapping:So the workerd segfault is the upstream trigger, but the test-side non-zero exit is the pool refusing to tolerate dispose rejection. Any single teardown rejection aborts the whole shutdown and bubbles out as exit 1, even though no test logic failed.
Local mitigation (so the test suite can finish deterministically on linux-x64 / WSL2 / macOS-arm64 dev machines while this is open): wrap both dispose promises with
.catch()that logs but doesn't propagate. We've shipped this in our repo viapatch-package, which generates the following diff (manually trimmed of file-mode noise):This makes our pre-commit hook (which runs frontend + workers vitest in parallel and treats exit 1 as a gate failure) behave the same as our Linux ARM64 self-hosted CI runners, which evidently use a different workerd binary path and don't trip this — that's likely why the failure surfaces only on certain hosts and not in
cloudflare/workerd's own CI.Things this patch does not fix and which still point at workerd:
Received signal #11itself — needs the actual workerd-side fix.Happy to gather coredumps or run with
MINIFLARE_WORKERD_PATHpointed at a debug build if that would help narrow the call site. Mentioning in case it's useful for triage.trafgals commentedon Jul 21, 2026
Adding an update with the complete 3-layer chain I traced from the workerd segfault to the observable vitest exit-1, since the dispose-time
.catch()wrapping from my previous comment turned out to only fix one of the amplifiers.The full chain (verified empirically with a local patch-package patch where 794/794 tests now pass deterministically while workerd still segfaults underneath):
Upstream trigger:
workerdSIGSEGV (this issue). The crash is the actual bug; everything downstream is how the surrounding tooling reacts to it.Amplifier Rename BUILD -> BUILD.bazel #1 in
@cloudflare/vitest-pool-workers. Inside the bundleddist/pool/index.mjsrunTests()(~line 7850 in 0.12.x, similar in 0.16.x), the WebSocket close handler does:When workerd dies post-task, the socket closes with a non-normal code,
startupErrorisundefined, and this throw kills the fork — after all tests in the file have already been reported via the birpc listener. We patched this to log +debuglog2('DONE', files); return;when there's nostartupError, and the patch is enough locally for the test results to stand. A realstartupErrorstill propagates unchanged.Amplifier Add tips for developing with vscode #2 in
tinypool. When the vitest-side fork dies beforeterminate()was called,ProcessWorker.onUnexpectedExitemitsError("Worker exited unexpectedly")on the worker regardless of whether any task was in flight. There's no distinction between "worker crashed mid-task" and "worker exited cleanly after its task was done." I opened a small PR upstream proposingoptions.allowWorkerIdleExit(default false; when true, swallows the post-task case) — pending review.Closing indirection in vitest's CLI layer. The error from Fix clang 11 and make icudata embed portable #3 is then surfaced as
[vitest-pool]: Worker cloudflare-pool emitted erroratvitest/dist/chunks/cli-api.*.js~3012, which is where vitest's exit-1 decision happens. So even if Add tips for developing with vscode #2 and Fix clang 11 and make icudata embed portable #3 are both mitigated and the fork death is silent, you still need Add tips for developing with vscode #2 fixed (the throw) for the kill-the-fork pathway.So three independent retry layers all assume "if my downstream died, that's a real problem" — none of which is true for teardown-after-task-completion noise. The workerd segfault needs fixing on its own merits; the pool-side layers are independently worth fixing because they make every flavor of upstream crash silent noise instead of test-side failures.
For anyone reproducing locally: the patch-package mitigation I shipped that gets us green is the union of the dispose-
.catch()from my prior comment + the throw-suppression inrunTests(). With both applied, the segfault is still printed to stderr (visible viadebuglog) but no longer affects exit code.Willing to bisect against a debug workerd build if that would help isolate the crashing frame — happy to coordinate on next steps.
trafgals commentedon Jul 22, 2026
Adding the missing third layer that completes the chain — a never-resolving dispose promise that neither of my earlier fixes covered, plus the upstream PRs that came out of this triage.
What my prior fix missed
The two earlier patches (dispose
.catch()wrapping + therunTests()throw-suppression) cover rejection paths. But when miniflare's I/O is waiting on a workerd process that no longer exists, the dispose promise doesn't reject — it just never resolves.Promise.all(promises)then waits indefinitely. Adding.catch()doesn't help a promise that never throws.The actual cap is a
Promise.raceagainst a timer:30s is enough for healthy teardown while bounding the worst case. The timeout promise resolves, so
Promise.allsettles and the runner exits cleanly.Verification in mynextrow (60 test files / 794 tests)
With all three patches applied (dispose
.catch()+runTests()throw suppression + dispose-timeout race), the suite now finishes deterministically:The 30s timeout log fires on every run — confirming workerd is still segfaulting underneath, but the dispose path no longer blocks the runner. Total wall-clock is unchanged from before; we just no longer wedge on the last dispose.
Adjacent finding: WSL2-only forks-pool overload (separate issue)
While validating, I hit a different failure on WSL2 (linux-x64 inside Windows) that initially looked like the same bug but isn't: when frontend vitest (forks pool) and workers vitest (cloudflare-pool) run in parallel via a pre-commit hook, the 30s vitest forks-pool
handshakeTimeoutis exceeded for ~10% of worker spawns. Manifests asFailed to start forks worker/Timeout waiting for worker to respond. Single-suite runs (frontend alone or workers alone) never reproduce.This is upstream vitest fork-spawn timing under WSL2 with combined fork + workerd subprocess pressure — not workerd itself. Sequential
(frontend) && (workers)in the hook makes it disappear. Mentioning in case anyone else is debugging similar WSL-only flakes and lands here.Upstream PRs opened out of this triage
runTests()throw-suppression). PR open againstcloudflare/vitest-pool-workers, review pending.options.allowWorkerIdleExit(default false; when true, swallowsonUnexpectedExitif no task was in flight, distinguishing post-task crash from mid-task crash). PR open againsttinypool/tinypool, review pending.workers/patches/@cloudflare+vitest-pool-workers+0.12.21.patchin mynextrow bundles all three local mitigations so the test suite ships green while upstream lands.Happy to coordinate on bisecting the actual workerd crash frame with a debug build — the long-running dispose suggests there's still an I/O handle keeping workerd's loop alive after the task channel closes.