diff --git a/apps/codex-plugin/server/plugin-updates.ts b/apps/codex-plugin/server/plugin-updates.ts index 70a4629f..8aea14fd 100644 --- a/apps/codex-plugin/server/plugin-updates.ts +++ b/apps/codex-plugin/server/plugin-updates.ts @@ -73,7 +73,25 @@ export type StorePlugin = z.infer; type Catalog = z.infer & { store: StorePlugin }; export type ReadStore = () => Promise; export type RunCodex = (args: string[]) => Promise; -class PluginUpdateError extends Error {} +// Why an update step failed, tagged on migration failures: Codex was not found, a Codex +// command or the store catalog failed, Codex answered unexpectedly, or a step's result +// did not hold. +type UpdateFailure = "codex-not-found" | "command-failed" | "invalid-response" | "store-unavailable" | "foreign-marketplace" | "not-removed" | "not-verified" | "cache-write"; +type MigrationStep = "catalog" | "remove-plugin" | "remove-marketplace" | "verify" | "save"; +class PluginUpdateError extends Error { + readonly failure: UpdateFailure; + + constructor(message: string, failure: UpdateFailure) { + super(message); + this.failure = failure; + } +} + +function updateFailure(error: unknown): UpdateFailure | "unknown" { + if (error instanceof PluginUpdateError) return error.failure; + if (error instanceof z.ZodError) return "invalid-response"; + return "unknown"; +} export async function resolveCodexCli(parentPid = process.ppid, cwd = homedir()): Promise { const pid = String(parentPid); @@ -81,7 +99,7 @@ export async function resolveCodexCli(parentPid = process.ppid, cwd = homedir()) const path = result.stdout.trim(); const absolute = isAbsolute(path); const name = basename(path); - if (absolute === false || name !== "codex") throw new PluginUpdateError("Could not locate the Codex executable that launched Mobile Dev."); + if (absolute === false || name !== "codex") throw new PluginUpdateError("Could not locate the Codex executable that launched Mobile Dev.", "codex-not-found"); return path; } @@ -95,10 +113,10 @@ export function createCodexRunner(cwd: string, options: { path?: string; env?: N const result = await execute(path, args, { cwd, timeout: 120000, maxBuffer: 1024 * 1024, env, signal: options.signal }); stdout = result.stdout; } catch { - throw new PluginUpdateError("Codex could not complete the Mobile Dev update command. Try again."); + throw new PluginUpdateError("Codex could not complete the Mobile Dev update command. Try again.", "command-failed"); } try { return JSON.parse(stdout); } - catch { throw new PluginUpdateError("Codex returned an invalid update response. Try again."); } + catch { throw new PluginUpdateError("Codex returned an invalid update response. Try again.", "invalid-response"); } }; } @@ -112,7 +130,7 @@ export async function readPluginStore(cwd: string, options: { path?: string; env const pending = new Map void; reject: (error: Error) => void; timer: ReturnType }>(); let nextId = 1; function fail() { - const error = new PluginUpdateError("Could not read the Codex plugin store catalog."); + const error = new PluginUpdateError("Could not read the Codex plugin store catalog.", "store-unavailable"); for (const request of pending.values()) { clearTimeout(request.timer); request.reject(error); @@ -148,7 +166,7 @@ export async function readPluginStore(cwd: string, options: { path?: string; env clearTimeout(request.timer); pending.delete(response.id); if (response.error) { - const error = new PluginUpdateError("Codex could not read the plugin store catalog."); + const error = new PluginUpdateError("Codex could not read the plugin store catalog.", "store-unavailable"); request.reject(error); } else request.resolve(response.result); }); @@ -256,7 +274,7 @@ export class PluginUpdates { await writeFile(temporary, text, { mode: 0o600 }); await rename(temporary, this.cachePath); } catch { - throw new PluginUpdateError("Could not save the update status. Check free disk space and try again."); + throw new PluginUpdateError("Could not save the update status. Check free disk space and try again.", "cache-write"); } finally { await rm(temporary, { force: true }); } } @@ -332,6 +350,7 @@ export class PluginUpdates { private async migrateToStore(): Promise { const startedAt = performance.now(); + let step: MigrationStep = "catalog"; try { if (this.checking !== undefined) await this.checking; const catalog = await this.catalog(); @@ -341,29 +360,33 @@ export class PluginUpdates { if (installed === false) { throw new ExpectedOperationError("app_unavailable", "Install and enable the latest Mobile Dev version from the plugin store, then finish migration here."); } + step = "remove-plugin"; if (githubInstalled(catalog)) { const result = await this.run(["plugin", "remove", githubPlugin, "--json"]); const removed = removedSchema.parse(result); - if (removed.pluginId !== githubPlugin) throw new PluginUpdateError("Codex did not remove the GitHub installation. Try again."); + if (removed.pluginId !== githubPlugin) throw new PluginUpdateError("Codex did not remove the GitHub installation. Try again.", "not-removed"); } + step = "remove-marketplace"; const marketplaces = await this.run(["plugin", "marketplace", "list", "--json"]); const marketplaceResult = marketplaceSchema.parse(marketplaces); const marketplace = marketplaceResult.marketplaces.find(item => item.name === "mobile-dev"); if (marketplace !== undefined) { const source = marketplace.marketplaceSource; if (source?.sourceType !== "git" || isReleaseRepository(source.source) === false) { - throw new PluginUpdateError("The mobile-dev marketplace belongs to another source and cannot be removed here."); + throw new PluginUpdateError("The mobile-dev marketplace belongs to another source and cannot be removed here.", "foreign-marketplace"); } const result = await this.run(["plugin", "marketplace", "remove", "mobile-dev", "--json"]); const removed = removedMarketplaceSchema.parse(result); - if (removed.marketplaceName !== "mobile-dev") throw new PluginUpdateError("Codex did not remove the GitHub marketplace. Try again."); + if (removed.marketplaceName !== "mobile-dev") throw new PluginUpdateError("Codex did not remove the GitHub marketplace. Try again.", "not-removed"); } + step = "verify"; const verified = await this.catalog(); if (githubConfigured(verified) || await this.storeInstalled(verified) === false) { - throw new PluginUpdateError("Could not verify the store migration. Try again."); + throw new PluginUpdateError("Could not verify the store migration. Try again.", "not-verified"); } const installedVersion = releaseVersionSchema.parse(verified.store.localVersion); const cache: Cache = { source: PLUGIN_STORE_ID, checkedAt: this.now(), latestVersion: version, githubConfigured: false, storeInstalled: true, migratedVersion: installedVersion }; + step = "save"; await this.saveCache(cache); const elapsed = performance.now() - startedAt; recordPluginUpdate("migrate", "migrated", elapsed); @@ -373,9 +396,12 @@ export class PluginUpdates { recordPluginUpdate("migrate", "failed", elapsed); if (error instanceof ExpectedOperationError) throw error; const telemetryError = new Error("The plugin store migration failed."); - captureServerError(telemetryError, "plugin.update.migrate"); + Sentry.withScope(scope => { + scope.setTags({ plugin_update_step: step, plugin_update_failure: updateFailure(error) }); + captureServerError(telemetryError, "plugin.update.migrate"); + }); if (error instanceof PluginUpdateError) throw error; - throw new PluginUpdateError("Could not verify the Mobile Dev migration. Try again."); + throw new Error("Could not verify the Mobile Dev migration. Try again."); } } diff --git a/apps/codex-plugin/test/plugin-updates.test.ts b/apps/codex-plugin/test/plugin-updates.test.ts index 041992fe..6bd4b53c 100644 --- a/apps/codex-plugin/test/plugin-updates.test.ts +++ b/apps/codex-plugin/test/plugin-updates.test.ts @@ -6,8 +6,11 @@ import { join } from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { PluginUpdates, registerPluginUpdateTools, resolveCodexCli, readPluginStore } from "../server/plugin-updates.ts"; +import * as Sentry from "@sentry/node"; +import type { Envelope } from "@sentry/core"; +import { createCodexRunner, PluginUpdates, registerPluginUpdateTools, resolveCodexCli, readPluginStore } from "../server/plugin-updates.ts"; import type { ReadStore, RunCodex } from "../server/plugin-updates.ts"; +import { scrubErrorEvent } from "@mobile-dev/shared/telemetry.ts"; import { compareReleaseVersions, PLUGIN_STORE_ID, pluginUpdateSchema } from "../shared/plugin-updates.ts"; async function profile(t: test.TestContext) { @@ -244,6 +247,45 @@ test("wrong marketplaces, failed deletion and unremoved GitHub installations nev } }); +test("migration failures report the failed step and cause without CLI output", async t => { + const envelopes: Envelope[] = []; + Sentry.init({ + dsn: "https://public@example.com/1", defaultIntegrations: false, beforeSend: scrubErrorEvent, + transport: () => ({ async send(envelope) { envelopes.push(envelope); return { statusCode: 200 }; }, async flush() { return true; } }), + }); + const previous = process.env.MOBILE_DEV_TELEMETRY; + delete process.env.MOBILE_DEV_TELEMETRY; + t.after(async () => { + if (previous === undefined) delete process.env.MOBILE_DEV_TELEMETRY; + else process.env.MOBILE_DEV_TELEMETRY = previous; + await Sentry.close(); + }); + const cases = [ + { fixture: { source: "https://github.com/other/repo" }, step: "remove-marketplace", failure: "foreign-marketplace" }, + { fixture: { keepGithub: true }, step: "verify", failure: "not-verified" }, + { fixture: {}, failRemoval: true, step: "remove-plugin", failure: "command-failed" }, + { fixture: {}, invalidStore: true, step: "catalog", failure: "invalid-response" }, + ]; + for (const { fixture, failRemoval, invalidStore, step, failure } of cases) { + envelopes.length = 0; + const directory = await profile(t); + const calls: string[][] = []; + const cli = cliFixture(directory, calls, { ...fixture, storeInstalled: true }); + // A real runner around a command that exits non-zero, so the failure is classified where Codex fails. + const failing = createCodexRunner(directory, { path: "/usr/bin/false" }); + const run: RunCodex = args => failRemoval && args[1] === "remove" ? failing(args) : cli.run(args); + const readStore: ReadStore = invalidStore ? async () => ({ name: "mobile-dev" }) : cli.readStore; + const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.167", run, readStore }); + await assert.rejects(updates.migrate()); + await Sentry.flush(); + const sent = JSON.stringify(envelopes); + assert.ok(sent.includes("The plugin store migration failed."), step); + assert.ok(sent.includes(`"plugin_update_step":"${step}"`), sent); + assert.ok(sent.includes(`"plugin_update_failure":"${failure}"`), sent); + assert.equal(sent.includes(directory), false); + } +}); + test("cleanup can resume when plugin removal succeeded but marketplace removal failed", async t => { const directory = await profile(t); const calls: string[][] = []; diff --git a/docs/telemetry.md b/docs/telemetry.md index 65d8f4de..57c81177 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -156,6 +156,13 @@ No CLI output, store metadata, versions, paths, or user data are included. Disposal aborts CLI requests and clears the UI timer and visibility listener. +Since 0.1.170, the fixed `plugin.update.migrate` error carries a `plugin_update_step` +tag (`catalog`, `remove-plugin`, `remove-marketplace`, `verify` or `save`) and a +`plugin_update_failure` tag (`codex-not-found`, `command-failed`, `invalid-response`, +`store-unavailable`, `foreign-marketplace`, `not-removed`, `not-verified`, +`cache-write` or `unknown`). The message, metrics and boundaries are unchanged, and +no CLI output or store response is included. + Since 0.1.129, discovery command failures retain bounded `discovery_command`, `discovery_cause`, `discovery_termination`, and optional `discovery_signal` tags. Failed exception contexts contain only numeric `elapsed_ms`, `deadline_ms`, and an optional exit @@ -498,6 +505,20 @@ release, scrubbing and opt-out remain in the parent. Installation and process-se IDs never enter performance measurements. These diagnostics distinguish failure mechanisms; they do not by themselves identify the cause of an ADB stall. +Since 0.1.170, the server records the inode and modification time of its bundle file +(`server.mjs`, or the chunk beside it in split builds) when it loads. Every error event +from a server whose file is gone carries `install_integrity: removed`, as when an update +or uninstall deletes the running version's folder; another file in its place gives +`replaced`. Split builds name chunks by content, so an in-place update there reads as +`removed`. A missing bundled serve-emu or Baguette runtime also carries `bundled_runtime` +(`serve-emu` or `baguette`), with `install_integrity: incomplete` when the server's file +is unchanged, so the installation it started from lacks the runtime. A runtime file that +exists but cannot be read or run is not reported as missing. Only these fixed values are +sent, never paths. The panel no longer retries a missing runtime on every reconnect. +Missing-runtime errors now have new messages and are created in `install-integrity.ts`, +so they group into new Sentry issues; compare them with MOBILE-DEV-SERVER-68 +(serve-emu) and MOBILE-DEV-SERVER-7C (Baguette) when reading trends. + Unhandled JavaScript errors and rejected promises, React render errors, and handled MCP tool failures produce issues. Expected stopped-device errors and cancelled operations are excluded. Sentry traces 10% of ordinary tool actions, continuing the UI trace through the MCP bridge. Frame reads, polling, discovery and pointer input are excluded from trace sampling. The SDK does not record MCP arguments or results. Device requests retain the ordinary sampled MCP trace. `device_picker.prepare` diff --git a/packages/core/src/android-tools.ts b/packages/core/src/android-tools.ts index 40f4cf6b..cf952b34 100644 --- a/packages/core/src/android-tools.ts +++ b/packages/core/src/android-tools.ts @@ -8,7 +8,7 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { ServeEmu, androidIdSchema } from "./serve-emu.ts"; import { AndroidStreams } from "./android-streams.ts"; -import { SimulatorUnavailableError } from "./simulator-unavailable.ts"; +import { retryableFailure } from "./simulator-unavailable.ts"; import { errorMessage, inputSchema } from "@mobile-dev/shared/protocol.ts"; import { captureServerError } from "./telemetry.ts"; @@ -22,7 +22,7 @@ function guarded(handler: (input: T, context: ServerRequestContext) => Promis try { return await handler(input, context); } catch (error) { captureServerError(error, "android.tool", { signal: context.signal }); - return { isError: true, content: [{ type: "text", text: errorMessage(error) }], _meta: { retryable: !(error instanceof SimulatorUnavailableError) } }; + return { isError: true, content: [{ type: "text", text: errorMessage(error) }], _meta: { retryable: retryableFailure(error) } }; } }; } diff --git a/packages/core/src/baguette.ts b/packages/core/src/baguette.ts index 3ea6b249..f53c6934 100644 --- a/packages/core/src/baguette.ts +++ b/packages/core/src/baguette.ts @@ -12,6 +12,7 @@ import type { Status } from "@mobile-dev/shared/protocol.ts"; import { buttonMarginsSchema } from "@mobile-dev/shared/bezel.ts"; import { definitionDeviceState, getDefinitionDiagnostic, parseDefinitionDiagnostic, setDefinitionDiagnostic } from "@mobile-dev/shared/simulator-definition-diagnostics.ts"; import { SimulatorUnavailableError } from "./simulator-unavailable.ts"; +import { isMissingFile, MissingRuntimeError } from "./install-integrity.ts"; import { deferEpisodeReport, ExpectedOperationError, FailureEpisodes } from "@mobile-dev/shared/error-reporting.ts"; import { annotateDeviceCommand, backendCommand, inheritDeviceCommand } from "@mobile-dev/shared/device-command-diagnostics.ts"; import { errorReportSignature } from "@mobile-dev/shared/telemetry.ts"; @@ -34,8 +35,9 @@ async function bundledExecutable(): Promise { const path = fileURLToPath(new URL("./baguette/Baguette", import.meta.url)); try { await access(path, constants.X_OK); - } catch { - throw new Error("The plugin is missing its bundled Baguette runtime. Run npm run vendor:baguette and npm run build in the source project, then package it again."); + } catch (error) { + if (isMissingFile(error)) throw new MissingRuntimeError("baguette"); + throw new Error("Mobile Dev cannot run its bundled simulator backend. Reinstall Mobile Dev, then restart the app running it."); } return path; } @@ -252,9 +254,8 @@ export class Baguette { const polling = AbortSignal.any([deadline, exited.signal]); while (!deadline.aborted && !this.disposed) { if (launchError) { - if ((launchError as NodeJS.ErrnoException).code === "ENOENT") { - const error = new Error("The bundled Baguette executable could not start. Reinstall the plugin package."); - throw annotateDeviceCommand(error, "baguette_serve", { failure: "missing_executable" }); + if (isMissingFile(launchError)) { + throw annotateDeviceCommand(new MissingRuntimeError("baguette"), "baguette_serve", { failure: "missing_executable" }); } throw annotateDeviceCommand(launchError, "baguette_serve"); } diff --git a/packages/core/src/install-integrity.ts b/packages/core/src/install-integrity.ts new file mode 100644 index 00000000..3471897c --- /dev/null +++ b/packages/core/src/install-integrity.ts @@ -0,0 +1,65 @@ +import { statSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { Event } from "@sentry/node"; + +export type InstallIntegrity = "intact" | "removed" | "replaced"; +export type BundledRuntime = "serve-emu" | "baguette"; + +// Bundled, this is the server file holding this module in dist/ (server.mjs, or a chunk +// beside it in split builds), next to the runtimes. Hosts can update or uninstall the +// plugin while its server runs, removing or replacing that file. +const serverFile = fileURLToPath(import.meta.url); +const loaded = serverFileStats(); + +function serverFileStats() { + try { return statSync(serverFile); } + catch { return undefined; } +} + +/** Whether the server still runs from the files it loaded, or an update or uninstall removed or replaced them. */ +export function installIntegrity(): InstallIntegrity { + const current = serverFileStats(); + if (current === undefined) return "removed"; + if (loaded === undefined || current.ino === loaded.ino && current.mtimeMs === loaded.mtimeMs) return "intact"; + return "replaced"; +} + +/** Whether a failed access or spawn found no file, rather than a file it could not use. */ +export function isMissingFile(error: unknown): boolean { + const code = error !== null && typeof error === "object" && "code" in error ? error.code : undefined; + return code === "ENOENT" || code === "ENOTDIR"; +} + +const labels: Record = { "serve-emu": "Android streaming backend", baguette: "simulator backend" }; + +/** + * A runtime missing beside the server. Its integrity is the server's, or `incomplete` + * when the server's files are intact, so the installation it started from lacks the runtime. + * Retrying cannot help until Mobile Dev restarts or is reinstalled. + */ +export class MissingRuntimeError extends Error { + readonly runtime: BundledRuntime; + readonly integrity: Exclude | "incomplete"; + + constructor(runtime: BundledRuntime) { + const integrity = installIntegrity(); + super(integrity === "intact" + ? `This Mobile Dev installation is missing its ${labels[runtime]}. Reinstall Mobile Dev, then restart the app running it.` + : `Mobile Dev was updated or removed while it was running and cannot find its ${labels[runtime]}. Restart the app running Mobile Dev to load the installed version.`); + this.runtime = runtime; + this.integrity = integrity === "intact" ? "incomplete" : integrity; + } +} + +export function installIntegrityTags(error: unknown): Record { + if (error instanceof MissingRuntimeError === false) return {}; + return { install_integrity: error.integrity, bundled_runtime: error.runtime }; +} + +/** Tags every error event of a server whose files were removed or replaced, however it was captured. */ +export function tagInstallIntegrity(event: Event): Event { + if (event.type !== undefined) return event; + const integrity = installIntegrity(); + if (integrity !== "intact") event.tags = { install_integrity: integrity, ...event.tags }; + return event; +} diff --git a/packages/core/src/instrument.ts b/packages/core/src/instrument.ts index 569f9665..bc30870f 100644 --- a/packages/core/src/instrument.ts +++ b/packages/core/src/instrument.ts @@ -6,6 +6,7 @@ import * as Sentry from "@sentry/node"; import { SENTRY_RELEASE, SENTRY_SERVER_DSN, sampleTrace, createErrorEventFilter, sampleMetric, scrubSpan } from "@mobile-dev/shared/telemetry.ts"; import { TELEMETRY_ENVIRONMENT } from "./telemetry-environment.ts"; import { getTelemetryIdentity } from "./telemetry-identity.ts"; +import { tagInstallIntegrity } from "./install-integrity.ts"; const identity = getTelemetryIdentity(); if (identity) { @@ -42,6 +43,7 @@ const client = Sentry.init({ beforeSendMetric: sampleMetric, beforeBreadcrumb: breadcrumb => breadcrumb.category === "mobile-dev" ? breadcrumb : null, }); +client?.addEventProcessor(tagInstallIntegrity); const executable = process.argv[1] ?? ""; const mirrorService = executable.endsWith("ios-mirror-service.mjs") || executable.endsWith("ios-mirror-service.ts"); const component = mirrorService ? "ios-mirror-service" : "server"; diff --git a/packages/core/src/panel-tools.ts b/packages/core/src/panel-tools.ts index 7dfe96aa..06c22fea 100644 --- a/packages/core/src/panel-tools.ts +++ b/packages/core/src/panel-tools.ts @@ -25,7 +25,7 @@ import type { CpuSessions } from "./cpu/sessions.ts"; import type { DisplayFpsSessions } from "./fps/sessions.ts"; import { SimulatorInputService } from "./simulator-input.ts"; import type { InputStatus, SimulatorInput } from "./simulator-input.ts"; -import { SimulatorUnavailableError } from "./simulator-unavailable.ts"; +import { retryableFailure } from "./simulator-unavailable.ts"; import { copyPNGToClipboard } from "./clipboard.ts"; import { registerPanelAppControlTools } from "./app-control/tools.ts"; import type { DriverFactory } from "./app-control/tools.ts"; @@ -59,7 +59,7 @@ function guarded(handler: (input: T, context: ServerRequestContext) => Promis try { return await handler(input, context); } catch (error) { captureServerError(error, "simulator.tool", { signal: context.signal }); - return { isError: true, content: [{ type: "text", text: errorMessage(error) }], _meta: { retryable: !(error instanceof SimulatorUnavailableError) } }; + return { isError: true, content: [{ type: "text", text: errorMessage(error) }], _meta: { retryable: retryableFailure(error) } }; } }; } @@ -195,7 +195,7 @@ function registerPanel(server: McpServer, { android, cpu, fps, drivers, page, sh const text = JSON.stringify({ state: "waiting", connectionState: streams.connectionState(sessionId), ...timings }); return { contents: [{ uri: uri.href, mimeType: "application/json", text, _meta: timings }] }; } catch (error) { - const text = JSON.stringify({ state: "failed", error: errorMessage(error), retryable: !(error instanceof SimulatorUnavailableError) }); + const text = JSON.stringify({ state: "failed", error: errorMessage(error), retryable: retryableFailure(error) }); return { contents: [{ uri: uri.href, mimeType: "application/json", text }] }; } }); diff --git a/packages/core/src/serve-emu.ts b/packages/core/src/serve-emu.ts index 78b61b82..4a20eb1b 100644 --- a/packages/core/src/serve-emu.ts +++ b/packages/core/src/serve-emu.ts @@ -14,6 +14,7 @@ import { errorMessage, parseBaseUrl } from "@mobile-dev/shared/protocol.ts"; import type { SimulatorDevice, Status } from "@mobile-dev/shared/protocol.ts"; import { annotateDeviceCommand, backendCommand } from "@mobile-dev/shared/device-command-diagnostics.ts"; import { SimulatorUnavailableError } from "./simulator-unavailable.ts"; +import { isMissingFile, MissingRuntimeError } from "./install-integrity.ts"; import { recordAndroidEmulatorReconnect, recordAndroidBackendStartup, recordAndroidStartupStages, recordAndroidStartupContext, recordAndroidStartupDeviceState, recordAndroidBackendStop } from "./telemetry.ts"; import { androidStartupMessageSchema, androidDeviceState, setAndroidStartupDiagnostic } from "@mobile-dev/shared/android-startup-diagnostics.ts"; import type { AndroidStartupContext, AndroidStartupFailure, AndroidStartupSummary } from "@mobile-dev/shared/android-startup-diagnostics.ts"; @@ -253,7 +254,10 @@ export class ServeEmu { } catch (error) { if (this.external) throw error; } const cliUrl = new URL("./serve-emu/src/cli.mjs", import.meta.url); const cli = fileURLToPath(cliUrl); - await access(cli).catch(() => { throw new Error("The plugin is missing its bundled serve-emu runtime. Run npm run vendor:serve-emu and npm run build, then package it again."); }); + await access(cli).catch(error => { + if (isMissingFile(error)) throw new MissingRuntimeError("serve-emu"); + throw new Error("Mobile Dev cannot read its bundled Android streaming backend. Reinstall Mobile Dev, then restart the app running it."); + }); const listener = createServer(); await new Promise((resolve, reject) => { listener.once("error", reject); listener.listen(0, "127.0.0.1", resolve); }); const address = listener.address(); diff --git a/packages/core/src/simulator-unavailable.ts b/packages/core/src/simulator-unavailable.ts index ea75dab3..33b43edd 100644 --- a/packages/core/src/simulator-unavailable.ts +++ b/packages/core/src/simulator-unavailable.ts @@ -1,7 +1,13 @@ import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; +import { MissingRuntimeError } from "./install-integrity.ts"; export class SimulatorUnavailableError extends ExpectedOperationError { constructor(message: string) { super("device_unavailable", message); } } + +/** Whether the panel should retry after this failure. An unavailable device or a missing runtime stays that way until the user acts. */ +export function retryableFailure(error: unknown): boolean { + return error instanceof SimulatorUnavailableError === false && error instanceof MissingRuntimeError === false; +} diff --git a/packages/core/src/telemetry.ts b/packages/core/src/telemetry.ts index fcee1486..e849f0ac 100644 --- a/packages/core/src/telemetry.ts +++ b/packages/core/src/telemetry.ts @@ -10,6 +10,7 @@ import { definitionDiagnosticTags } from "@mobile-dev/shared/simulator-definitio import { deviceAppsDiagnosticTags } from "@mobile-dev/shared/device-apps-diagnostics.ts"; import { getDiscoveryCommandDiagnostic } from "@mobile-dev/shared/device-apps-command-diagnostics.ts"; import { androidStartupDiagnosticTags } from "@mobile-dev/shared/android-startup-diagnostics.ts"; +import { installIntegrityTags } from "./install-integrity.ts"; import type { AndroidStartupSummary, AndroidStartupContext, AndroidStartupFailure, AndroidDeviceState } from "@mobile-dev/shared/android-startup-diagnostics.ts"; export function recordIosMirrorSharing(captures: number, subscribers: number, dropped: number) { @@ -172,10 +173,11 @@ export function captureServerError(error: unknown, operation: string, options?: return; } const androidTags = androidStartupDiagnosticTags(error); + const installTags = installIntegrityTags(error); const command = getDiscoveryCommandDiagnostic(error); const contexts = command ? { device_apps_command: { elapsed_ms: command.elapsed_ms, deadline_ms: command.deadline_ms, exit_status: command.exit_status } } : undefined; - Sentry.captureException(options?.reportAs ?? error, { tags: { ...diagnosticTags, ...definitionTags, ...androidTags, operation }, contexts }); + Sentry.captureException(options?.reportAs ?? error, { tags: { ...diagnosticTags, ...definitionTags, ...androidTags, ...installTags, operation }, contexts }); } type RequestReporting = { accounted: boolean; sessionResponse?: { release: () => void; close: () => Promise } }; diff --git a/packages/core/test/android.test.ts b/packages/core/test/android.test.ts index 6afeee21..93f8136c 100644 --- a/packages/core/test/android.test.ts +++ b/packages/core/test/android.test.ts @@ -4,6 +4,7 @@ import { createServer } from "node:http"; import { WebSocketServer } from "ws"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ServeEmu, androidDisplaySize } from "../src/serve-emu.ts"; import { AndroidStreams, androidInput } from "../src/android-streams.ts"; import { videoPacket } from "../../ui/src/android-video.ts"; @@ -11,6 +12,7 @@ import { Baguette } from "../src/baguette.ts"; import { createTestPlugin, fakeBaguette, fakeSimulatorInput, PNG } from "../../test-support/fixtures.ts"; import { fakeInspectionSdk } from "../../test-support/android-inspection-fixtures.ts"; import { createPanel } from "../src/panel-tools.ts"; +import { registerAndroidTools } from "../src/android-tools.ts"; const ID = "emulator-5554"; const SCREEN = { width: 1080, height: 2400 }; @@ -184,6 +186,24 @@ test("Android MCP flow lists without booting, captures, streams, and closes", as assert.deepEqual(fake.backend.boots, []); }); +test("a missing serve-emu runtime tells the user to reinstall and stops the panel's retries", async t => { + // Running from source, serve-emu is never beside this module, so starting a stream finds it missing. + class Unbundled extends ServeEmu { + async list() { return { connected: true, managed: false, baseUrl: "", devices: [{ udid: ID, name: "Pixel", state: "Booted", runtime: "Android", platform: "android" as const }] }; } + } + const server = new McpServer({ name: "android-runtime-test", version: "1" }); + const backend = new Unbundled(); + registerAndroidTools(server, backend, "ui://mobile-dev/test.html", async () => {}, async () => {}); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "android-runtime-test", version: "1" }); + t.after(async () => { await client.close(); await server.close(); backend.dispose(); }); + await server.connect(serverTransport); await client.connect(clientTransport); + const session = await client.callTool({ name: "mobile_android_stream_session", arguments: { deviceId: ID } }); + assert.equal(session.isError, true); + assert.equal(session._meta?.retryable, false); + assert.deepEqual(session.content, [{ type: "text", text: "This Mobile Dev installation is missing its Android streaming backend. Reinstall Mobile Dev, then restart the app running it." }]); +}); + test("closing an Android stream releases a waiting read and rejects later input", async t => { const fake = await fakeAndroid(); const streams = new AndroidStreams(fake.backend); t.after(async () => { streams.close(); await fake.close(); }); diff --git a/packages/core/test/install-integrity.test.ts b/packages/core/test/install-integrity.test.ts new file mode 100644 index 00000000..29a4c06b --- /dev/null +++ b/packages/core/test/install-integrity.test.ts @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +// Bundles the check as a server ships it, so its own file is a server.mjs the test can remove or replace. +async function bundledServer(t: test.TestContext) { + const directory = await mkdtemp(join(tmpdir(), "mobile-dev-install-integrity-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const server = join(directory, "server.mjs"); + await build({ + stdin: { contents: 'export * from "./packages/core/src/install-integrity.ts";', resolveDir: fileURLToPath(new URL("../../..", import.meta.url)), loader: "ts" }, + outfile: server, bundle: true, format: "esm", platform: "node", target: "node22.18", + }); + const module: typeof import("../src/install-integrity.ts") = await import(pathToFileURL(server).href); + return { server, module }; +} + +test("a runtime missing beside intact server files is an incomplete installation to reinstall", async t => { + const { module } = await bundledServer(t); + assert.equal(module.installIntegrity(), "intact"); + assert.deepEqual(module.installIntegrityTags(new Error("other")), {}); + const error = new module.MissingRuntimeError("serve-emu"); + assert.equal(error.integrity, "incomplete"); + assert.match(error.message, /missing its Android streaming backend\. Reinstall Mobile Dev/); + assert.deepEqual(module.installIntegrityTags(error), { install_integrity: "incomplete", bundled_runtime: "serve-emu" }); + assert.deepEqual(module.tagInstallIntegrity({ tags: { operation: "android.tool" } }).tags, { operation: "android.tool" }); + // A runtime that exists but cannot be run is not missing. + assert.equal(module.isMissingFile(Object.assign(new Error("gone"), { code: "ENOENT" })), true); + assert.equal(module.isMissingFile(Object.assign(new Error("not executable"), { code: "EACCES" })), false); +}); + +test("server files deleted while running mark every error event removed and ask for a restart", async t => { + const { server, module } = await bundledServer(t); + await rm(server); + const error = new module.MissingRuntimeError("baguette"); + assert.equal(error.integrity, "removed"); + assert.match(error.message, /updated or removed while it was running and cannot find its simulator backend\. Restart the app running Mobile Dev/); + assert.deepEqual(module.installIntegrityTags(error), { install_integrity: "removed", bundled_runtime: "baguette" }); + assert.deepEqual(module.installIntegrityTags(new Error("spawn ENOENT")), {}); + assert.deepEqual(module.tagInstallIntegrity({ tags: { operation: "device_apps.discover" } }).tags, { install_integrity: "removed", operation: "device_apps.discover" }); + assert.equal(module.tagInstallIntegrity({ type: "transaction" }).tags, undefined); +}); + +test("server files installed over while running are replaced", async t => { + const { server, module } = await bundledServer(t); + const replacement = `${server}.new`; + await writeFile(replacement, await readFile(server)); + await rename(replacement, server); + assert.equal(module.installIntegrity(), "replaced"); + assert.equal(new module.MissingRuntimeError("serve-emu").integrity, "replaced"); +}); diff --git a/packages/core/test/telemetry.test.ts b/packages/core/test/telemetry.test.ts index 161196aa..4765c796 100644 --- a/packages/core/test/telemetry.test.ts +++ b/packages/core/test/telemetry.test.ts @@ -19,6 +19,7 @@ import { openRequestSession } from "../src/request-session.ts"; import { registerDeviceAppsTool } from "../src/device-apps/tools.ts"; import { directoryBytes } from "../src/storage-metrics.ts"; import { SimulatorUnavailableError } from "../src/simulator-unavailable.ts"; +import { MissingRuntimeError } from "../src/install-integrity.ts"; import { registerDeviceChoiceTools } from "../src/device-choice-tools.ts"; import { listIosDevices, registerIosDeviceTools } from "../src/ios-devices.ts"; import { createPhysicalIosBezelReader } from "../src/physical-ios-bezel.ts"; @@ -980,6 +981,26 @@ test("Android startup diagnostics retain stage, device state and anonymous error assert.equal(afterOptOut, sent); }); +test("missing bundled runtimes report the runtime and install integrity with anonymous attribution", async t => { + const envelopes: Envelope[] = []; + Sentry.init({ + dsn: "https://public@example.com/1", defaultIntegrations: false, environment: "release", release: "mobile-dev@test", beforeSend: scrubErrorEvent, + initialScope: { user: { id: "anon_0123456789abcdef0123456789abcdef" } }, + transport: () => ({ async send(envelope) { envelopes.push(envelope); return { statusCode: 200 }; }, async flush() { return true; } }), + }); + const previous = process.env.MOBILE_DEV_TELEMETRY; + delete process.env.MOBILE_DEV_TELEMETRY; + t.after(async () => { + if (previous === undefined) delete process.env.MOBILE_DEV_TELEMETRY; + else process.env.MOBILE_DEV_TELEMETRY = previous; + await Sentry.close(); + }); + captureServerError(new MissingRuntimeError("serve-emu"), "android.tool"); + await Sentry.flush(); + const sent = JSON.stringify(envelopes); + for (const fragment of ['"install_integrity":"incomplete"', '"bundled_runtime":"serve-emu"', '"operation":"android.tool"', "anon_0123456789abcdef0123456789abcdef", "mobile-dev@test"]) contains(sent, fragment); +}); + test("resource teardown is classified before automatic SDK capture", async t => { const envelopes: Envelope[] = []; Sentry.init({