Repository navigation
Tag snapshot hydration worker deadlocks under Bun (credit ack never delivered after first transferred packet) #754
Description
Summary
When the host harness runs on Bun (the same plugin runs fine under Node.js), tag snapshot hydration can refuse every turn with:
Magic Context could not safely prepare this turn; send your message again. stage=tag snapshot hydration elapsed=10064ms recovery=no managed result; refused (Pi tag hydration: job deadline exceeded)
The worker deadlocks in the credit handshake inside send() in packages/pi-plugin/src/tag-snapshot-worker-pi.ts: it posts a packet with a transfer list, then awaits an ack that — under Bun — is never delivered to the worker's port. The parent's 10 s deadline then aborts the job.
Environment
@cortexkit/pi-magic-context0.49.17 (also reproduced against the v0.49.18 source,master@ 9711862)- Bun 1.3.14, macOS arm64; Node v26.9.0 on the same machine is unaffected
Reproduction
Spawn the shipped worker directly and reply with exactly the acks the real client sends (tag-snapshot-worker-client-pi.ts: ack with message.bytes transferred back for packets, plain ack otherwise):
import { Worker } from "node:worker_threads";
const entry = new URL("<plugin-dist>/tag-snapshot-worker-pi.js", import.meta.url);
const w = new Worker(entry, {
workerData: {
path: "<a magic-context context.db>",
sessionId: "<any session id present in that db>",
connectionEpoch: 1, sessionEpoch: 0, jobId: 1,
},
});
w.on("message", (m) => {
console.log("parent got", m.type);
if (m.type === "packet") w.postMessage({ type: "ack", sequence: m.sequence, bytes: m.bytes }, [m.bytes]);
else if (m.type === "end") w.postMessage({ type: "ack", sequence: m.sequence });
else if (m.type === "done") process.exit(0);
});
setTimeout(() => { console.error("TIMEOUT — worker never finished"); process.exit(1); }, 10_000);- Under Node: completes in tens of milliseconds (
packet → end → end → done). - Under Bun: the parent receives the first packet, then nothing; the worker's
port.once("message", …)ack callback never fires, even though the parent did post the ack (verified by logging on both sides).
What I ruled out
- Not data-dependent: happens with tiny sessions (single 64 KiB page) and multi-MB ones alike; identical script + data passes under Node.
- Not the handshake timing logic: an equivalent
port.once+postMessage(transferList)pattern in a standalone minimal worker works fine under Bun. The trigger requires the real worker bundle (its import graph), so I could not reduce it further — it may be worth an upstream Bun issue, but I could not produce a minimal standalone repro for it. - Not version/migration related: reproduced across plugin restarts, after reinstalling the same version, and across a schema v85 → v95 migration of the shared database.
- Inside the real worker file, even a module-top-level probe that posts a transferred packet and awaits one ack never receives it, while the parent receives the probe packet — so from the worker's perspective the port simply stops delivering inbound messages after the first outbound transfer.
Proposed fix (PR to follow)
Make the worker's send() one-way: post the packet and immediately continue with a freshly allocated packet buffer instead of waiting for the parent to transfer the buffer back. To preserve the ordering guarantee the credit handshake previously provided implicitly, the client serializes consumption of worker messages (consumption = consumption.then(() => consumeMessage(message))). The parent keeps sending acks (ignored by the worker), so mixed-version parents/workers stay harmless.
With that change, the reproduction above completes under Bun in ~20–40 ms for real sessions (up to multi-MB tag snapshots), and Node behavior is unchanged.
Activity
magic-alfonso commentedon Oct 22, 2026
Thanks for the detailed report and the reproduction script. It's precise enough to work from directly. Background tag loading for Pi shipped in 0.49.14, and our own Bun checks ran on Bun 1.4.x, so a Bun 1.3.14 difference in how the worker's port delivers messages after a transfer is plausible.
We're reproducing it now on Bun 1.3.14 and 1.4.x with your script, and finding why the ack never reaches the worker. The credit handshake is there to bound memory and main-thread work while a large snapshot streams in, so before we make
send()one-way we want to confirm that bound still holds, or keep it another way. We'll review your PR against that when it arrives.Until a fix ships, running Pi under Node avoids it, as you found.
magic-alfonso commentedon Oct 22, 2026
Found the cause, and the fix ships in the next patch.
Before Bun 1.4, a worker receives no messages from its parent until its entry module has finished loading. Pi's background tag loader ran its whole job under a top-level
await, and each page waits for the parent's acknowledgement, so on Bun 1.3.x it stopped after the first page and every turn hit the 10-second limit. Bun 1.4 and Node aren't affected. The fix starts the job without a top-levelawait, which keeps the limit on data in flight. The same pattern also affected the auto-search worker on Bun 1.3, and that's fixed too.Until the release, running Pi on Node, or on Bun 1.4 or later, avoids it.
magic-alfonso commentedon Oct 22, 2026
Fixed in v0.49.19, now on npm for OpenCode, Pi and the CLI.
Pi sessions on Bun 1.3.x no longer stall on background tag loading. The cause was in our worker, not in the buffer transfer: before Bun 1.4, a worker receives no messages while its entry module is still loading, and ours ran its whole job under a top-level
await. The worker now starts its job without awaiting it at the top level, so messages flow on Bun 1.3.14 as they do on 1.4. The in-flight limit stays in place. The same pattern in OpenCode's auto-search worker is fixed too, and the rest of our workers were checked for it.Thanks @ChenYCL for the report and the reproduction in #755. If you were running Pi on Node as a workaround, you can switch back to Bun.