pi crashed inside the Gondolin sandbox: the Bun vs Node runtime mismatch
A Gondolin-sandboxed pi session crashed on socket close because mise ships pi as a Bun binary while the Gondolin SDK targets Node; installing pi via npm under mise-managed Node fixed it.
I set up pi to run its tools inside a Gondolin micro-VM, so file edits and shell commands stay confined. The session crashed the whole agent mid-turn:
pi exiting due to uncaughtException:
socket.@end(), callback(err);
^
TypeError: socket.@end is not a function. (In 'socket.@end()', 'socket.@end' is undefined)
at endNT (node:net:5:14)
Quitting pi with /quit crashed it again the same way.
Crash
The crash trace pointed at Node’s node:net module. The symbols in it (@end, @lazy, @getInternalField, @createInternalModuleById) are JavaScriptCore internals. Those only appear in Bun. pi was running under Bun, and Bun was polyfilling node:net.
Two facts together explain the crash:
-
pi was installed by mise as a Bun binary.
file $(which pi)returnedMach-O 64-bit executable arm64, andstringson the binary showed__BUNandbun-runtime. mise’s pi backend compiles pi with Bun’s single-executable-app builder. The Mach-O is Bun, the host runtime is Bun, and anynode:import runs through Bun’s compatibility layer. -
The Gondolin SDK targets Node. Its
package.jsondeclaresengines.node >= 23.6.0, and it imports thenetbuiltin in five source files1. The SDK opens a virtio socket to the VM, streamsvm.execoutput over it, and tears that socket down invm.close().
Bun’s node:net polyfill has a broken socket.end() path. When the SDK closed the VM socket (on command exit, on session shutdown), Bun called endNT, then socket.@end(), and @end was undefined. The exception escaped the SDK, hit pi’s uncaughtException handler, and killed the process.
The crash sat inside Bun’s node:net implementation, which Bun’s own compatibility table marks 🟢 Fully implemented2. The badge is accurate for most of the module. The teardown path is not. When the SDK closed a VM socket, Bun’s node:net called endNT, then socket.end(), and the internal handler resolved socket.@end to undefined. The crash trace names it exactly:
TypeError: socket.@end is not a function.
at endNT (node:net:5:14)
endNT is Node’s internal net.Socket.end helper; @end is a JavaScriptCore internal slot that Bun failed to wire up for that call. Two crash sites, same root cause: a net.Socket close under Bun v1.3.10.
Runtime investigation and fix
The pi.dev quickstart installs pi with npm, not mise:
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
mise is absent from the official docs. The Bun single-executable binary is a mise choice, and pi’s package.json declares bin: { "pi": "dist/cli.js" } with engines.node >= 22.19.0. It is a Node program.
I changed one line in ~/.config/mise/config.toml:
# before: mise's Bun backend
pi = "latest"
# after: npm install under mise-managed Node
"npm:@earendil-works/pi-coding-agent" = "latest"
The npm: prefix is mise-native; it installs the npm package into mise’s node environment and keeps version pinning and the lockfile. After mise install, the binary looked completely different:
# before: Bun Mach-O
$ file $(readlink -f $(which pi))
Mach-O 64-bit executable arm64
# after: a Node script
$ file $(readlink -f $(which pi))
a /usr/bin/env node script text executable
$ head -1 $(readlink -f $(which pi))
#!/usr/bin/env node
I removed the orphaned Bun install directory, then ran the exact crash path under Node to confirm:
// /tmp/crashtest.mjs
import { VM } from "@earendil-works/gondolin";
const vm = await VM.create({ sessionLabel: "post-migration" });
const r = await vm.exec(["/bin/sh", "-lc", "echo node-pi-vm-ok"]);
console.log("exec:", r.stdout.trim());
await vm.close(); // this crashed under Bun
console.log("close: OK");
exec: node-pi-vm-ok
close: OK
No socket.@end. The entire crash class is gone.
The two interim workarounds I reverted
Before I found the runtime mismatch, I patched the extension to dodge each crash site. Both are worth recording.
The first was the streaming path. The official Gondolin extension streams tool output with stdout: "pipe" and for await (chunk of proc.output()):
const proc = vm.exec([shell, "-lc", command], {
stdout: "pipe",
stderr: "pipe",
});
for await (const chunk of proc.output()) onData(chunk.data);
const result = await proc;
The async iteration is what triggered endNT on socket close. I switched to the SDK’s default "buffer" mode, which resolves the full result without streaming:
const result = await vm.exec([shell, "-lc", command], {
stdout: "buffer",
stderr: "buffer",
});
if (result.stdoutBuffer.length > 0) onData(result.stdoutBuffer);
That cost live output: the UI only updated when the command finished. It also had a second gotcha. pi’s onData callback expects bytes, and the SDK’s result.stdout getter coerces to string. Passing the string threw TextDecoder.decode expects an ArrayBuffer or TypedArray. The fix was result.stdoutBuffer (a Buffer).
The second workaround was a swallow-catch around vm.close() in the session-shutdown handler:
try {
await activeVm.close();
} catch (_err) {
// swallow: we're exiting anyway, the VM is force-killed on process exit
}
Both were symptoms of the same runtime mismatch. Once pi ran under Node, I reverted both back to the official example’s shape and re-ran the original 5-second streaming command followed by /quit. Output streamed live, teardown was clean, zero crash signatures in the log.
Verification
| Check | mise/Bun | npm/Node |
|---|---|---|
file on the pi binary | Mach-O | node script |
node:net symbols in a crash trace | yes | no |
vm.exec streaming (stdout: "pipe") | crash | works |
vm.close() on /quit | crash | works |
| Live incremental tool output in the UI | no (needed buffer) | yes |
The extension now runs the official example’s code unchanged, plus the ShadowProvider layer that hides the repo’s .env and node_modules from the agent. No Bun-specific code.
Avoided dead ends
Two things, in order of how much time they would have saved.
Check the runtime before the code. The crash trace had JavaScriptCore symbols in it. The moment I saw @end and @lazy instead of V8 internals, I should have asked which runtime produces those, rather than which line of my code calls socket.end(). I patched two code paths before I read file $(which pi).
Trust the official install docs over the package manager default. mise’s pi backend ships a Bun binary. The pi.dev quickstart ships an npm package. I assumed they were equivalent because both produced a working pi --version. They diverge specifically because the Gondolin SDK imports the net builtin directly in five modules1. The VM socket is opened with net and torn down with it, which is exactly the Bun path we hit. Most Node programs reach net transitively too (Node’s own http and https are built on net.Socket), but the precise reason this program tripped the bug is the SDK’s direct, repeated use.
The general lesson: when a JS tool crashes inside a node: builtin, the first thing to check is which runtime is actually executing it. file $(readlink -f $(which <bin>)) and head -1 on the resolved file take two seconds and would have surfaced this in the first minute.
References
- pi.dev quickstart. Canonical install is
npm install -g, not mise. - pi.dev Containerization. Documents the Gondolin-extension pattern this extension is based on.
- Gondolin SDK: VM Control. Covers
vm.exec,stdout: "buffer" | "pipe", andvm.close(). - Gondolin VFS Providers. Covers
ShadowProvider, used to hide.envandnode_modules. miseNode tool-namespace docs. Thenpm:prefix syntax inmise.toml.- Bun Node.js compatibility. The table that marks
node:net🟢 Fully implemented, despite thesocket.end()crash we hit. - Node
net/http/https.httpandhttpsare built onnet.Socket, which is why most Node programs reachnode:nettransitively.
Footnotes
-
Verified against the installed
v0.12.0dist. Five source files import thenetbuiltin:host/src/vm/core.ts(the virtio socket the VM runs on),host/src/session-registry.ts(the attach socket),host/src/ingress.ts,host/src/utils/dns.ts, andhost/src/utils/ip.ts. ↩ ↩2 -
Bun Node.js compatibility lists
node:netas 🟢 Fully implemented. A “fully implemented” module can still carry a specific bug in a path the badge does not exercise, which is what we hit. ↩
This post was written with AI assistance.