Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 48 additions & 5 deletions packages/tool-server/src/blueprints/simulator-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -298,6 +300,50 @@ 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` in a per-user directory: under the
* user's home or cache folder, or `$XDG_RUNTIME_DIR` / `/run/user/<uid>` 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|Failed to find any running emulator/;

async function spawnWithEmulatorFallback(
device: DeviceInfo
): ReturnType<typeof spawnSimulatorServerProcess> {
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`
);
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;
}
}
}

async function spawnSimulatorServerProcess(
udid: string,
subcommand: "ios" | "android" | "android_device"
Expand Down Expand Up @@ -567,10 +613,7 @@ export const simulatorServerBlueprint: ServiceBlueprint<SimulatorServerApi, Devi
);
}

const { proc, apiUrl, streamUrl } = await spawnSimulatorServerProcess(
device.id,
subcommandForDevice(device)
);
const { proc, apiUrl, streamUrl } = await spawnWithEmulatorFallback(device);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: A tap that is the first call to reach a fallen-back emulator is reported as done but does not land. Each trial: Settings open, search bar frame from uiautomator dump [42,591][1038,728] on a 1080x2400 screen, then stop-simulator-server, then gesture-tap at (0.5, 0.2747), then the top activity after 2 s.

Run Tap result Top activity
This PR, fallen-back emulator, tap as the first call tapped:true (~1.1 s) .Settings in 3/3
This PR, same, one screenshot before the tap tapped:true SearchActivity in 3/3
main, normal emulator, tap as the first call tapped:true SearchActivity in 3/3

Every respawn of this service is exposed: first use, after stop-simulator-server, and after the registry's connection-refused recovery. Each one hands back a server whose first touch is silently dropped.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving open: the dropped first tap needs a warm-up or readiness fix verified on a real emulator, which I did not do unattended.


const events = new TypedEventEmitter<ServiceEvents>();

Expand Down
4 changes: 3 additions & 1 deletion packages/tool-server/src/utils/device-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-");
Expand Down
71 changes: 67 additions & 4 deletions packages/tool-server/test/simulator-server-blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,69 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf
});
});

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.
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(discoveryError);
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("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
Expand Down Expand Up @@ -293,15 +356,15 @@ 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));
});

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",
Expand Down Expand Up @@ -337,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`
Expand All @@ -348,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");
});

Expand Down
Loading