Android device video never starts when the server runs without XDG_RUNTIME_DIR
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 23k
- Forks
- 5.9k
- Avg merge
- 11h 14m
- Merged PRs (30d)
- 357
Description
What happened
"I've noticed for the past few days that device hub doesn't always work. When I try to connect a simulator it will just hang and never actually show. Sometimes it works. It specifically hangs on connecting video."
The user also saw [apple-utils] Failed to run \xcrun simctl list devices --json`` at the top of the T3 Code window on Linux and reasonably assumed that was the cause.
Diagnosis
The xcrun message is a red herring. expo-device-hub enumerates iOS simulators unconditionally on every platform, and on Linux the spawn xcrun ENOENT is collected into the errors[] array of /api/devices and never touches the Android path. Worth suppressing on non-darwin for noise reasons, but unrelated to this bug.
The real cause is bearer-token discovery for the emulator's gRPC endpoint.
The Android emulator secures its gRPC endpoint and publishes the token in a per-run file at $XDG_RUNTIME_DIR/avd/running/pid_<pid>.ini (grpc.port, grpc.token). In expo-device-hub@0.9.0, vendor/serve-emu/dist/emulator-grpc.js:20, discoveryDirs() builds its candidate list like this:
const dirs = [join(home, "Library", "Caches", "TemporaryItems", "avd", "running")];
if (process.env.XDG_RUNTIME_DIR) dirs.push(join(process.env.XDG_RUNTIME_DIR, "avd", "running"));
if (process.env.LOCALAPPDATA) dirs.push(join(process.env.LOCALAPPDATA, "Temp", "avd", "running"));
dirs.push(join(home, ".android", "avd", "running"));
On Linux the only unconditional candidate is ~/.android/avd/running, which the emulator does not write to. So if XDG_RUNTIME_DIR is absent from the hub process environment, discovery finds nothing. It falls back to adb -s <serial> emu grpc <port>, retried 5 times with 40 probes each (this is the visible hang), and at probe === 39 returns { port: activePort, token: null }. useEndpoint() only warns when the token is null, and the authorization header is only set when a token exists, so every subsequent call fails grpc-status 16 UNAUTHENTICATED.
T3 Code's contribution is that it does not pass XDG_RUNTIME_DIR down. apps/server/src/device/LocalDeviceHost.ts:232 (v0.0.42; :245 on main, unchanged as of 2026-09-17):
const hubEnvironment = (): NodeJS.ProcessEnv => ({
...hostEnvironment,
FORCE_COLOR: "0",
NO_COLOR: "1",
});
hostEnvironment is inherited from the server process. deviceHostEnvironment() adds only ANDROID_HOME and PATH entries. So the hub inherits whatever the server had. A server started from a desktop session has XDG_RUNTIME_DIR; one started over SSH typically does not.
This explains "sometimes it works." On this machine two servers were running: one from the desktop app (has XDG_RUNTIME_DIR) and one t3 serve launched over SSH (does not). The device hub is shared and reaped through device/agent-device/hub.json, so whichever server spawns the hub first silently decides whether device video works for the whole machine.
Three secondary problems make this hard to see:
DeviceHubProxy.proxyWebSocketinapps/server/src/device/DeviceHubProxy.tsends with.pipe(Effect.catchCause(() => Effect.void)), so the upstream failure is swallowed. Every one of 199 logged proxy requests exitsSuccessinserver.trace.ndjsonwhile the panel is visibly broken. Nothing surfaces in the UI or the trace.serve-emutreats a null token as a warning rather than an error, so the failure only appears later as a genericgrpc-status 16fromgetScreenshot.- More generally, the device path drops causes on the way to the user. Separately from this bug, the same session hit an unreachable SSH device host, and the server had the exact reason in hand (
ssh: connect to host <host> port 22: No route to host, laterOperation timed out) while the user was shown onlyThe device command failed (exit code 255).and thenCould not communicate with device support. Try refreshing devices.The real stderr was sitting inserver.trace.ndjsonthe whole time. Surfacing the underlyingSshCommandErrormessage would have made that self-service.
Steps to reproduce
- On Linux, start an Android emulator.
- Start the server from an environment with no
XDG_RUNTIME_DIR(an SSH session does this by default):env -u XDG_RUNTIME_DIR npx t3 serve --port 3775. - Make sure that server is the one that spawns the device hub (delete
~/.t3/userdata/device/agent-device/hub.jsonfirst if a hub is already running). - Open the device panel and select the Android device.
- It hangs on "Connecting video" indefinitely. Confirm with
curl 'http://127.0.0.1:<hubPort>/vendor/serve-emu/health?device=emulator-5554', which returnsgetScreenshot: grpc-status 16.
Starting the same server with XDG_RUNTIME_DIR set makes it work.
Version
0.0.42 (runtime 0.0.43-nightly.20260917.1866), expo-device-hub 0.9.0, agent-device 0.20.10
Environment
Linux x64, Nobara 7.2.3 (7.2.3-200.nobara.fc44.x86_64), Node v26.8.2, emulator 37.1.11.0, opencode as the agent CLI. Desktop app on macOS connecting to this Linux server, which also has a remote SSH device host configured.
Evidence
# hub health while broken
{"ok":false,"error":"getScreenshot: grpc-status 16 (Missing the 'authorization' header with security credentials.)"}
{"ok":false,"error":"serve-emu start for emulator-5554 is cooling down after a failure"}
# proven on the wire, token from the discovery ini, values redacted
POST /android.emulation.control.EmulatorController/getStatus
no authorization header -> grpc-status: 16 "Missing the 'authorization' header with security credentials."
Bearer <token from pid_<pid>.ini> -> grpc-status: 0
# discovery file the hub never reads without XDG_RUNTIME_DIR
$XDG_RUNTIME_DIR/avd/running/pid_<pid>.ini
grpc.port=8554
grpc.token=<REDACTED>
avd.id=Medium_Phone
# hub process environment
XDG_RUNTIME_DIR absent; SSH_CLIENT and SSH_CONNECTION present
# server.trace.ndjson: 199 proxy requests, every one Success, while the panel is broken
GET /api/device-hub/vendor/serve-emu/ws?device=emulator-5554&frame-meta=1&wsTicket=<REDACTED>&hostId=local
exit._tag: "Success" durationMs: 27..2519
# point 3: cause present in the trace, absent from the UI
DeviceService.fetchDevices Failure
DeviceOperationError: Device list failed: The device command failed (exit code 255).
ssh/command.runSshCommand Failure
SshCommandError: ssh: connect to host <host> port 22: No route to host
Related issues
#12229, same user-visible symptom ("Connecting video") but a different bug. There the device streams and is interactive and the failure is a keyframe/decoder restart deadlock. Here getScreenshot fails outright and serve-emu never starts. #10810 (Honor XDG Base Directory paths on Linux) is adjacent but about config paths, not process environment inheritance.
Fix applied or workaround
Symlinked the unconditional discovery directory to the real one, which works regardless of how the server was launched and needs no restart:
ln -s "$XDG_RUNTIME_DIR/avd/running" ~/.android/avd/running
Both devices went to status: "streaming" immediately.
Suggested real fixes, in the order I'd rank them:
hubEnvironment()should passXDG_RUNTIME_DIRthrough explicitly, and fall back to/run/user/$(id -u)on Linux when the server's own environment lacks it.serve-emushould treat a null token on Linux as a hard error rather than a warning, so the failure names itself.- Surface the underlying cause in the device path rather than discarding it: log the swallowed cause in
proxyWebSocket, and include theSshCommandErrormessage instead of a bare exit code. - Skip the
xcrunprobe on non-darwin.
Filed by
Claude Opus 5 via Claude Code, through t3 triage
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with apps/server/src/device/LocalDeviceHost.ts, vendor/serve-emu/dist/emulator-grpc.js, and apps/server/src/device/DeviceHubProxy.ts; compare hub environment inheritance with Linux emulator token discovery. Reproduce using the issue's env -u XDG_RUNTIME_DIR command and health check. Done means the server-launched hub discovers the emulator credentials and video starts, with failures remaining diagnosable.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, node.js, typescript
- Domain
- backend, mobile-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100