Summary
A worker_threads worker does not get a fresh instance of the modules it imports. It sees the main thread's module-level bindings, including mutations the main thread already made — but not, apparently, everything those bindings point at. A memoizing closure therefore ends up with its "already computed" flag set and its cached value missing, and returns undefined forever.
Per the Node/Web worker model each worker has its own module registry and evaluates its own copy of the graph. bun does exactly that.
This is the OpenCode TUI wall, and the whole chain follows from it (see the end).
Reproducer
mod.ts — lazy is copied verbatim from opencode/packages/opencode/src/util/lazy.ts:
// @ts-nocheck
export function lazy(fn) {
let value
let loaded = false
const result = () => { if (loaded) return value; value = fn(); loaded = true; return value }
result.reset = () => { loaded = false; value = undefined }
return result
}
export let counter = 0
export function bump() { counter++; return counter }
let initCount = 0
export const getLazy = lazy(() => { initCount++; return { made: "made", initCount } })
export function peekInit() { return initCount }
w.ts (worker):
// @ts-nocheck
import { parentPort } from "node:worker_threads"
import { getLazy, bump, counter, peekInit } from "./mod.js"
const say = (m) => { try { process.stderr.write("MS WORKER " + m + "\n") } catch {} }
say("counter seen at entry = " + counter)
say("peekInit at entry = " + peekInit())
say("getLazy() = " + JSON.stringify(getLazy()))
say("peekInit after = " + peekInit())
say("bump() = " + bump())
parentPort?.postMessage({ done: true })
main.ts:
// @ts-nocheck
import { Worker } from "node:worker_threads"
import { getLazy, bump, peekInit } from "./mod.js"
const say = (m) => { try { process.stderr.write("MS MAIN " + m + "\n") } catch {} }
say("getLazy() = " + JSON.stringify(getLazy()))
say("peekInit = " + peekInit())
say("bump() = " + bump())
say("bump() = " + bump())
const w = new Worker(new URL("./w.ts", import.meta.url))
const t = setTimeout(() => { say("TIMEOUT"); process.exit(3) }, 10000)
w.on("message", () => { clearTimeout(t); say("done"); process.exit(0) })
Measured (perry @ v0.5.1579 + local fixes, --platform bun, vs bun 1.3.14)
Main thread runs first and mutates the module: getLazy() memoizes, bump() twice.
| # |
cell |
bun |
perry |
|
| 1 |
MAIN getLazy() |
{"made":"made","initCount":1} |
same |
ok |
| 2 |
MAIN peekInit() |
1 |
1 |
ok |
| 3 |
MAIN bump(), bump() |
1, 2 |
1, 2 |
ok |
| 4 |
WORKER counter at entry |
0 |
2 |
✗ |
| 5 |
WORKER peekInit() at entry |
0 |
1 |
✗ |
| 6 |
WORKER getLazy() |
{"made":"made","initCount":1} |
undefined |
✗ |
| 7 |
WORKER peekInit() after |
1 |
2 |
✗ |
| 8 |
WORKER bump() |
1 |
3 |
✗ |
Cells 4, 5 and 8 show the worker observing the main thread's mutations: a fresh module would start at 0 and bump() would return 1.
Cell 6 is the damaging one. loaded reads as true in the worker (inherited), so lazy takes its if (loaded) return value path — but value comes back undefined. The memo is half-inherited: the flag crossed, the payload did not. Cell 7 confirms the factory then ran anyway (initCount went 1 → 2) while the caller still received undefined.
So it is not simply "state is shared": it is shared inconsistently, which is worse than either isolation or full sharing, because correct memoization code silently yields undefined.
Why this is OpenCode's blank TUI
worker imports Server
-> Server.Default() is lazy(() => { const handler = HttpApiApp.webHandler().handler; ... })
-> HttpApiApp.webHandler() is lazy(() => HttpRouter.toWebHandler(routes, ...))
-> in the worker, webHandler() returns undefined (this bug)
-> `.handler` throws "Cannot read properties of undefined (reading 'handler')"
-> the RPC handler in cli/tui/worker.ts rejects
-> worker.ts installs no-op `unhandledRejection` / `uncaughtException` handlers,
so the rejection is swallowed and no RPC reply is ever sent
-> every SDK call from the TUI hangs; Sync's bootstrap() never completes
-> store.status stays "loading", so Sync's `ready` getter stays false
-> createSimpleContext renders `<Show when={init.ready === undefined || init.ready === true}>`,
which withholds all 27 nested providers
-> render() resolves, paints nothing, throws nothing: a blank screen with no error
Confirmed against the real modules at each step: a minimal driver importing OpenCode's real Server and calling Server.Default().app.fetch(request) inside a worker gives
BUN worker: app.fetch RESOLVED status=200 (2884-byte body)
PER worker: THREW "Cannot read properties of undefined (reading 'handler')"
while perry's main thread reports typeof d.app.fetch === "function" and d.app keys=fetch,request, identical to bun.
Expected
Each worker should evaluate its own copy of the module graph: cells 4, 5 and 8 should read 0, 0, 1, and getLazy() should compute fresh in the worker rather than returning a half-inherited memo.
If full per-worker module instantiation is a large change, the half-inherited case is the urgent part — a closure variable whose sibling in the same closure did not cross is a silent-wrong-answer bug, not a performance or semantics trade-off.
Note
This is distinct from #10394 (async handlers never resuming after await). Both are real; OpenCode dies on this one first, before it ever reaches an await that needs resuming. I have corrected the OpenCode attribution on #10394 accordingly.
Summary
A
worker_threadsworker does not get a fresh instance of the modules it imports. It sees the main thread's module-level bindings, including mutations the main thread already made — but not, apparently, everything those bindings point at. A memoizing closure therefore ends up with its "already computed" flag set and its cached value missing, and returnsundefinedforever.Per the Node/Web worker model each worker has its own module registry and evaluates its own copy of the graph. bun does exactly that.
This is the OpenCode TUI wall, and the whole chain follows from it (see the end).
Reproducer
mod.ts—lazyis copied verbatim fromopencode/packages/opencode/src/util/lazy.ts:w.ts(worker):main.ts:Measured (perry @ v0.5.1579 + local fixes,
--platform bun, vs bun 1.3.14)Main thread runs first and mutates the module:
getLazy()memoizes,bump()twice.getLazy(){"made":"made","initCount":1}peekInit()11bump(),bump()1,21,2counterat entry02peekInit()at entry01getLazy(){"made":"made","initCount":1}undefinedpeekInit()after12bump()13Cells 4, 5 and 8 show the worker observing the main thread's mutations: a fresh module would start at
0andbump()would return1.Cell 6 is the damaging one.
loadedreads astruein the worker (inherited), solazytakes itsif (loaded) return valuepath — butvaluecomes backundefined. The memo is half-inherited: the flag crossed, the payload did not. Cell 7 confirms the factory then ran anyway (initCountwent 1 → 2) while the caller still receivedundefined.So it is not simply "state is shared": it is shared inconsistently, which is worse than either isolation or full sharing, because correct memoization code silently yields
undefined.Why this is OpenCode's blank TUI
Confirmed against the real modules at each step: a minimal driver importing OpenCode's real
Serverand callingServer.Default().app.fetch(request)inside a worker giveswhile perry's main thread reports
typeof d.app.fetch === "function"andd.app keys=fetch,request, identical to bun.Expected
Each worker should evaluate its own copy of the module graph: cells 4, 5 and 8 should read
0,0,1, andgetLazy()should compute fresh in the worker rather than returning a half-inherited memo.If full per-worker module instantiation is a large change, the half-inherited case is the urgent part — a closure variable whose sibling in the same closure did not cross is a silent-wrong-answer bug, not a performance or semantics trade-off.
Note
This is distinct from #10394 (async handlers never resuming after
await). Both are real; OpenCode dies on this one first, before it ever reaches anawaitthat needs resuming. I have corrected the OpenCode attribution on #10394 accordingly.