Skip to content

worker_threads: a worker never runs module init (__perry_init_done_* is process-wide), so it aliases the spawning thread's heap — object literals read back property-less (OpenCode TUI wall) #10399

Description

@proggeramlug

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions