From 3feaf4208c4e6b729836c2357ef0dbe70fe80a29 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 19 Oct 2026 18:25:33 -0500 Subject: [PATCH 1/2] fix(device): report emulator launch diagnostics and dismiss stale warnin - Include bounded boot failure details in device errors. - Ignore deleted simulator plist warnings and keep device notices dismissible. --- apps/server/src/device/DeviceService.test.ts | 112 +++++- apps/server/src/device/DeviceService.ts | 35 +- apps/server/src/mcp/McpDeviceToolkit.test.ts | 40 ++ .../device/DevicePanel.browser.test.tsx | 63 +++- .../web/src/components/device/DevicePanel.tsx | 345 +++++++++++------- packages/contracts/src/device.ts | 4 +- 6 files changed, 445 insertions(+), 154 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 01c424924b78..dd3849110c15 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, + DeviceBootError, DeviceId, DeviceOperationError, LOCAL_DEVICE_HOST_ID, @@ -37,6 +38,8 @@ import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; import * as DeviceService from "./DeviceService.ts"; const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const decodeBootError = Schema.decodeUnknownSync(Schema.fromJsonString(DeviceBootError)); +const encodeBootError = Schema.encodeSync(Schema.fromJsonString(DeviceBootError)); const baseState: DeviceServiceState = { hosts: [], @@ -77,7 +80,7 @@ describe("DeviceService.stateStream", () => { const fixture = Effect.fn("fixture")(function* ( onBoot: Effect.Effect = Effect.void, - bootError?: string, + bootError?: string | { error?: string; errors?: Array<{ message: string; error?: string }> }, failListAfterShutdown = false, runtimeFailure?: | NodeRuntimeUnavailableError @@ -92,6 +95,7 @@ const fixture = Effect.fn("fixture")(function* ( const agentStops: string[] = []; const requests: string[] = []; const devices: Array> = []; + const discoveryErrors = yield* Ref.make>([]); const encoderResult = yield* Ref.make({ code: 0, stdout: " V....D libx264", stderr: "" }); let booted = false; let shutDown = false; @@ -212,7 +216,12 @@ const fixture = Effect.fn("fixture")(function* ( return HttpClientResponse.fromWeb( request, Response.json( - bootError ? { ok: false, error: bootError } : { ok: true, serial: "emulator-5554" }, + bootError + ? { + ok: false, + ...(typeof bootError === "string" ? { error: bootError } : bootError), + } + : { ok: true, serial: "emulator-5554" }, ), ); } @@ -222,6 +231,7 @@ const fixture = Effect.fn("fixture")(function* ( return HttpClientResponse.fromWeb( request, Response.json({ + errors: yield* Ref.get(discoveryErrors), simulators: devices.filter((device) => device.platform === "ios"), emulators: [ ...devices.filter((device) => device.platform === "android"), @@ -244,7 +254,17 @@ const fixture = Effect.fn("fixture")(function* ( ), ), ); - return { service, starts, agentStarts, agentStops, requests, settings, devices, encoderResult }; + return { + service, + starts, + agentStarts, + agentStops, + requests, + settings, + devices, + encoderResult, + discoveryErrors, + }; }); describe("device setup consent", () => { @@ -506,6 +526,60 @@ describe("device discovery after server restart", () => { ); }); +const missingPlistMessage = + "[apple-utils] Failed to read CoreSimulator plist at /Users/test/Library/Developer/CoreSimulator/Devices/deleted-simulator/device.plist:"; + +it.effect.each([ + { + message: missingPlistMessage, + error: "Error: ENOENT: no such file or directory, open 'device.plist'", + ignored: true, + }, + { + message: missingPlistMessage, + error: "Error: EACCES: permission denied, open 'device.plist'", + ignored: false, + }, + { message: missingPlistMessage, error: undefined, ignored: false }, + { + message: "[apple-utils] Failed to parse CoreSimulator device.plist:", + error: "Invalid XML", + ignored: false, + }, + { + message: "[apple-utils] Failed to list devices:", + error: "Error: ENOENT: simctl missing", + ignored: false, + }, +])("handles discovery diagnostic: $error", ({ message, error, ignored }) => + Effect.gen(function* () { + const { service, discoveryErrors } = yield* fixture(); + yield* Ref.set(discoveryErrors, [{ message, ...(error ? { error } : {}) }]); + const state = yield* service.configure({ enabled: true }); + expect(state.hostStatus).toBe("ready"); + expect(state.devices.map((device) => device.id)).toEqual(["Pixel_API_35"]); + expect(state.hostStatusDetail).toBe(ignored ? undefined : message); + expect(state.hostStatuses.local?.detail).toBe(ignored ? undefined : message); + }).pipe(Effect.scoped), +); + +it.effect("keeps other discovery warnings and clears them when only a deleted plist remains", () => + Effect.gen(function* () { + const { service, discoveryErrors } = yield* fixture(); + const missing = { + message: missingPlistMessage, + error: "Error: ENOENT: no such file or directory", + }; + yield* Ref.set(discoveryErrors, [missing, { message: "Android discovery failed." }]); + const initial = yield* service.configure({ enabled: true }); + expect(initial.hostStatusDetail).toBe("Android discovery failed."); + yield* Ref.set(discoveryErrors, [missing]); + const refreshed = yield* service.list; + expect(refreshed.hostStatusDetail).toBeUndefined(); + expect(refreshed.hostStatuses.local?.detail).toBeUndefined(); + }).pipe(Effect.scoped), +); + it.effect.each([ { diagnostic: "Insufficient disk space at /private/user/path", @@ -518,7 +592,8 @@ it.effect.each([ message: "did not become ready in time", }, { - diagnostic: "Unexpected failure: secret-token", + diagnostic: + "The emulator process exited with code 1.\nTry manually: emulator -avd Pixel_API_35", reason: "launch_failed", message: "could not start", }, @@ -535,7 +610,34 @@ it.effect.each([ .pipe(Effect.flip); expect(error._tag).toBe("DeviceBootError"); expect(error.message).toContain(message); - expect(error.message).not.toContain(diagnostic); + expect(error.message).toContain(diagnostic); + expect((yield* service.state).bootingDevices).toEqual([]); + }).pipe(Effect.scoped), +); + +it.effect("includes nested hub boot diagnostics and bounds them to 2,000 characters", () => + Effect.gen(function* () { + const diagnostic = "Error: emulator binary missing\n" + "diagnostic output\n".repeat(200); + const { service } = yield* fixture(Effect.void, { + error: "Failed to spawn emulator", + errors: [{ message: "Failed to launch emulator binary", error: diagnostic }], + }); + yield* service.configure({ enabled: true }); + const error = yield* service + .open({ + threadId: ThreadId.make("nested-boot-failure"), + deviceId: "Pixel_API_35", + platform: "android", + }) + .pipe(Effect.flip); + expect(error._tag).toBe("DeviceBootError"); + if (error._tag !== "DeviceBootError") return; + const expected = `Failed to spawn emulator\n\nFailed to launch emulator binary\n\n${diagnostic}`; + expect(error.diagnostics).toBe(`${expected.slice(0, 1_999)}…`); + expect(error.diagnostics).toHaveLength(2_000); + expect(error.message).toContain(error.diagnostics); + const roundTrip = decodeBootError(encodeBootError(error)); + expect(roundTrip.message).toBe(error.message); expect((yield* service.state).bootingDevices).toEqual([]); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 5fba72079fe8..4dbaa49b28e9 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -96,13 +96,18 @@ const HubDevice = Schema.Struct({ const HubDeviceList = Schema.Struct({ simulators: Schema.Array(HubDevice), emulators: Schema.Array(HubDevice), - errors: Schema.optional(Schema.Array(Schema.Struct({ message: Schema.String }))), + errors: Schema.optional( + Schema.Array(Schema.Struct({ message: Schema.String, error: Schema.optional(Schema.String) })), + ), }); const HubActionResult = Schema.Struct({ ok: Schema.Boolean, id: Schema.optional(Schema.String), serial: Schema.optional(Schema.String), error: Schema.optional(Schema.String), + errors: Schema.optional( + Schema.Array(Schema.Struct({ message: Schema.String, error: Schema.optional(Schema.String) })), + ), }); export interface DeviceScreenshot { @@ -439,7 +444,16 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* } } } - return { devices, detail: list.errors?.map((error) => error.message).join("\n") || undefined }; + const warnings = list.errors?.filter( + (error) => + // CoreSimulator can retain deleted devices; their optional usage timestamps are absent. + !( + error.message.startsWith("[apple-utils] Failed to read CoreSimulator plist at ") && + /\/CoreSimulator\/Devices\/[^/]+\/device\.plist:$/.test(error.message) && + /(?:^Error:\s*ENOENT:|\bcode:\s*['"]ENOENT['"])/.test(error.error ?? "") + ), + ); + return { devices, detail: warnings?.map((error) => error.message).join("\n") || undefined }; }); const refresh = Effect.fn("DeviceService.refresh")(function* (ready: DeviceReadiness) { @@ -630,16 +644,29 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* Effect.flatMap((request) => hubJson(request, HubActionResult, "boot", BOOT_TIMEOUT)), ); if (!result.ok) { + // Keep the hub's launch command and nested failure available to agents for recovery. + const diagnostics = [ + result.error, + ...(result.errors ?? []).flatMap((error) => [error.message, error.error]), + ] + .filter((text) => text?.trim()) + .join("\n\n"); return yield* new DeviceBootError({ hostId: ready.hostId, deviceId: device.id, reason: /insufficient.*(?:disk|space)|not enough.*(?:disk|space)|no space left/i.test( - result.error ?? "", + diagnostics, ) ? "disk_space" - : /timed? out|timeout/i.test(result.error ?? "") + : /timed? out|timeout/i.test(diagnostics) ? "timeout" : "launch_failed", + ...(diagnostics + ? { + diagnostics: + diagnostics.length > 2_000 ? `${diagnostics.slice(0, 1_999)}…` : diagnostics, + } + : {}), cause: result, }); } diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 8e80b30a7f10..306d5cabc715 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + DeviceBootError, DeviceHostUnavailableError, DeviceId, EnvironmentId, @@ -124,6 +125,45 @@ const layerTest = McpHttpServer.layerDeviceToolkit.pipe( Layer.provide(NodeServices.layer), ); +it.effect("returns the complete bounded launch diagnostics to the agent", () => { + const diagnostics = + "Emulator exited with code 1.\nTry manually: emulator -avd TestPhone\n" + "x".repeat(1_930); + const failure = new DeviceBootError({ + hostId: "local", + deviceId: "UDID-1", + reason: "launch_failed", + diagnostics: diagnostics.slice(0, 2_000), + cause: { ok: false, error: diagnostics }, + }); + const failedDevices = Layer.mock(DeviceService.DeviceService)({ + list: Effect.succeed(state), + agentTarget: () => Effect.succeed([]), + open: () => Effect.fail(failure), + }); + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ name: "device_open", arguments: { platform: "ios" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(["device"])), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(result.isError).toBe(true); + expect(result.content).toEqual([{ type: "text", text: failure.message }]); + }).pipe( + Effect.scoped, + Effect.provide( + McpHttpServer.layerDeviceToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), + Layer.provide(failedDevices), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-boot-error-test-" })), + Layer.provide(NodeServices.layer), + ), + ), + ); +}); + it.effect("registers the device tools and returns the screenshot as image content", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/web/src/components/device/DevicePanel.browser.test.tsx b/apps/web/src/components/device/DevicePanel.browser.test.tsx index 1c3ba32e9eec..a6aaef3dd97a 100644 --- a/apps/web/src/components/device/DevicePanel.browser.test.tsx +++ b/apps/web/src/components/device/DevicePanel.browser.test.tsx @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import type { RightPanelSurface } from "~/rightPanelStore"; import { DevicePanel } from "./DevicePanel"; +import "~/index.css"; type CommandResult = | { _tag: "Success"; value: { hostId: string; deviceId: string } } @@ -58,9 +59,10 @@ const deviceState: DeviceServiceState = { hubBasePath: "/api/device-hub", revision: 1, }; +let currentState = deviceState; vi.mock("~/state/device", () => ({ deviceEnvironment: { list: "list", open: "open", close: "close" }, - useDeviceState: () => ({ state: deviceState, loaded: true }), + useDeviceState: () => ({ state: currentState, loaded: true }), })); vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: (command: string) => @@ -77,14 +79,15 @@ vi.mock("~/rightPanelStore", () => ({ }, })); vi.mock("~/components/device/DeviceHostUpdates", () => ({ DeviceHostUpdates: () => null })); -vi.mock("~/components/device/DeviceLoadingView", () => ({ DeviceLoadingView: () => null })); vi.mock("~/components/device/DeviceWorkspace", () => ({ DeviceWorkspace: ({ onPowerOff }: { onPowerOff: () => void }) => ( ), })); vi.mock("~/components/preview/PreviewPanelShell", () => ({ - PreviewPanelShell: ({ children }: { children: ReactNode }) => children, + PreviewPanelShell: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), })); const threadA = { @@ -142,8 +145,11 @@ const errors = () => container.querySelectorAll('[role="alert"]'); beforeEach(() => { vi.resetAllMocks(); + currentState = deviceState; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); container = document.createElement("div"); + container.style.width = "480px"; + container.style.height = "800px"; document.body.append(container); root = createRoot(container); }); @@ -153,6 +159,57 @@ afterEach(async () => { vi.unstubAllGlobals(); }); +describe("DevicePanel notices", () => { + it("keeps discovery warnings dismissed until the diagnostic or environment changes", async () => { + currentState = { + ...deviceState, + hostStatusDetail: "Could not read simulator usage timestamps.", + }; + await show(threadA); + expect(errors()).toHaveLength(1); + expect(startButton()?.disabled).toBe(false); + await click(container.querySelector('[aria-label="Dismiss device warning"]')); + expect(errors()).toHaveLength(0); + await show(threadA); + expect(errors()).toHaveLength(0); + + currentState = { ...currentState, hostStatusDetail: "A different discovery warning." }; + await show(threadA); + expect(errors()).toHaveLength(1); + await click(container.querySelector('[aria-label="Dismiss device warning"]')); + await show(otherEnvironment); + expect(errors()).toHaveLength(1); + }); + + it("keeps the loading content centered when discovery and boot notices arrive or are dismissed", async () => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + await show(threadA); + await click(startButton()); + const loadingBounds = () => + container + .querySelector('[aria-label="Step 1 of 2: open device"]')! + .parentElement!.getBoundingClientRect(); + const before = loadingBounds(); + const panel = container.getBoundingClientRect(); + expect(before.height).toBeGreaterThan(0); + expect(before.top + before.height / 2).toBeCloseTo(panel.top + panel.height / 2, 0); + + currentState = { + ...deviceState, + hostStatusDetail: "Could not read CoreSimulator plist.\n".repeat(8), + bootingDevices: [{ ...phone, threadId: threadA.threadId }], + }; + await show(threadA); + expect(loadingBounds().top).toBe(before.top); + expect(loadingBounds().left).toBe(before.left); + await click(container.querySelector('[aria-label="Dismiss device warning"]')); + expect(loadingBounds().top).toBe(before.top); + expect(loadingBounds().left).toBe(before.left); + await act(async () => pick.settle(failed)); + }); +}); + describe("DevicePanel thread switches", () => { it.each(switches)("does not carry a pending or failed pick to %s", async (_, target) => { const pick = deferred(); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 4e9cc7873e15..374c1a268971 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -5,13 +5,13 @@ import type { DeviceSummary, ScopedThreadRef, } from "@t3tools/contracts"; -import { Smartphone, X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { ArrowUpRight, CircleAlert, Play, Smartphone, X } from "lucide-react"; +import { type ReactNode, useEffect, useState } from "react"; import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore"; import { Button } from "~/components/ui/button"; -import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list"; +import { DiscoveryList } from "~/components/ui/discovery-list"; import { Dialog } from "~/components/ui/dialog"; import { WizardPopup } from "~/components/ui/wizard"; import { Spinner } from "~/components/ui/spinner"; @@ -150,6 +150,14 @@ export function DevicePanel(props: { const bootingDevices = state.bootingDevices?.filter((device) => device.threadId === threadId) ?? []; const { ready: hostReady, busy: hostBusy } = deviceHubReadiness(state); + const loading = !activeDevice && (pendingDevice !== null || hostBusy || !loaded); + const diagnostics = state.hosts.flatMap((host) => { + const status = state.hostStatuses[host.id]; + const detail = status?.detail ?? (host.kind === "local" ? state.hostStatusDetail : undefined); + return detail && (status?.status ?? state.hostStatus) === "ready" + ? [{ hostId: host.id, label: host.label, detail }] + : []; + }); const unavailablePlatforms = state.hosts.flatMap((host) => host.platforms .filter((platform) => !platform.available) @@ -173,157 +181,212 @@ export function DevicePanel(props: { return ( - {hostReady && !activeDevice && state.hostStatusDetail ? ( +
- {state.hostStatusDetail} -
- ) : null} - - {bootingDevices.length > 0 ? ( -
- Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a minute. -
- ) : null} - {operationError ? ( -
-

{operationError}

- + + + {bootingDevices.length > 0 ? ( +
+ Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a + minute. +
+ ) : null} + {operationError ? ( + setOperation(null)}> + {operationError} + + ) : null}
- ) : null} -
- {activeDevice && activeSession ? ( - host.id === activeDevice.hostId)?.label ?? "Device host" - } - hostDiagnostics={state.hostStatusDetail} - visible={props.visible} - onFloat={floatActive} - onClose={() => closeActive(false)} - onPowerOff={() => closeActive(true)} - /> - ) : pendingDevice || hostBusy || !loaded ? ( - host.id === pendingDevice.hostId)?.label ?? "Device host"} · ${pendingDevice.version}` - : "" - } - stage="opening" - message={ - pendingDevice - ? pendingDevice.booted - ? "Opening device…" - : "Starting device…" - : state.hostStatus === "installing" - ? (state.hostStatusDetail ?? "Installing device support…") - : "Finding devices…" - } - /> - ) : ( -
-
- {grouped.length === 0 ? ( - <> - -

- {state.hostStatus === "failed" - ? (state.hostStatusDetail ?? "The device hub failed to start.") - : "No simulators or emulators were found on this environment."} -

- - ) : null} - {hostReady && grouped.length > 0 ? ( -
- {grouped.map((group) => ( -
-
- -

{platformLabel(group.platform)}

-
- - {group.devices.map((device) => ( - + {activeDevice && activeSession ? ( + host.id === activeDevice.hostId)?.label ?? "Device host" + } + hostDiagnostics={ + diagnostics.find(({ hostId }) => hostId === activeDevice.hostId)?.detail + } + visible={props.visible} + onFloat={floatActive} + onClose={() => closeActive(false)} + onPowerOff={() => closeActive(true)} + /> + ) : loading ? ( + host.id === pendingDevice.hostId)?.label ?? "Device host"} · ${pendingDevice.version}` + : "" + } + stage="opening" + message={ + pendingDevice + ? pendingDevice.booted + ? "Opening device…" + : "Starting device…" + : state.hostStatus === "installing" + ? (state.hostStatusDetail ?? "Installing device support…") + : "Finding devices…" + } + /> + ) : ( +
+
+ {grouped.length === 0 ? ( + <> + +

+ {state.hostStatus === "failed" + ? (state.hostStatusDetail ?? "The device hub failed to start.") + : "No simulators or emulators were found on this environment."} +

+ + ) : null} + {hostReady && grouped.length > 0 ? ( +
+ {grouped.map((group) => ( +
+
+ +

{platformLabel(group.platform)}

+
+ + {group.devices.map((device) => ( +
- } - title={device.name} - description={`${state.hosts.find((host) => host.id === device.hostId)?.label} · ${device.version} · ${device.booted ? "Running" : "Stopped"}`} - disabled={pendingDeviceKey !== null} - aria-label={`${device.booted ? "Open" : "Start"} ${device.name}`} - onClick={() => void selectDevice(deviceKey(device))} - action={ - pendingDeviceKey === deviceKey(device) ? ( - - ) : ( - - {device.booted ? "Open" : "Start"} +
+ + {device.name} + + + {`${state.hosts.find((host) => host.id === device.hostId)?.label} · ${device.version} · ${device.booted ? "Running" : "Stopped"}`} - ) - } - /> - ))} - -
- ))} -
- ) : null} - {hostReady && - !state.devices.some((device) => device.platform === "android") && - !unavailablePlatforms.some((platform) => platform.platform === "android") ? ( -

- No Android virtual devices found. Create one in Android Studio's Device Manager, - then refresh. -

- ) : null} - {loaded && !hostBusy ? ( - - ) : null} +
+ +
+ ))} +
+
+ ))} +
+ ) : null} + {hostReady && + !state.devices.some((device) => device.platform === "android") && + !unavailablePlatforms.some((platform) => platform.platform === "android") ? ( +

+ No Android virtual devices found. Create one in Android Studio's Device Manager, + then refresh. +

+ ) : null} + {loaded && !hostBusy ? ( + + ) : null} +
-
- )} + )} +
); } +function DeviceNotice(props: { + readonly tone: "warning" | "error"; + readonly onDismiss: () => void; + readonly children: ReactNode; +}) { + return ( +
+ +
+ {props.children} +
+ +
+ ); +} + +function DeviceDiscoveryWarning(props: { readonly label: string; readonly detail: string }) { + const [dismissed, setDismissed] = useState(false); + if (dismissed) return null; + return ( + setDismissed(true)}> +

Device discovery warning · {props.label}

+ {props.detail} +
+ ); +} + function groupDevices(state: DeviceServiceState) { const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = []; for (const platform of ["ios", "android"] as const) { diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 93c41663fa75..a3b92a8510de 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -354,6 +354,7 @@ export class DeviceBootError extends Schema.TaggedError()("Devi hostId: DeviceHostId, deviceId: DeviceId, reason: Schema.Literals(["disk_space", "timeout", "launch_failed"]), + diagnostics: Schema.optional(Schema.String.check(Schema.isMaxLength(2_000))), cause: Schema.Defect(), }) { override get message(): string { @@ -363,7 +364,8 @@ export class DeviceBootError extends Schema.TaggedError()("Devi launch_failed: "The simulator or emulator could not start. Check its configuration on the environment server.", }[this.reason]; - return `Device ${this.deviceId} failed to boot: ${explanation}`; + const summary = `Device ${this.deviceId} failed to boot: ${explanation}`; + return this.diagnostics ? `${summary}\n\n${this.diagnostics}` : summary; } } From 5cb70ee486280cd48df5478831aad566b6e51435 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 19 Oct 2026 18:45:56 -0500 Subject: [PATCH 2/2] fix(server): configure cameras for older Android emulators --- .../effect-service-conventions.md | 1 + .../server/src/device/LocalDeviceHost.test.ts | 68 ++++++++++++++++++- apps/server/src/device/LocalDeviceHost.ts | 36 ++++++++++ .../server/src/device/sshDeviceScript.test.ts | 12 +++- apps/server/src/device/sshDeviceScript.ts | 14 +++- docs/internals/effect-services.md | 3 + 6 files changed, 129 insertions(+), 5 deletions(-) diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index 52fd09fd1b33..624018f7811f 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -57,6 +57,7 @@ Review changed TypeScript for the conventions below. They apply when a pull requ - Define service failures with `Schema.TaggedError` and structured attributes: operation or stage, resource path or entity identifier, normalized category or status. Derive `message` from those attributes only. Never derive it from `cause`, `cause.message`, or a stringified defect, and do not add a `detail` field that copies `cause.message`. - When wrapping a real failure, keep the immediate underlying error as `cause` so the chain and stack survive. Make `cause` required if every construction wraps a failure. Pure validation or domain errors created without an underlying failure need no cause. - Keep attributes and log annotations safe and bounded: no raw wire payloads, command arguments or output, signed URLs, credentials, query strings, or arbitrary defect text. Preserve the exact value only as `cause`; expose normalized categories, lengths, counts, and safe URL protocol or hostname where useful. + The documented exception is `DeviceBootError.diagnostics`: device hub launch commands and failure text are intentionally exposed to authorized device users and agents, capped at 2,000 characters and included in the error message. Do not apply this exception to other service errors. - At a translation boundary, pass through an already structured domain error when it is part of the target error channel; wrap only unknown or lower-level failures. Map failures where the context is known instead of wrapping a whole multi-step pipeline in one generic error. - Do not encode the same distinction twice with both a specific error tag and a single-value `operation`, `reason`, `kind`, or `phase` literal. Split into separate error classes when a discriminator drives caller control flow or the user-facing message; a discriminator used only for diagnostics may stay a field. Caller-visible messages exposed through HTTP, RPC, persisted state, or UI are behavior and must survive a structural refactor. - Do not add a helper whose only behavior is `(...args) => new SomeError({ ...args })`. Construct the error at the failure boundary. Keep a mapper only when it performs real normalization, passes through domain errors, or adds reusable context; when such a mapper belongs to the target error type, prefer a static factory on that class. diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 68bf759e414b..f0d46ae1e67f 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -458,7 +458,73 @@ it.effect( Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})), ); expect(missing.XDG_RUNTIME_DIR).toBeUndefined(); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe( + Effect.scoped, + Effect.provide(Layer.provideMerge(ProcessRunner.layer, NodeServices.layer)), + ), +); + +it.effect.each([ + { + name: "old SDK", + front: "emulated webcam none", + back: "emulated virtualscene none", + code: 0, + extra: undefined, + expected: "-camera-front emulated -camera-back emulated", + }, + { + name: "imagefile-capable SDK", + front: "emulated imagefile: none", + back: "emulated imagefile: none", + code: 0, + extra: undefined, + expected: undefined, + }, + { + name: "user overrides", + front: "emulated none", + back: "emulated none", + code: 0, + extra: "-memory 4096 -camera-front none", + expected: "-camera-front emulated -camera-back emulated -memory 4096 -camera-front none", + }, + { + name: "different camera capabilities", + front: "emulated none", + back: "emulated imagefile: none", + code: 0, + extra: undefined, + expected: "-camera-front emulated", + }, + { + name: "failed help probe", + front: "emulated none", + back: "emulated none", + code: 1, + extra: undefined, + expected: undefined, + }, +])("configures supported emulator cameras for $name", ({ front, back, code, extra, expected }) => + Effect.gen(function* () { + const result = yield* LocalDeviceHost.__testing + .hubEnvironment({ ANDROID_HOME: "/sdk", EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS: extra }) + .pipe( + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.UserId, 1000), + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => { + expect(input.command).toBe("/sdk/emulator/emulator"); + expect(input.args[0]).toMatch(/^-help-camera-(front|back)$/); + return Effect.succeed( + processOutput(code, input.args[0] === "-help-camera-front" ? front : back), + ); + }, + }), + Effect.provide(NodeServices.layer), + ); + expect(result.EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS).toBe(expected); + }), ); it.live("limits hub discovery to Android unless the host is a Mac", () => diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index bb8384df8276..7a972b6a677c 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -208,6 +208,40 @@ const hubEnvironment = Effect.fn("LocalDeviceHost.hubEnvironment")(function* ( const env: NodeJS.ProcessEnv = { ...environment, FORCE_COLOR: "0", NO_COLOR: "1" }; const platform = yield* HostProcess.Platform; const uid = yield* HostProcess.UserId; + if (env.ANDROID_HOME) { + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + const emulator = path.join( + env.ANDROID_HOME, + "emulator", + platform === "win32" ? "emulator.exe" : "emulator", + ); + const cameraArgs = yield* Effect.forEach(["front", "back"], (facing) => + runner + .run({ + command: emulator, + args: [`-help-camera-${facing}`], + env, + timeout: Duration.seconds(5), + }) + .pipe( + Effect.map((result) => { + const help = `${result.stdout}\n${result.stderr}`; + // The hub wires imagefile cameras unconditionally; older SDK emulators reject that mode. + return result.code === 0 && /\bemulated\b/.test(help) && !/\bimagefile\b/.test(help) + ? `-camera-${facing} emulated` + : ""; + }), + Effect.orElseSucceed(() => ""), + ), + ); + const overrides = cameraArgs.filter(Boolean).join(" "); + if (overrides) { + env.EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS = [overrides, env.EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS] + .filter(Boolean) + .join(" "); + } + } if (platform === "linux" && env.XDG_RUNTIME_DIR === undefined && uid !== undefined) { // SSH sessions may omit the directory where the emulator publishes its gRPC token. const runtimeDir = `/run/user/${uid}`; @@ -409,6 +443,8 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { stderr: "pipe", env: yield* hubEnvironment(hostEnvironment).pipe( Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.provideService(HostProcess.Platform, hostPlatform), ), }, diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index 91e88e123761..713e0a566280 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -56,6 +56,11 @@ describe("remote helper lifecycle", () => { const bin = NodePath.join(home, "bin"); await NodeFSP.mkdir(bin); await NodeFSP.writeFile(NodePath.join(bin, "adb"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + await NodeFSP.writeFile( + NodePath.join(bin, "emulator"), + "#!/bin/sh\nprintf 'emulated webcam none\\n' >&2\n", + { mode: 0o755 }, + ); const root = NodePath.join(home, ".t3/device"); const hubDir = NodePath.join(root, `tools/expo-device-hub@${DEVICE_HUB_VERSION}`); const agentDir = NodePath.join(root, `tools/agent-device@${AGENT_DEVICE_VERSION}`); @@ -73,7 +78,7 @@ describe("remote helper lifecycle", () => { hub, `import http from 'node:http'; import fs from 'node:fs'; if(fs.existsSync('fail-start-once')) {fs.unlinkSync('fail-start-once');process.exit(1);} -const args=process.argv.slice(2); fs.writeFileSync('hub-args.json',JSON.stringify(args)); http.createServer((req,res)=>{res.statusCode=fs.existsSync('unhealthy-'+process.pid)?503:200;res.end('ok');}).listen(Number(args[args.indexOf('--port')+1]),'127.0.0.1');`, +const args=process.argv.slice(2); fs.writeFileSync('hub-args.json',JSON.stringify(args)); fs.writeFileSync('hub-camera-args',process.env.EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS || ''); http.createServer((req,res)=>{res.statusCode=fs.existsSync('unhealthy-'+process.pid)?503:200;res.end('ok');}).listen(Number(args[args.indexOf('--port')+1]),'127.0.0.1');`, ); await NodeFSP.writeFile( agent, @@ -116,6 +121,8 @@ else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1 HOME: home, PATH: `${bin}:${process.env.PATH}`, SERVE_EMU_FFMPEG: NodePath.join(home, "missing-ffmpeg"), + ANDROID_HOME: "", + EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS: "-memory 4096", }, }); return result.stdout ? JSON.parse(result.stdout) : null; @@ -147,6 +154,9 @@ else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1 expect(concurrent.hubPort).toBe(manual.hubPort); expect(manual.daemonPort).toBeUndefined(); expect((await hubArgs("one")).slice(-2)).toEqual(["--platform", "android"]); + expect( + await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub-camera-args"), "utf8"), + ).toBe("-camera-front emulated -camera-back emulated -memory 4096"); // A hub started before discovery was filtered has no recorded platform. const hubFile = NodePath.join(root, "hosts/one/hub.json"); const { platform: _platform, ...unfilteredHub } = JSON.parse( diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index 3df4677d25b7..149ef7647a13 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -166,19 +166,27 @@ async function install(name, version, entry) { const hubEntry = await install('expo-device-hub', hubVersion, 'dist/server/cli.mjs'); // Only a Mac can list Apple simulators. Recording the filter replaces hubs started without it. const hubPlatform = process.platform === 'darwin' ? undefined : 'android'; + const cameraArgs = android ? ['front', 'back'].flatMap(facing => { + const emulator = process.env.ANDROID_HOME ? path.join(process.env.ANDROID_HOME, 'emulator', 'emulator') : 'emulator'; + const help = run(emulator, ['-help-camera-' + facing], { timeout: 5000 }); + const text = (help.stdout || '') + '\n' + (help.stderr || ''); + // The hub's imagefile camera mode is not supported by every installed SDK emulator. + return help.status === 0 && /\bemulated\b/.test(text) && !/\bimagefile\b/.test(text) ? ['-camera-' + facing, 'emulated'] : []; + }) : []; + const emulatorExtraArgs = [cameraArgs.join(' '), process.env.EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS].filter(Boolean).join(' '); let hub = read(hubFile); - if (!hub || hub.owner !== owner || hub.entryPath !== hubEntry || hub.platform !== hubPlatform || !await healthy(hub.port, '/readyz')) { + if (!hub || hub.owner !== owner || hub.entryPath !== hubEntry || hub.platform !== hubPlatform || hub.emulatorExtraArgs !== emulatorExtraArgs || !await healthy(hub.port, '/readyz')) { stopHub(hub); for (let attempt = 0; attempt < 5; attempt++) { const hubPort = await port(); const log = fs.openSync(path.join(state, 'hub.log'), 'a'); const child = spawn(process.execPath, [hubEntry, '--port', String(hubPort), '--host', '127.0.0.1', '--hide-sidebar', '--hide-boot-device', ...(hubPlatform ? ['--platform', hubPlatform] : [])], { - cwd: state, detached: true, stdio: ['ignore', log, log], env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' }, + cwd: state, detached: true, stdio: ['ignore', log, log], env: { ...process.env, EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS: emulatorExtraArgs, FORCE_COLOR: '0', NO_COLOR: '1' }, }); try { await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); } finally { fs.closeSync(log); } child.unref(); - hub = { owner, pid: child.pid, port: hubPort, entryPath: hubEntry, platform: hubPlatform }; + hub = { owner, pid: child.pid, port: hubPort, entryPath: hubEntry, platform: hubPlatform, emulatorExtraArgs }; write(hubFile, hub); const deadline = Date.now() + 30000; let listening = false; diff --git a/docs/internals/effect-services.md b/docs/internals/effect-services.md index 4da6357c0a91..c29016dbf85d 100644 --- a/docs/internals/effect-services.md +++ b/docs/internals/effect-services.md @@ -114,6 +114,9 @@ optional-service layer. Don't route around the layer with an imperative runtime. - **Safe values.** Attributes and log annotations stay bounded: no raw payloads, command arguments or output, signed URLs, credentials, query strings, or arbitrary defect text. The exact value lives only in `cause`. Expose a category, length, count, or a URL's protocol and host instead. + `DeviceBootError.diagnostics` is a deliberate exception: authorized device users and agents + need the device hub's launch command and failure text to diagnose host setup. It is capped at + 2,000 characters and included in the error message; other service errors keep the rule above. - **Translation.** Construct the error where the failure happens, and map it to a transport error only in the transport. A translation boundary passes through domain errors already in the target channel and wraps only unknown or lower-level failures. Map each failure where its context is