From 2db09a7e2498e2e921ce4f78d12fcf9fac4a4439 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Sat, 10 Oct 2026 11:44:19 +0200 Subject: [PATCH 1/2] fix(android): drive emulators without a gRPC bridge through the adb controller An emulator started outside argent under another HOME (a CI runner, a service account) has no gRPC discovery file where argent looks, so every screenshot, tap and swipe failed with 'not found among running emulators'. Retry such emulators with the screen-sharing (android_device) controller, which only needs adb. --- .../src/blueprints/simulator-server.ts | 33 ++++++++++++++++--- .../test/simulator-server-blueprint.test.ts | 29 ++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/tool-server/src/blueprints/simulator-server.ts b/packages/tool-server/src/blueprints/simulator-server.ts index 72d620a4a4..3aa1e3aed5 100644 --- a/packages/tool-server/src/blueprints/simulator-server.ts +++ b/packages/tool-server/src/blueprints/simulator-server.ts @@ -298,6 +298,34 @@ function subcommandForDevice(device: DeviceInfo): "ios" | "android" | "android_d return device.kind === "device" ? "android_device" : "android"; } +/** + * The emulator controller finds an AVD's gRPC bridge through the emulator's + * discovery file (`avd/running/pid_*.ini` under the user's home). An emulator + * started outside argent with another HOME (a CI runner, a service account) + * has no such file where argent looks, so simulator-server exits with "not + * found among running emulators". The phone controller (screen-sharing agent + * over adb) needs nothing but adb, so such an emulator is retried through it + * instead of leaving the device with no screenshot, tap or swipe at all. + */ +const EMULATOR_NOT_DISCOVERED = /not found among running emulators/; + +async function spawnWithEmulatorFallback( + device: DeviceInfo +): ReturnType { + const subcommand = subcommandForDevice(device); + try { + return await spawnSimulatorServerProcess(device.id, subcommand); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (subcommand !== "android" || !EMULATOR_NOT_DISCOVERED.test(message)) throw err; + process.stderr.write( + `[sim ${device.id}] emulator not found through gRPC discovery; ` + + `falling back to the adb screen-sharing controller\n` + ); + return spawnSimulatorServerProcess(device.id, "android_device"); + } +} + async function spawnSimulatorServerProcess( udid: string, subcommand: "ios" | "android" | "android_device" @@ -567,10 +595,7 @@ export const simulatorServerBlueprint: ServiceBlueprint(); diff --git a/packages/tool-server/test/simulator-server-blueprint.test.ts b/packages/tool-server/test/simulator-server-blueprint.test.ts index e6a96ea7c5..e66b53e036 100644 --- a/packages/tool-server/test/simulator-server-blueprint.test.ts +++ b/packages/tool-server/test/simulator-server-blueprint.test.ts @@ -210,6 +210,35 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf }); }); + it("falls back to `android_device` when the emulator's gRPC discovery file is missing", async () => { + // An emulator launched outside argent under another HOME has no discovery + // file where argent looks, so the gRPC controller cannot find it; the adb + // screen-sharing controller still drives it. + const grpcLess = makeFakeProc(); + const fallback = makeFakeProc(); + spawnMock.mockReturnValueOnce(grpcLess).mockReturnValueOnce(fallback); + + const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server"); + + const serial = "emulator-5570"; + const device = androidDevice(serial); + const factoryPromise = simulatorServerBlueprint.factory({}, device, { device }); + setImmediate(() => { + grpcLess.stderr.push( + 'Error: emulator "emulator-5570" not found among running emulators (a token-less `-grpc` launch omits grpc.token; check avd/running/*.ini)\n' + ); + setTimeout(() => { + grpcLess.emit("exit", 1); + signalReady(fallback, 55570); + }, 20); + }); + await factoryPromise; + + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock.mock.calls[0]![1]).toEqual(["android", "--id", serial]); + expect(spawnMock.mock.calls[1]![1]).toEqual(["android_device", "--id", serial]); + }); + it("spawns the `android_device` subcommand for a physical Android device", async () => { // A physical phone (kind 'device') is driven by a different simulator-server // controller than an emulator — the screen-sharing-agent path. The blueprint From f53f8a40eb2ae6044115c7cea1821f1eeea8e436 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Thu, 22 Oct 2026 00:27:27 +0200 Subject: [PATCH 2/2] fix(android): also fall back when Linux has no emulator discovery directory On Linux, a user with no avd/running directory gets 'Failed to find any running emulator' instead of 'not found among running emulators', so the fallback never triggered there. Match both, keep the emulator controller's error when the adb fallback fails too, and update the comments that said emulators always use the gRPC bridge. --- .../src/blueprints/simulator-server.ts | 36 +++++++++---- packages/tool-server/src/utils/device-info.ts | 4 +- .../test/simulator-server-blueprint.test.ts | 50 ++++++++++++++++--- 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/packages/tool-server/src/blueprints/simulator-server.ts b/packages/tool-server/src/blueprints/simulator-server.ts index 3aa1e3aed5..03e2907e04 100644 --- a/packages/tool-server/src/blueprints/simulator-server.ts +++ b/packages/tool-server/src/blueprints/simulator-server.ts @@ -279,7 +279,9 @@ const SAFE_SIMULATOR_DEVICE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; /** * iOS simulators, Android emulators and physical Android phones each get their * own simulator-server controller; the phone one (`android_device`, screen-sharing - * agent over adb) is picked by `kind`, not by platform alone. + * agent over adb) is picked by `kind`, not by platform alone. An emulator whose + * gRPC bridge cannot be discovered is later retried on `android_device` too + * (see `spawnWithEmulatorFallback`), while keeping `kind: "emulator"`. */ function subcommandForDevice(device: DeviceInfo): "ios" | "android" | "android_device" { if (device.platform === "ios") { @@ -300,14 +302,18 @@ function subcommandForDevice(device: DeviceInfo): "ios" | "android" | "android_d /** * The emulator controller finds an AVD's gRPC bridge through the emulator's - * discovery file (`avd/running/pid_*.ini` under the user's home). An emulator - * started outside argent with another HOME (a CI runner, a service account) - * has no such file where argent looks, so simulator-server exits with "not - * found among running emulators". The phone controller (screen-sharing agent - * over adb) needs nothing but adb, so such an emulator is retried through it - * instead of leaving the device with no screenshot, tap or swipe at all. + * discovery file (`avd/running/pid_*.ini` in a per-user directory: under the + * user's home or cache folder, or `$XDG_RUNTIME_DIR` / `/run/user/` on + * Linux). An emulator started outside argent by another user or HOME (a CI + * runner, a service account) has no such file where argent looks, so + * simulator-server exits with "not found among running emulators", or, on + * Linux when no discovery directory exists at all, "Failed to find any running + * emulator". The phone controller (screen-sharing agent over adb) needs + * nothing but adb, so such an emulator is retried through it instead of + * leaving the device with no screenshot, tap or swipe at all. */ -const EMULATOR_NOT_DISCOVERED = /not found among running emulators/; +const EMULATOR_NOT_DISCOVERED = + /not found among running emulators|Failed to find any running emulator/; async function spawnWithEmulatorFallback( device: DeviceInfo @@ -322,7 +328,19 @@ async function spawnWithEmulatorFallback( `[sim ${device.id}] emulator not found through gRPC discovery; ` + `falling back to the adb screen-sharing controller\n` ); - return spawnSimulatorServerProcess(device.id, "android_device"); + try { + return await spawnSimulatorServerProcess(device.id, "android_device"); + } catch (fallbackErr) { + // "Failed to find any running emulator" also means no emulator is up at + // all; then the fallback fails too, and the emulator controller's error + // (and its failure signal) is the one that explains it. + const fallbackMessage = + fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr); + process.stderr.write( + `[sim ${device.id}] adb screen-sharing fallback failed too: ${fallbackMessage}\n` + ); + throw err; + } } } diff --git a/packages/tool-server/src/utils/device-info.ts b/packages/tool-server/src/utils/device-info.ts index 57e7f739e5..dc636a0ece 100644 --- a/packages/tool-server/src/utils/device-info.ts +++ b/packages/tool-server/src/utils/device-info.ts @@ -84,7 +84,9 @@ export function classifyDevice(udid: string): Platform { * * The distinction picks the simulator-server controller: emulators go through * the emulator gRPC bridge (`android` subcommand), physical devices through the - * screen-sharing agent over adb (`android_device`). + * screen-sharing agent over adb (`android_device`). An emulator whose gRPC + * bridge cannot be discovered (started by another user or HOME) falls back to + * `android_device` as well; see the simulator-server blueprint. */ export function isAndroidEmulatorSerial(serial: string): boolean { return serial.startsWith("emulator-"); diff --git a/packages/tool-server/test/simulator-server-blueprint.test.ts b/packages/tool-server/test/simulator-server-blueprint.test.ts index e66b53e036..359c228195 100644 --- a/packages/tool-server/test/simulator-server-blueprint.test.ts +++ b/packages/tool-server/test/simulator-server-blueprint.test.ts @@ -210,7 +210,13 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf }); }); - it("falls back to `android_device` when the emulator's gRPC discovery file is missing", async () => { + it.each([ + [ + "the discovery file is missing", + 'Error: emulator "emulator-5570" not found among running emulators (a token-less `-grpc` launch omits grpc.token; check avd/running/*.ini)\n', + ], + ["no discovery directory exists (Linux)", "Error: Failed to find any running emulator\n"], + ])("falls back to `android_device` when %s", async (_case, discoveryError) => { // An emulator launched outside argent under another HOME has no discovery // file where argent looks, so the gRPC controller cannot find it; the adb // screen-sharing controller still drives it. @@ -224,9 +230,7 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf const device = androidDevice(serial); const factoryPromise = simulatorServerBlueprint.factory({}, device, { device }); setImmediate(() => { - grpcLess.stderr.push( - 'Error: emulator "emulator-5570" not found among running emulators (a token-less `-grpc` launch omits grpc.token; check avd/running/*.ini)\n' - ); + grpcLess.stderr.push(discoveryError); setTimeout(() => { grpcLess.emit("exit", 1); signalReady(fallback, 55570); @@ -239,6 +243,36 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf expect(spawnMock.mock.calls[1]![1]).toEqual(["android_device", "--id", serial]); }); + it("surfaces the emulator controller's error when the `android_device` fallback fails too", async () => { + // With no emulator running at all, both controllers fail; the original + // discovery error explains it better than the phone controller's. + const grpcLess = makeFakeProc(); + const fallback = makeFakeProc(); + spawnMock.mockReturnValueOnce(grpcLess).mockReturnValueOnce(fallback); + + const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server"); + + const device = androidDevice("emulator-5572"); + const factoryPromise = simulatorServerBlueprint.factory({}, device, { device }); + setImmediate(() => { + grpcLess.stderr.push("Error: Failed to find any running emulator\n"); + grpcLess.stderr.push(null); + grpcLess.emit("exit", 1, null); + setTimeout(() => { + fallback.stderr.push("Error: device emulator-5572 not found\n"); + fallback.stderr.push(null); + fallback.emit("exit", 1, null); + }, 20); + }); + + const error = (await factoryPromise.catch((e: unknown) => e)) as Error; + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(error.message).toContain("Error: Failed to find any running emulator"); + expect(getFailureSignal(error)).toMatchObject({ + error_code: "SIMULATOR_SERVER_READY_EXITED", + }); + }); + it("spawns the `android_device` subcommand for a physical Android device", async () => { // A physical phone (kind 'device') is driven by a different simulator-server // controller than an emulator — the screen-sharing-agent path. The blueprint @@ -322,7 +356,7 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf fakeProc.stderr.push( "[2026-09-23T16:23:35Z INFO simulator_server::media_handler::screenshot_service] Screenshot service stopped\n" ); - fakeProc.stderr.push("Error: Failed to find any running emulator\n"); + fakeProc.stderr.push("Error: Failed to connect to the emulator gRPC endpoint\n"); // `exit` can fire before stderr drains; the reason must still make it in. fakeProc.emit("exit", 1, null); setImmediate(() => fakeProc.stderr.push(null)); @@ -330,7 +364,7 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf const error = (await factoryPromise.catch((e: unknown) => e)) as Error; expect(error.message).toMatch(/^simulator-server exited with code 1 before becoming ready/); - expect(error.message).toContain("Error: Failed to find any running emulator"); + expect(error.message).toContain("Error: Failed to connect to the emulator gRPC endpoint"); expect(error.message).not.toContain("Screenshot service stopped"); expect(getFailureSignal(error)).toMatchObject({ error_code: "SIMULATOR_SERVER_READY_EXITED", @@ -366,7 +400,7 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf const device = androidDevice("emulator-5554"); const factoryPromise = simulatorServerBlueprint.factory({}, device, { device }); setImmediate(() => { - fakeProc.stderr.push("Error: Failed to find any running emulator\n"); + fakeProc.stderr.push("Error: Failed to connect to the emulator gRPC endpoint\n"); for (let i = 0; i < 12; i++) { fakeProc.stderr.push( `[2026-09-23T16:23:35Z INFO simulator_server::media_handler] shutdown step ${i}\n` @@ -377,7 +411,7 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf }); const error = (await factoryPromise.catch((e: unknown) => e)) as Error; - expect(error.message).toContain("Error: Failed to find any running emulator"); + expect(error.message).toContain("Error: Failed to connect to the emulator gRPC endpoint"); expect(error.message).not.toContain("shutdown step"); });