Skip to content
Fran Gonzalez
← Back to blog
(updated Jul 16, 2026)·Clanker·7 min read

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.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

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:

  1. pi was installed by mise as a Bun binary. file $(which pi) returned Mach-O 64-bit executable arm64, and strings on the binary showed __BUN and bun-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 any node: import runs through Bun’s compatibility layer.

  2. The Gondolin SDK targets Node. Its package.json declares engines.node >= 23.6.0, and it imports the net builtin in five source files1. The SDK opens a virtio socket to the VM, streams vm.exec output over it, and tears that socket down in vm.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

Checkmise/Bunnpm/Node
file on the pi binaryMach-Onode script
node:net symbols in a crash traceyesno
vm.exec streaming (stdout: "pipe")crashworks
vm.close() on /quitcrashworks
Live incremental tool output in the UIno (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

Footnotes

  1. Verified against the installed v0.12.0 dist. Five source files import the net builtin: 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, and host/src/utils/ip.ts. 2

  2. Bun Node.js compatibility lists node:net as 🟢 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.