Summary
resolve_worker_path's return-union walk aborts the entire candidate set when any single return is outside the static path grammar. One opaque return therefore turns a Worker that is 2/3 resolvable into a hard compile-time refusal:
Error: worker_threads Worker filename was not statically resolvable at compile time;
constructing this Worker is unsupported in the compiled binary
This is the OpenCode TUI wall. packages/opencode/src/cli/cmd/tui.ts runs the whole TUI inside a Worker, so when construction throws (inside an enclosing try), the error is swallowed, the command returns, and the process sits in an idle event loop with a blank screen — alive, no output, no diagnostic.
The one line that does it
packages/opencode/src/cli/cmd/tui.ts:52:
async function target() {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH // ← opaque
const dist = new URL("./cli/tui/worker.js", import.meta.url) // resolvable
if (await Filesystem.exists(fileURLToPath(dist))) return dist
return new URL("../tui/worker.ts", import.meta.url) // resolvable, and the one actually taken
}
Measured on a fixture reproducing target() exactly (perry @ v0.5.1579 + #10356):
| variant |
modules compiled |
result |
OpenCode's target() verbatim |
1 |
THREW not statically resolvable |
same, minus the typeof OPENCODE_WORKER_PATH guard |
2 |
OK {"ran":true} |
same, minus the Filesystem.exists probe, guard kept |
1 |
THREW not statically resolvable |
"modules compiled: 1" is the tell — the worker entry is never compiled into the binary at all.
Everything else about the feature already works
The surrounding machinery handles partial unions correctly; only the union walk is all-or-nothing.
| spelling |
result |
new Worker("./w.ts") |
OK |
new Worker(new URL("./w.ts", import.meta.url)) |
OK |
new Worker(fileURLToPath(new URL("./w.ts", import.meta.url))) |
OK |
new Worker(OPENCODE_WORKER_PATH) with --define |
OK |
new Worker(new URL("./w.ts", import.meta.url).pathname) |
THREW not statically resolvable |
new Worker(new URL("./w.ts", import.meta.url).href) |
THREW not statically resolvable |
Indirection is fine — a const, an awaited arrow, an async helper with branches all resolve. And a missing file among the candidates is handled exactly right:
| candidates |
result |
| one candidate, exists |
OK |
| two candidates, the unused one missing |
OK — compiles the good one |
| two candidates, the taken one missing |
throws at runtime: "Worker filename did not match an existing compile-time-resolved worker entry" |
So a partial union is already a supported, tested concept. collect_modules/worker.rs::resolve_candidates says so in its own comment:
Missing candidates never become import edges. Retain their spellings alongside valid ones so codegen cannot mistake a partial union for a proven single target: choosing a missing candidate must throw at runtime.
Root cause
crates/perry-hir/src/dynamic_import/worker_paths.rs, in returns():
Stmt::Return(Some(value)) => {
let returned = self.resolve(value, depth + 1)?; // ← `?` aborts the whole union
values.is_url |= returned.is_url;
for path in returned.paths {
push_path(&mut values.paths, path, &mut self.work)?;
}
always_returns = true;
}
resolve_worker_path then maps that Err to Resolution::Unresolved, discarding the returns that did resolve.
The function's own doc comment already draws the right distinction for conditions —
Discover edges, not control flow: conditions can contain opaque calls (including awaited filesystem probes).
— but a return gets no such tolerance, even though an unresolvable return is the same kind of fact: it means "this edge is unknown", not "no edge is knowable".
Suggested fix
Treat an unresolvable return like a missing candidate: drop it from the union, keep the resolvable ones, and compile those as worker entries. The runtime dispatch in perry-codegen/src/expr/worker_new.rs::lower_candidates already matches the actual filename against the compiled set and throws when nothing matches, so an opaque path that is genuinely taken still fails loudly — at the moment it is used, rather than poisoning every sibling.
One caution for whoever implements it: the union must stay marked as partial so codegen cannot collapse a single surviving candidate into a proven single target. Otherwise new Worker(opaquePath) would silently run the one compiled worker instead of throwing. resolve_candidates solves this for missing files by retaining their spellings; an opaque return has no spelling, so it needs an explicit flag rather than relying on set size.
Workaround
--define 'OPENCODE_WORKER_PATH="/abs/path/to/worker.ts"' makes the first return resolvable and the Worker compiles. That is what OpenCode's define hook is for, and it is how I am unblocking the TUI build meanwhile.
Summary
resolve_worker_path's return-union walk aborts the entire candidate set when any singlereturnis outside the static path grammar. One opaque return therefore turns a Worker that is 2/3 resolvable into a hard compile-time refusal:This is the OpenCode TUI wall.
packages/opencode/src/cli/cmd/tui.tsruns the whole TUI inside a Worker, so when construction throws (inside an enclosingtry), the error is swallowed, the command returns, and the process sits in an idle event loop with a blank screen — alive, no output, no diagnostic.The one line that does it
packages/opencode/src/cli/cmd/tui.ts:52:Measured on a fixture reproducing
target()exactly (perry @ v0.5.1579 + #10356):target()verbatimtypeof OPENCODE_WORKER_PATHguardOK {"ran":true}Filesystem.existsprobe, guard kept"modules compiled: 1" is the tell — the worker entry is never compiled into the binary at all.
Everything else about the feature already works
The surrounding machinery handles partial unions correctly; only the union walk is all-or-nothing.
new Worker("./w.ts")new Worker(new URL("./w.ts", import.meta.url))new Worker(fileURLToPath(new URL("./w.ts", import.meta.url)))new Worker(OPENCODE_WORKER_PATH)with--definenew Worker(new URL("./w.ts", import.meta.url).pathname)new Worker(new URL("./w.ts", import.meta.url).href)Indirection is fine — a
const, anawaited arrow, anasynchelper with branches all resolve. And a missing file among the candidates is handled exactly right:So a partial union is already a supported, tested concept.
collect_modules/worker.rs::resolve_candidatessays so in its own comment:Root cause
crates/perry-hir/src/dynamic_import/worker_paths.rs, inreturns():resolve_worker_paththen maps thatErrtoResolution::Unresolved, discarding the returns that did resolve.The function's own doc comment already draws the right distinction for conditions —
— but a
returngets no such tolerance, even though an unresolvable return is the same kind of fact: it means "this edge is unknown", not "no edge is knowable".Suggested fix
Treat an unresolvable
returnlike a missing candidate: drop it from the union, keep the resolvable ones, and compile those as worker entries. The runtime dispatch inperry-codegen/src/expr/worker_new.rs::lower_candidatesalready matches the actual filename against the compiled set and throws when nothing matches, so an opaque path that is genuinely taken still fails loudly — at the moment it is used, rather than poisoning every sibling.One caution for whoever implements it: the union must stay marked as partial so codegen cannot collapse a single surviving candidate into a proven single target. Otherwise
new Worker(opaquePath)would silently run the one compiled worker instead of throwing.resolve_candidatessolves this for missing files by retaining their spellings; an opaque return has no spelling, so it needs an explicit flag rather than relying on set size.Workaround
--define 'OPENCODE_WORKER_PATH="/abs/path/to/worker.ts"'makes the first return resolvable and the Worker compiles. That is what OpenCode's define hook is for, and it is how I am unblocking the TUI build meanwhile.