cloudflare / cloudflare/workerd
`JsRpcStub` destructor crashes workerd via `CaptureDetailedStackTrace` during V8 GC epilogue (SIGTRAP)
- Dominant language
- C++
- Stars
- 8.7k
- Forks
- 739
- Avg merge
- 2d 20h
- Merged PRs (30d)
- 174
Description
### What versions & operating system are you using?
| Package | Version |
|---|---|
| workerd | 1.20260609.1 |
| miniflare | 4.20260609.0 |
| @cloudflare/vite-plugin | 1.40.1 |
| macOS | 14.6.1 (23G93) |
| Architecture | ARM64 (Apple Silicon) |
### Please provide a link to a minimal reproduction
https://github.com/ptim/workerd-rpc-stub-crash-repro
### Describe the Bug
`workerd` crashes with `EXC_BREAKPOINT` / SIGTRAP during local development when a `JsRpcStub` is garbage-collected without being explicitly disposed. The destructor logs a "not disposed" warning, which captures a JS stack trace — but when this destructor fires _inside a V8 GC epilogue callback_, calling `CaptureDetailedStackTrace` hits a fatal V8 assertion and kills the process.
After the crash, all subsequent requests through `@cloudflare/vite-plugin`'s `dispatchFetch` return `fetch failed` until the dev server is restarted.
_(bug investigated by claude sonnet)_
## Crash stack (abbreviated, ARM64, all three `.ips` reports identical)
```
workerd::api::JsRpcStub::~JsRpcStub() [+196]
workerd::api::JsRpcStub::~JsRpcStub() [+20] ← virtual dtor thunk
workerd::jsg::HeapTracer::HeapTracer(v8::Isolate*)::$_1::__invoke(...) ← GC epilogue callback
v8::internal::Heap::CallGCEpilogueCallbacks(...)
v8::internal::Heap::CollectGarbage(...)
v8::internal::HeapAllocator::CollectGarbageAndRetryAllocation(...)
v8::internal::Runtime_AllocateInYoungGeneration(...)
Builtins_StringAdd_CheckNone ← triggered by string allocation
```
The destructor path is:
```
~JsRpcStub()
→ Worker::Isolate::logWarningOnce()
→ Worker::Isolate::logMessage()
→ workerd::(anonymous namespace)::stackTraceToCDP()
→ v8::StackTrace::CurrentStackTrace()
→ v8::internal::Isolate::CaptureDetailedStackTrace() ← fatal assertion here
```
**Exception**: `EXC_BREAKPOINT` / `SIGTRAP` at `v8_inspector::EvaluateCallback::sendSuccess (...) (.cold.2)`
## To reproduce
The precondition (undisposed stub + GC) can be reproduced reliably. The crash
(SIGTRAP) additionally requires the GC to be an **allocation-triggered major GC**
running while JavaScript is executing — see [Reproduction notes](#reproduction-notes).
1. Use `@cloudflare/vite-plugin` with a Workflows binding
2. Acquire a stub via `.get()` or `.create()` — **do not** call `.dispose()` before the stub goes out of scope
3. Trigger large string allocation so the stub can be collected during a GC epilogue
Running the repro project (`npm install && npm run dev && curl http://localhost:8787`)
reliably prints the workerd warning:
```
An RPC stub was not disposed properly. You must call dispose() on all stubs in
order to let the other side know that you are no longer using them. You cannot
rely on the garbage collector for this because it may take arbitrarily long
before actually collecting unreachable objects. As a shortcut, calling
dispose() on the result of an RPC call disposes all stubs within it.
```
This confirms the destructor path is being hit. The SIGTRAP fires when this
path executes during a major GC epilogue; in the original incident this
occurred after ~35 minutes of use with multi-MB image payloads filling the
V8 heap. See [Reproduction notes](#reproduction-notes) for why a forced DevTools
GC does not crash.
**Minimal example (Worker handler):**
```ts
// wrangler.jsonc — Workflows binding
// "workflows": [{ "name": "MY_WORKFLOW", "binding": "MY_WORKFLOW", "class_name": "MyWorkflow" }]
export default {
async fetch(request: Request, env: Env) {
// BUG: stub acquired but never disposed
const instance = await env.MY_WORKFLOW.get('some-id')
await instance.sendEvent({ type: 'ping', payload: {} })
// instance falls out of scope here — GC'd without disposal
// Now trigger GC pressure with a large string allocation:
const bytes = new Uint8Array(4 * 1024 * 1024) // 4 MB
let s = ''
for (const b of bytes) s += String.fromCharCode(b) // ← triggers young-gen GC mid-loop
return new Response('ok')
}
}
```
**Fixed pattern** — explicit disposal in `finally`:
```ts
const instance = await env.MY_WORKFLOW.get('some-id')
try {
await instance.sendEvent({ type: 'ping', payload: {} })
} finally {
// Dispose the RPC stub before it can be GC'd.
// In production the real Workflows binding is not RPC-backed so this is a no-op.
const dispose = (Symbol as { dispose?: symbol }).dispose
if (dispose && typeof (instance as Record)[dispose] === 'function') {
try { ((instance as Record)[dispose] as () => void).call(instance) } catch {}
}
}
```
## Root cause
`JsRpcStub::~JsRpcStub()` unconditionally calls `logWarningOnce()` when a stub is destroyed without disposal. `logWarningOnce` calls `logMessage`, which calls `stackTraceToCDP` → `CaptureDetailedStackTrace`. Calling `CaptureDetailedStackTrace` from inside a V8 GC epilogue callback is unsafe and hits a V8 assertion.
The GC that triggers the destructor is caused by young-generation allocation pressure from large string concatenation (`Builtins_StringAdd_CheckNone`).
## Expected behavior
The "not disposed" warning should not crash workerd. Options:
- Defer the warning until after GC completes (e.g. schedule a microtask)
- Guard `stackTraceToCDP` calls against being inside a GC epilogue
- Avoid calling `CaptureDetailedStackTrace` from destructor paths that may fire during GC
## Workaround
App-side: explicitly call `.dispose()` on all `JsRpcStub` instances before they go out of scope. In our case, wrapping stub acquisition and workflow creation in a try/finally with explicit disposal prevented further crashes.
## Reproduction notes
The SIGTRAP requires a very specific GC state: a **major (mark-compact) GC
triggered by an allocation failure while JavaScript is executing** (i.e. while
`Builtins_StringAdd_CheckNone` is mid-loop). In that state, the V8 isolate is
not safe for `CaptureDetailedStackTrace` — hence the fatal assertion.
Two things that do **not** crash, and why:
| Attempt | Why it doesn't crash |
|---|---|
| Chrome DevTools "Collect garbage" | Fires when V8 is **idle** between turns — `CaptureDetailedStackTrace` is safe at that point |
| Synthetic repro without heap pressure | String churn only triggers **minor (scavenger) GCs**; `HeapTracer` and its epilogue only run during major GCs |
To crash synthetically you would need the V8 heap to be near its limit so that
a minor GC cannot satisfy the allocation and V8 escalates to a major GC. In
the original incident, ~35 minutes of large image payloads (multi-MB base64
strings per request) built up enough old-gen pressure for this to occur
naturally. Three identical `.ips` crash reports are available confirming the
stack.
## Notes
- Production is unaffected — the real Workflows binding is not RPC-backed
- Three identical crash reports from the same session confirm this is deterministic once triggered
- `.ips` crash reports available on request
- Related (different bug, same symptom — `fetch failed` after workerd death): https://github.com/cloudflare/workers-sdk/issues/13013
### Please provide any relevant error logs
[workerd-2026-06-12-142214.ips.txt](https://github.com/user-attachments/files/28868752/workerd-2026-06-12-142214.ips.txt)
Contributor guide
Assessment
This issue has not been assessed yet.