Skip to content
Merged
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
52 changes: 39 additions & 13 deletions apps/codex-plugin/server/plugin-updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,33 @@ export type StorePlugin = z.infer<typeof storeSummarySchema>;
type Catalog = z.infer<typeof pluginsSchema> & { store: StorePlugin };
export type ReadStore = () => Promise<unknown>;
export type RunCodex = (args: string[]) => Promise<unknown>;
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<string> {
const pid = String(parentPid);
const result = await execute("/bin/ps", ["-p", pid, "-o", "comm="], { cwd, timeout: 5000, maxBuffer: 4096 });
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;
}

Expand All @@ -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"); }
};
}

Expand All @@ -112,7 +130,7 @@ export async function readPluginStore(cwd: string, options: { path?: string; env
const pending = new Map<number, { resolve: (result: unknown) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }>();
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);
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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 }); }
}

Expand Down Expand Up @@ -332,6 +350,7 @@ export class PluginUpdates {

private async migrateToStore(): Promise<PluginUpdate> {
const startedAt = performance.now();
let step: MigrationStep = "catalog";
try {
if (this.checking !== undefined) await this.checking;
const catalog = await this.catalog();
Expand All @@ -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);
Expand All @@ -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.");
}
}

Expand Down
44 changes: 43 additions & 1 deletion apps/codex-plugin/test/plugin-updates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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[][] = [];
Expand Down
21 changes: 21 additions & 0 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/android-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -22,7 +22,7 @@ function guarded<T>(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) } };
}
};
}
Expand Down
11 changes: 6 additions & 5 deletions packages/core/src/baguette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -34,8 +35,9 @@ async function bundledExecutable(): Promise<string> {
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;
}
Expand Down Expand Up @@ -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");
}
Expand Down
Loading
Loading