Repository navigation
Android device video never starts when the server runs without XDG_RUNTIME_DIR #12313
Description
Activity
Triage
Confirmed on current
main(4749035bd) and on v0.0.42. This is a real Linux device-hub bug, not thexcrunbanner and not a duplicate of #12229. Not already fixed. Pinned tools still match the report:expo-device-hub@0.9.0,agent-device@0.20.10.What happens
The Android emulator publishes
grpc.port/grpc.tokenunder$XDG_RUNTIME_DIR/avd/running/pid_<pid>.ini. Inexpo-device-hub@0.9.0vendor/serve-emu/dist/emulator-grpc.js,discoveryDirs()only adds that path whenprocess.env.XDG_RUNTIME_DIRis set. The unconditional Linux candidate is~/.android/avd/running, which the emulator does not write. With no ini, serve-emu falls back toadb emu grpc, retries, and atprobe === 39returns{ token: null }.useEndpoint()only warns; latergetScreenshotfailsgrpc-status 16(missingauthorization). That matches the health JSON and the wire capture in this report.T3 starts the hub with:
const hubEnvironment = (): NodeJS.ProcessEnv => ({ ...hostEnvironment, FORCE_COLOR: "0", NO_COLOR: "1", });
hostEnvironmentisprocess.envplusANDROID_HOME/ PATH. There is no Linux fallback. A desktop-launched server usually hasXDG_RUNTIME_DIR;t3 serveover SSH usually does not. The desktop shell already knows the fallback (linuxRuntimeDirCandidates→/run/user/${uid}inDesktopShellEnvironment.ts); the hub child does not.The “sometimes it works” story is right in spirit, with one correction:
reapStaleHubkills a leftover hub on the same userdata andspawnHubstarts a new one. On a shared~/.t3/userdata, the last server to callensureHubReadywins, and that process’s env decides whether video works for everyone.The
xcrun simctlline is noise.fetchDevicesjoins/api/deviceserrors[]intohostStatusDetail, andDevicePanelshows that banner while the host is otherwise ready. expo-device-hub enumerates iOS on every platform; on Linux that isspawn xcrun ENOENTand never touches Android.Why this is hard to see
DeviceHubProxy.proxyWebSocketends withEffect.catchCause(() => Effect.void), so the 199 proxy rows inserver.trace.ndjsonareSuccesswhile the canvas stays on “Connecting video…”. Separately,DeviceOperationErrorrenderscommand_failed/request_failedas a bare exit code or “Could not communicate with device support,” even whenSshCommandErroralready hasssh: connect to host … No route to hostin the cause. That SSH host is a different failure in the same session, not this video bug.Not a duplicate
- Android device panel never leaves "Connecting video…": the keyframe request after decoder configure restarts the encoder, which tears the decoder back down #12229 — same UI string. There the device is interactive and frames exist; the client and serve-emu deadlock on keyframe /
video-session. HeregetScreenshotfails and serve-emu never starts. - Honor XDG Base Directory paths on Linux #10810 — XDG config/state layout, not hub process environment.
Suggested smallest scope
In
hubEnvironment(), on Linux, ifXDG_RUNTIME_DIRis missing, set it to/run/user/${uid}when that directory exists (HostProcessUserIdis already inhostProcess.ts). Same pattern as the desktop shell. That is enough to find the emulator’s ini when the emulator was started from a graphical session.Do not block on expo-device-hub. Useful follow-ups, not required to unblock: log the swallowed proxy cause; put the underlying
SshCommandError/ stderr in the device error string; skipxcrunon non-darwin (upstream).Workaround
The report’s symlink is correct and does not need a hub restart:
ln -s "$XDG_RUNTIME_DIR/avd/running" ~/.android/avd/running
~/.android/avd/runningis always on the discovery list. Starting the server withXDG_RUNTIME_DIRset also works, as long as that server is the one that last spawned the hub.- Android device panel never leaves "Connecting video…": the keyframe request after decoder configure restarts the encoder, which tears the decoder back down #12229 — same UI string. There the device is interactive and frames exist; the client and serve-emu deadlock on keyframe /
- addedbugSomething is broken or behaving incorrectly.Something is broken or behaving incorrectly.acceptedfeature request acceptedfeature request acceptedvia-triageFiled through npx t3 triageFiled through npx t3 triage
on Sep 17, 2026 Looking into starting Android device video when the server has no XDG_RUNTIME_DIR.
Note
Grok responding on behalf of Julius.
Fixed by #12402 (merged in f729e0e): when the server starts on Linux without
XDG_RUNTIME_DIR, the device hub now falls back to/run/user/<uid>if that directory exists and belongs to the user. Values you set yourself are left alone. That's the smallest fix suggested in the triage above, so the hub can find the emulator's gRPC token again and Android video shouldn't hang on "Connecting video" anymore.The other follow-ups (logging the proxy error that gets swallowed, showing SSH error details, and skipping
xcrunon non-macOS) were optional and aren't part of this fix. Feel free to open a separate issue for any of them. Closing this as completed. If video still hangs on a nightly that includes #12402, please reopen.

What happened
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
xcrunmessage is a red herring.expo-device-hubenumerates iOS simulators unconditionally on every platform, and on Linux thespawn xcrun ENOENTis collected into theerrors[]array of/api/devicesand 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). Inexpo-device-hub@0.9.0,vendor/serve-emu/dist/emulator-grpc.js:20,discoveryDirs()builds its candidate list like this:On Linux the only unconditional candidate is
~/.android/avd/running, which the emulator does not write to. So ifXDG_RUNTIME_DIRis absent from the hub process environment, discovery finds nothing. It falls back toadb -s <serial> emu grpc <port>, retried 5 times with 40 probes each (this is the visible hang), and atprobe === 39returns{ 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 failsgrpc-status 16UNAUTHENTICATED.T3 Code's contribution is that it does not pass
XDG_RUNTIME_DIRdown.apps/server/src/device/LocalDeviceHost.ts:232(v0.0.42;:245onmain, unchanged as of 2026-09-17):hostEnvironmentis inherited from the server process.deviceHostEnvironment()adds onlyANDROID_HOMEand PATH entries. So the hub inherits whatever the server had. A server started from a desktop session hasXDG_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 onet3 servelaunched over SSH (does not). The device hub is shared and reaped throughdevice/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.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
XDG_RUNTIME_DIR(an SSH session does this by default):env -u XDG_RUNTIME_DIR npx t3 serve --port 3775.~/.t3/userdata/device/agent-device/hub.jsonfirst if a hub is already running).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_DIRset makes it work.Version
0.0.42 (runtime 0.0.43-nightly.20260917.1866),
expo-device-hub0.9.0,agent-device0.20.10Environment
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
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
getScreenshotfails outright andserve-emunever 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:
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.proxyWebSocket, and include theSshCommandErrormessage instead of a bare exit code.xcrunprobe on non-darwin.Filed by
Claude Opus 5 via Claude Code, through
t3 triage