Skip to content

Device hub never starts on WSL: resolveNodeExecutable returns the t3 binary as nodePath #12219

Description

@JorrinKievit

What happened

On WSL2, the Device panel never works. device_list reports the local host as
failed, every time:

"local": { "status": "failed",
  "detail": "Device host local is unavailable: Device host local failed while waiting for the device hub to answer." }

Android is detected fine (available: true, hubInstalled: true,
agentDeviceInstalled: true) — an emulator is installed and booted, and
npx agent-device drives it without trouble. Only t3's own device integration
is dead. The bundled ~/.t3/userdata/device/bin/agent-device shim is broken the
same way: it prints t3's own --help instead of running agent-device.

Diagnosis

resolveNodeExecutable (packages/shared/src/nodeRuntime.ts:34-41) returns the
host executable path when HostProcessIsExecutable is false:

/** A standalone T3 binary runs its embedded CLI, regardless of script arguments. */
export const resolveNodeExecutable = Effect.fn(...)(function* (feature, environment?) {
  const executablePath = yield* HostProcessExecutablePath;   // process.execPath
  if (!(yield* HostProcessIsExecutable)) return executablePath;
  // ...otherwise resolve a real `node` from PATH...

HostProcessIsExecutable defaults to NodeSea.isSea()
(packages/shared/src/hostProcess.ts:74-79). In the WSL runtime build
isSea() evaluates to false, so t3 concludes it is already running under Node
and hands back its own path. LocalDeviceHost.ts:544 takes that as nodePath,
and spawnHub (LocalDeviceHost.ts:336-355) spawns:

<t3 binary> <hub entryPath> --port <port> --host 127.0.0.1 --hide-sidebar --hide-boot-device

The standalone binary cannot run a script argument — which is exactly what the
doc comment above resolveNodeExecutable says. --hide-sidebar is not a t3
flag, so the child prints t3's help and exits 1. Nothing ever binds the port,
and waitForHttpReady polls /readyz for 30s (~290 attempts) before failing.

Verified the hub itself is healthy — run under real Node it works immediately:

node ~/.t3/tools/expo-device-hub/0.9.0/node_modules/expo-device-hub/dist/server/cli.mjs --port 36851
# Expo Device Hub ready -> Local: http://localhost:36851
# /readyz -> {"status":"ready","device":"no-device-id"}

Also confirmed the spawned child never executes the entry file at all: replacing
the hub's dist/server/cli.mjs with a wrapper that appends one line to a log
before importing the real module produced no log output under t3's own spawn.

Same root cause reaches AgentDeviceShim.ts:24 and DeviceService.ts:1025,
which is why the bundled agent-device shim prints t3's help: the shim execs
t3 agent-device-launcher.mjs, and the launcher's own inner spawn goes
through the same binary again.

Both runtime builds present on this machine behave identically, so this is not a
recent regression.

Steps to reproduce

On WSL2, with a standalone/SEA t3 runtime:

echo 'console.log("HELLO")' > /tmp/probe.mjs
~/.t3/wsl-runtime/sha256-<hash>/t3 /tmp/probe.mjs

Expected: prints HELLO.
Actual: the path is parsed as the optional cwd positional and t3 tries to
create it as a directory:

ERROR PlatformError: AlreadyExists: FileSystem.makeDirectory (/tmp/probe.mjs)
  [cause]: Error: EEXIST: file already exists, mkdir '/tmp/probe.mjs'

Then, in any thread, call device_list and watch the local host fail after 30s.

Version

v0.0.41-nightly.20260916.1795

Environment

WSL2 (Linux 6.6.87.2-microsoft-standard-WSL2) on Windows, Node v24.18.0 (nvm),
Android SDK in WSL with emulator + platform-tools, expo-device-hub 0.9.0,
agent-device 0.20.10 (bundled).

Evidence

LocalDeviceHost.spawnHub  durationMs 30014
  Failure: DeviceHostError: Device host local failed while waiting for the device hub to answer.

shared.httpReadiness.waitForHttpReady  durationMs 30009
  httpReadiness.timedOut { requestUrl: "http://127.0.0.1:36817/readyz",
                           timeoutMs: 30000, intervalMs: 100, attempts: 290 }

http.client GET http://127.0.0.1:36851/readyz
  Failure: HttpClientError: Transport error

# actual spawned child, captured by polling the process table:
<t3 binary> ~/.t3/tools/expo-device-hub/0.9.0/node_modules/expo-device-hub/dist/server/cli.mjs \
  --port 38261 --host 127.0.0.1 --hide-sidebar --hide-boot-device

Note: the child's stdout/stderr are piped but do not appear in
userdata/logs/server.trace.ndjson; surfacing them would have made this
diagnosable in minutes rather than by polling /proc.

Suggested fix

Either make HostProcessIsExecutable true for the WSL runtime build (so the
real-node resolution path runs), or drop the isSea() dependency and decide
by testing whether the host executable can actually run a script. Logging the
hub child's stderr on readiness timeout would help regardless.


Filed after investigation with Claude Code (Opus 5).

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

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions