Skip to content

Worker path helper: one opaque return discards the whole candidate union #10379

Description

@proggeramlug

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.

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