From bf575671a9efe7a7d737aa1c7d3828c95ecd1f21 Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Thu, 8 Oct 2026 20:57:20 +0200 Subject: [PATCH 1/3] fix(android): reconnect emulators that adb leaves unauthorized An emulator restored from a quick-boot snapshot can stay unauthorized because adb does not repeat the key exchange, so the panel waited out the boot timeout and listed the bare serial next to the stopped AVD. Name emulators through the console in every adb state, and reconnect the adb transport at most every 5 seconds while an AVD boots or after selecting a running emulator. Both paths share one readiness check that waits for authorization and sys.boot_completed. Count reconnects. --- docs/devices.md | 2 +- docs/telemetry.md | 2 ++ src/server/serve-emu.ts | 49 ++++++++++++++++++++++++++--------- src/server/telemetry.ts | 6 +++++ tests/android-devices.test.ts | 17 ++++++++++++ 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/docs/devices.md b/docs/devices.md index fb30d844..b5e2afe9 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -34,7 +34,7 @@ You can request fullscreen or a tool-only workflow. Planning, docs, code review, Install Android SDK platform-tools and emulator. Create an AVD in Android Studio or connect an Android device and authorize adb access. The Android panel lists devices without booting one. Selecting an AVD boots it if needed, then starts the bundled Node.js serve-emu CLI on a private loopback port. Home, Back, Recents, Lock, pointer gestures, and typing use scrcpy's control socket. Closing the panel leaves the emulator running. AVDs start without a separate emulator window. A failed emulator process reports its exit right away instead of waiting for the boot timeout. -The Android dropdown shows **Connected devices** first, with USB or Wi-Fi labels, then **Emulators**. It refreshes every three seconds while the Android panel is visible, and when opening the dropdown. Physical devices use ADB discovery: enable USB debugging and authorize the computer, or pair the device for wireless debugging. Offline and unauthorized devices remain visible with their state. Selecting an authorized phone connects its screen and shares its serial and transport with the chat. Bundled scrcpy mirrors and controls the phone without installing a companion app. Physical Android devices support the existing screen, input, screenshot, native log, and performance tools; emulator boot and stop controls do not apply to them. +The Android dropdown shows **Connected devices** first, with USB or Wi-Fi labels, then **Emulators**. It refreshes every three seconds while the Android panel is visible, and when opening the dropdown. Physical devices use ADB discovery: enable USB debugging and authorize the computer, or pair the device for wireless debugging. Offline and unauthorized devices remain visible with their state. Emulators keep their AVD name while unauthorized. An emulator restored from a quick-boot snapshot can stay unauthorized because adb does not repeat the key exchange; the panel reconnects adb at most every 5 seconds while an AVD boots, and for up to 15 seconds after you select an already running emulator. Selecting an authorized phone connects its screen and shares its serial and transport with the chat. Bundled scrcpy mirrors and controls the phone without installing a companion app. Physical Android devices support the existing screen, input, screenshot, native log, and performance tools; emulator boot and stop controls do not apply to them. Android H.264 packets travel through MCP resource reads. The panel decodes them with WebCodecs, so the host must support H.264 `VideoDecoder`. Each device gets its own backend and each panel gets its own stream session. Decoder errors and video backlog request a fresh keyframe on the same connection. The panel drops delta frames until it can decode that keyframe. Requests have a cooldown, and stale decoder callbacks cannot repaint a closed stream. The plugin reuses a matching serve-emu server at port 3300. Set `SERVE_EMU_URL` to reuse another loopback HTTP server; it must already stream the selected serial. Closing MCP stops only backends the plugin started. diff --git a/docs/telemetry.md b/docs/telemetry.md index f3c825c9..f2e8e6e8 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -2,6 +2,8 @@ [Back to README](../README.md) · [Contributing](../CONTRIBUTING.md) +Since 0.1.140, `android.emulator.adb_reconnects` counts adb transport reconnects for emulators that stay unauthorized, with a fixed `trigger` (`boot` while waiting for an AVD, `selection` for an already running emulator). It uses the simulator surface and Android emulator context and sends no serials, AVD names or adb output. Existing Android startup diagnostics and MCP tool errors cover the boot outcome. + Since 0.1.139, `ios.accessibility_bridge.restarts` counts restarts of the simulator's CoreSimulatorBridge after a describe-UI read reported no accessibility data, with a fixed `outcome` (`recovered`, `unavailable` or `failed`). Failed restarts also report a handled `simulator.accessibility_bridge` error. The existing `inspection.tool` errors, sampled MCP traces and `ui.annotations.inspection` timings cover the retried read. No device identifiers, accessibility content or command output are sent. Since 0.1.134, physical iOS capture runs in the shared `ios-mirror-service` Node process. Existing native packet-processing, input acknowledgement, connection, resource, and crash instrumentation stays attached to the active capture. Native resource measurements now overlap that service's Node process rather than each chat's MCP process. The service uses centralized server initialization, its packaged development/release environment, and server-owned anonymous installation/process-session identity. UI decode/render timings and sampled MCP operations retain their existing boundaries. Subscriber keyframe requests no longer invalidate the shared native queue or release another panel’s touch. The native packet-processing and recovery measurements remain on this active path; the rebuilt addon retains matching debug symbols. diff --git a/src/server/serve-emu.ts b/src/server/serve-emu.ts index e278d6b2..b1c8c39f 100644 --- a/src/server/serve-emu.ts +++ b/src/server/serve-emu.ts @@ -14,7 +14,7 @@ import { adbPath } from "./native-logs.ts"; import { errorMessage, parseBaseUrl } from "../shared/protocol.ts"; import type { SimulatorDevice, Status } from "../shared/protocol.ts"; import { SimulatorUnavailableError } from "./simulator-unavailable.ts"; -import { recordAndroidBackendStartup, recordAndroidStartupStages, recordAndroidStartupContext, recordAndroidStartupDeviceState, recordAndroidBackendStop } from "./telemetry.ts"; +import { recordAndroidEmulatorReconnect, recordAndroidBackendStartup, recordAndroidStartupStages, recordAndroidStartupContext, recordAndroidStartupDeviceState, recordAndroidBackendStop } from "./telemetry.ts"; import { androidStartupMessageSchema, androidDeviceState, setAndroidStartupDiagnostic } from "../shared/android-startup-diagnostics.ts"; import type { AndroidStartupContext, AndroidStartupFailure, AndroidStartupSummary } from "../shared/android-startup-diagnostics.ts"; @@ -35,6 +35,7 @@ export function androidDisplaySize(output: string, video: { width: number; heigh export class ServeEmu { private readonly avdNames = new Map(); + private readonly reconnects = new Map(); private readonly backends = new Map(); private readonly booting = new Map>(); private readonly lifetime = new AbortController(); @@ -79,7 +80,8 @@ export class ServeEmu { const model = rawModel?.replace(/_/g, " "); let name = model ?? match[1]; const emulatorDevice = /^emulator-\d+$/.test(match[1]); - if (emulatorDevice && match[2] === "device") { + // The emulator console names the AVD before adb authorizes the device. + if (emulatorDevice) { const response = await execute(adb, ["-s", match[1], "emu", "avd", "name"], { timeout: 3000 }).catch(() => undefined); const avdName = parseAvdName(response?.stdout); if (avdName) this.avdNames.set(match[1], avdName); @@ -93,7 +95,7 @@ export class ServeEmu { })()]; }); const running = await Promise.all(runningReads); - for (const serial of this.avdNames.keys()) if (!running.some(device => device.udid === serial)) this.avdNames.delete(serial); + for (const serials of [this.avdNames, this.reconnects]) for (const serial of serials.keys()) if (!running.some(device => device.udid === serial)) serials.delete(serial); const avdOutput = avds.stdout.trim(); const avdLines = avdOutput.split(/\r?\n/); const stoppedNames = avdLines.filter(name => { @@ -133,22 +135,45 @@ export class ServeEmu { private async ensureBooted(id: string): Promise { const device = await this.device(id); if (device.state === "Booted") return this.list(); + if (device.kind === "emulator" && device.state === "unauthorized") return this.awaitRunningEmulator(id); if (!id.startsWith("avd:")) throw new SimulatorUnavailableError("Reconnect and authorize this Android device through adb."); - const adb = await adbPath(); // Launch separately from serve-emu so closing the panel never stops the AVD. return bootAndroidEmulator({ name: device.name, executable: await this.emulatorPath(), signal: this.lifetime.signal, - ready: async () => { - const status = await this.list(); - if (!status.connected) throw new Error(status.error); - const running = status.devices.find(item => item.kind === "emulator" && item.name === device.name && item.state === "Booted"); - if (!running) return; - const response = await execute(adb, ["-s", running.udid, "shell", "getprop", "sys.boot_completed"], { timeout: 3000 }).catch(() => undefined); - if (response?.stdout.trim() === "1") return status; - }, + ready: () => this.emulatorReady(item => item.name === device.name, "boot"), }); } + // Ready means adb authorized the emulator and Android finished booting. + private async emulatorReady(matches: (device: SimulatorDevice) => boolean, trigger: "boot" | "selection"): Promise { + const status = await this.list(); + if (!status.connected) throw new Error(status.error); + const running = status.devices.find(item => item.kind === "emulator" && matches(item)); + if (running?.state === "unauthorized") await this.reconnectEmulator(running.udid, trigger); + if (running?.state !== "Booted") return; + const response = await execute(await adbPath(), ["-s", running.udid, "shell", "getprop", "sys.boot_completed"], { timeout: 3000 }).catch(() => undefined); + if (response?.stdout.trim() === "1") return status; + } + + // A quick-boot snapshot can restore an emulator after adb has marked it unauthorized, and adb + // never checks again. Reconnecting the transport repeats the key exchange, at most every 5 s. + private async reconnectEmulator(serial: string, trigger: "boot" | "selection") { + if (Date.now() - (this.reconnects.get(serial) ?? -Infinity) < 5000) return; + this.reconnects.set(serial, Date.now()); + recordAndroidEmulatorReconnect(trigger); + await execute(await adbPath(), ["-s", serial, "reconnect"], { timeout: 5000 }).catch(() => {}); + } + + private async awaitRunningEmulator(serial: string): Promise { + const signal = AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(15000)]); + while (!signal.aborted) { + const status = await this.emulatorReady(device => device.udid === serial, "selection"); + if (status) return status; + await delay(500, undefined, { signal }).catch(() => {}); + } + throw new SimulatorUnavailableError("The Android emulator is still unauthorized. Restart it with a cold boot, or run adb kill-server and try again."); + } + async shutdown(id: string) { androidIdSchema.parse(id); if (!/^emulator-\d+$/.test(id)) throw new Error("Only Android emulators can be shut down from this panel."); diff --git a/src/server/telemetry.ts b/src/server/telemetry.ts index 171af7fb..213a268d 100644 --- a/src/server/telemetry.ts +++ b/src/server/telemetry.ts @@ -32,6 +32,12 @@ export function recordAccessibilityBridgeRestart(outcome: "recovered" | "unavail Sentry.metrics.count("ios.accessibility_bridge.restarts", 1, { attributes }); } +export function recordAndroidEmulatorReconnect(trigger: "boot" | "selection") { + if (process.env.MOBILE_DEV_TELEMETRY === "off") return; + const attributes = { component: "server", surface: "simulator", device_platform: "android", device_kind: "emulator", trigger }; + Sentry.metrics.count("android.emulator.adb_reconnects", 1, { attributes }); +} + export function recordAndroidBackendStartup(duration: number, outcome: "ready" | "failed") { if (process.env.MOBILE_DEV_TELEMETRY === "off") return; const attributes = { component: "server", surface: "simulator", device_platform: "android", outcome }; diff --git a/tests/android-devices.test.ts b/tests/android-devices.test.ts index 65b66ab4..09144ddf 100644 --- a/tests/android-devices.test.ts +++ b/tests/android-devices.test.ts @@ -38,6 +38,11 @@ if (args[0] === "devices") { process.stdout.write(output); } else if (args.join(" ") === "-s emulator-5554 emu avd name") console.log("RunningAVD\\nOK"); +else if (args.join(" ") === "-s emulator-5554 shell getprop sys.boot_completed") console.log("1"); +else if (args.join(" ") === "-s emulator-5554 reconnect") { + const output = fs.readFileSync(${JSON.stringify(devicesPath)}, "utf8"); + fs.writeFileSync(${JSON.stringify(devicesPath)}, output.replace("emulator-5554 unauthorized", "emulator-5554 device")); +} else process.exit(1); `, { mode: 0o755 }); const emulator = join(emulatorDirectory, "emulator"); @@ -104,3 +109,15 @@ test("Android discovery identifies USB, TCP, mDNS and IPv6 phones separately fro const remainingPhysical = disconnected.devices.filter(device => device.kind === "physical"); assert.equal(remainingPhysical.length, 0); }); + +test("an emulator restored while adb marked it unauthorized keeps its AVD name and reconnects when selected", async t => { + const f = await fixture(t, "List of devices attached\nemulator-5554 unauthorized transport_id:7\n", ["RunningAVD"]); + const before = await f.backend.list(); + // The console names the AVD, so it is not listed again as a stopped AVD. + assert.deepEqual(before.devices, [{ udid: "emulator-5554", name: "RunningAVD", state: "unauthorized", runtime: "Android", platform: "android", kind: "emulator" }]); + const status = await f.backend.boot("emulator-5554"); + assert.equal(status.devices[0].state, "Booted"); + assert.ok((await f.calls()).some(args => args.join(" ") === "-s emulator-5554 shell getprop sys.boot_completed"), "Selection waits for Android to finish booting."); + const reconnects = (await f.calls()).filter(args => args.join(" ") === "-s emulator-5554 reconnect"); + assert.equal(reconnects.length, 1); +}); From 0d2b8d15224ca87b62f4738df8e937956c3ff5f4 Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Thu, 8 Oct 2026 20:57:20 +0200 Subject: [PATCH 2/3] fix(build): reject stale serve-emu dependencies The build copies runtimes/serve-emu/node_modules into the package. A checkout that last ran vendor:serve-emu before the Node.js runtime change shipped without ws, so the Android backend exited on start. Compare npm's installed lockfile with package-lock.json and stop with a vendor:serve-emu hint when they differ. --- scripts/build-serve-emu.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/build-serve-emu.mjs b/scripts/build-serve-emu.mjs index e7a95119..0db5bd49 100644 --- a/scripts/build-serve-emu.mjs +++ b/scripts/build-serve-emu.mjs @@ -34,6 +34,17 @@ async function sourceHash() { return hash.digest("hex"); } +// npm ci records what it installed. Refuse to package dependencies from an older lockfile. +async function assertInstalledDependencies() { + const packages = async path => { + const text = await readFile(join(runtime, path), "utf8").catch(() => "{}"); + const entries = Object.entries(JSON.parse(text).packages ?? {}).filter(([name]) => name); + return JSON.stringify(entries.map(([name, item]) => [name, item.version]).sort()); + }; + const [locked, installed] = await Promise.all([packages("package-lock.json"), packages("node_modules/.package-lock.json")]); + if (locked !== installed) throw new Error("runtimes/serve-emu/node_modules does not match its package-lock.json. Run npm run vendor:serve-emu."); +} + export async function buildServeEmu(directory) { const outputDirectory = resolve(directory); if (outputDirectory === runtime) throw new Error("Build the Android runtime into a separate output directory."); @@ -48,6 +59,7 @@ export async function buildServeEmu(directory) { const scrcpyClientPath = join(runtime, "src/scrcpy.ts"); const scrcpyClientSHA256 = await sha256(scrcpyClientPath); const sourceSHA256 = await sourceHash(); + await assertInstalledDependencies(); await rm(outputDirectory, { recursive: true, force: true }); await mkdir(outputDirectory, { recursive: true }); for (const path of ["src", "scripts", "node_modules", "vendor", "LICENSE", "README.md", "upstream.json", "package-lock.json"]) { From b45ed85d4f9b9d1a4145240ea7b3f0bada09da18 Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Thu, 8 Oct 2026 20:57:20 +0200 Subject: [PATCH 3/3] chore(release): bump version to 0.1.140 --- .codex-plugin/plugin.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/shared/version.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 03e5e16f..a3c3a8e1 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mobile-dev", - "version": "0.1.139", + "version": "0.1.140", "description": "Keep a simulator beside the chat while building, running, or debugging iOS and Android apps. Stream devices, read native and Metro logs, render interactive CPU, memory, and FPS recordings, and control apps with Mobile Dev tools.", "author": { "name": "Callstack" diff --git a/package-lock.json b/package-lock.json index 6db92a24..6edfeeeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-mobile-dev-plugin", - "version": "0.1.139", + "version": "0.1.140", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-mobile-dev-plugin", - "version": "0.1.139", + "version": "0.1.140", "dependencies": { "@base-ui/react": "^1.8.0", "@fontsource-variable/inter": "^5.3.0", diff --git a/package.json b/package.json index 31761a0c..75b131d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-mobile-dev-plugin", - "version": "0.1.139", + "version": "0.1.140", "private": true, "type": "module", "engines": { diff --git a/src/shared/version.ts b/src/shared/version.ts index 84777a96..afd81653 100644 --- a/src/shared/version.ts +++ b/src/shared/version.ts @@ -1 +1 @@ -export const PLUGIN_VERSION = "0.1.139"; +export const PLUGIN_VERSION = "0.1.140";