From 49830168d7789baf82dca3a18e1df08eec75c235 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Tue, 27 Oct 2026 13:21:26 +0000 Subject: [PATCH 01/21] fix(devices): a failed simulator listing keeps the last devices When simctl hung, the hub's device list timed out after 15 s and the host was marked failed with a generic network error. When simctl failed at once, the hub's empty simulator list replaced every known iOS device, and setup suggested installing a runtime. Device listing now waits 40 s, longer than a hub-side 30 s simctl timeout, and a timeout reports that the hub did not answer. A timeout or a hub report that simctl could not list devices marks those platforms incomplete, so the host stays ready, keeps its last devices for them, and shows the warning until a later listing succeeds. Setup no longer blames a missing runtime when discovery reported a problem. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceHost.ts | 2 + apps/server/src/device/DeviceHubOperations.ts | 53 ++++++-- apps/server/src/device/DeviceService.test.ts | 127 ++++++++++++++++++ apps/server/src/device/DeviceService.ts | 14 +- .../src/components/device/DeviceSetup.test.ts | 17 +++ .../web/src/components/device/DeviceSetup.tsx | 11 ++ 6 files changed, 206 insertions(+), 18 deletions(-) diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index 7a0962e06f18b..ef0b48eefc80f 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -110,6 +110,8 @@ export interface DeviceOperations { readonly detail: string | undefined; /** Some Android virtual devices could not be listed; the rest are. */ readonly androidDiscoveryIncomplete?: boolean; + /** Platforms that could not be listed; their last known devices stay listed. */ + readonly incompletePlatforms?: ReadonlyArray; }, DeviceOperationError >; diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index 772b626619487..91948b804573f 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -6,6 +6,7 @@ import { } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; @@ -13,6 +14,11 @@ import type { DeviceHostReady, DeviceOperations, HostDevice } from "./DeviceHost const BOOT_TIMEOUT = Duration.minutes(3); const SCREENSHOT_TIMEOUT = Duration.seconds(20); +// Outlasts a hub-side 30 s simctl timeout, so the hub's own explanation can arrive first. +const LIST_TIMEOUT_SECONDS = 40; +// simctl could not list simulators at all, so the hub's empty iOS list says nothing. +const SIMULATOR_LIST_FAILED = + /^\[apple-utils\] Failed to (?:run `xcrun simctl list devices|parse JSON output:|list devices:)/; const HubDevice = Schema.Struct({ id: Schema.String, @@ -69,25 +75,30 @@ export const makeDeviceHubOperations = (options: { const { hostId, origin, run } = options; const httpClient = options.http.pipe(HttpClient.withScope); + const requestFailed = (operation: string) => (cause: unknown) => + new DeviceOperationError({ operation, reason: "request_failed", cause }); + + const fetchJson = ( + request: HttpClientRequest.HttpClientRequest, + schema: Schema.Codec, + ) => + httpClient + .execute(request) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), + Effect.scoped, + ); + const hubJson = ( request: HttpClientRequest.HttpClientRequest, schema: Schema.Codec, operation: string, timeout: Duration.Input = Duration.seconds(15), ) => - httpClient.execute(request).pipe( - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), - Effect.scoped, + fetchJson(request, schema).pipe( Effect.timeout(timeout), - Effect.mapError( - (cause) => - new DeviceOperationError({ - operation, - reason: "request_failed", - cause, - }), - ), + Effect.mapError(requestFailed(operation)), ); const postJson = (path: string, body: Record, operation: string) => @@ -106,11 +117,21 @@ export const makeDeviceHubOperations = (options: { ); const list: DeviceOperations["list"] = Effect.gen(function* () { - const hubList = yield* hubJson( + const listed = yield* fetchJson( HttpClientRequest.get(`${origin}/api/devices`), HubDeviceList, - "list", + ).pipe( + Effect.mapError(requestFailed("list")), + Effect.timeoutOption(Duration.seconds(LIST_TIMEOUT_SECONDS)), ); + if (Option.isNone(listed)) { + return { + devices: [], + detail: `The device hub did not answer within ${LIST_TIMEOUT_SECONDS} seconds. Devices found earlier stay listed.`, + incompletePlatforms: ["ios", "android"] as const, + }; + } + const hubList = listed.value; const devices: Array = [...hubList.simulators, ...hubList.emulators]; const androidAvailable = yield* options.androidAvailable; const warnings = hubList.errors?.filter( @@ -155,10 +176,14 @@ export const makeDeviceHubOperations = (options: { } } } + const simulatorsUnlisted = + hubList.simulators.length === 0 && + (hubList.errors ?? []).some((error) => SIMULATOR_LIST_FAILED.test(error.message)); return { devices, detail: details.join("\n") || undefined, androidDiscoveryIncomplete, + ...(simulatorsUnlisted ? { incompletePlatforms: ["ios"] as const } : {}), }; }).pipe(Effect.withSpan("DeviceHubOperations.list")); diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index cdcb100918beb..fba075275b6ba 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -26,6 +26,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as ServerConfig from "../config.ts"; @@ -1239,6 +1240,132 @@ it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), ); +const discoveryFixture = Effect.fn("discoveryFixture")(function* () { + const hubDevices = [ + { id: "iphone", name: "iPhone", platform: "ios", physical: false }, + { id: "emulator-5554", name: "Running_AVD", platform: "android", physical: false }, + ].map((device) => ({ ...device, booted: true, version: "test" })); + const hub = { + mode: "listed" as "listed" | "empty" | "simctl_failed" | "hung", + requested: yield* Deferred.make(), + }; + const http = HttpClient.make((request) => + Effect.gen(function* () { + if (hub.mode === "hung") { + yield* Deferred.succeed(hub.requested, undefined); + return yield* Effect.never; + } + return HttpClientResponse.fromWeb( + request, + Response.json( + hub.mode === "listed" + ? { simulators: hubDevices.slice(0, 1), emulators: hubDevices.slice(1) } + : hub.mode === "empty" + ? { simulators: [], emulators: [] } + : { + simulators: [], + emulators: hubDevices.slice(1), + errors: [ + { + message: "[apple-utils] Failed to run `xcrun simctl list devices --json`:", + error: "Unable to connect to CoreSimulatorService", + }, + ], + }, + ), + ); + }), + ); + const run = () => Effect.succeed({ code: 0, stdout: "", stderr: "" }); + const ready: DeviceHost.DeviceHostReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + devices: makeDeviceHubOperations({ + hostId: LOCAL_DEVICE_HOST_ID, + origin: "http://device.test", + http, + run, + androidAvailable: Effect.succeed(true), + }), + helpers: { serveSimAxSettings: null, serveSimCli: null }, + run, + }; + const host: DeviceHost.DeviceHost["Service"] = { + id: LOCAL_DEVICE_HOST_ID, + label: "This machine", + summary: Effect.succeed({ + id: LOCAL_DEVICE_HOST_ID, + kind: "local", + label: "This machine", + platforms: [ + { platform: "ios", available: true }, + { platform: "android", available: true }, + ], + hubInstalled: true, + agentDeviceInstalled: false, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => Effect.succeed(ready), + ensureAgentReady: () => Effect.die("Agent access is not used in this test"), + current: Effect.succeed(ready), + stop: Effect.void, + stopAgent: Effect.void, + }; + const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe( + Effect.provide(NodeCrypto.layer), + ); + const listed = hubDevices.map((device) => ({ ...device, hostId: LOCAL_DEVICE_HOST_ID })); + return { service, hub, listed }; +}); + +it.effect( + "keeps the last simulators while simctl fails and drops them once a listing succeeds", + () => + Effect.gen(function* () { + const { service, hub, listed } = yield* discoveryFixture(); + expect((yield* service.list).devices).toEqual(listed); + + hub.mode = "simctl_failed"; + const failed = yield* service.list; + expect(failed.devices).toEqual(listed); + expect(failed.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ + status: "ready", + detail: "[apple-utils] Failed to run `xcrun simctl list devices --json`:", + }); + + hub.mode = "empty"; + const emptied = yield* service.list; + expect(emptied.devices).toEqual([]); + expect(emptied.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ status: "ready" }); + }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), +); + +it.effect( + "waits past the hub's own simctl timeout and keeps devices when the hub never answers", + () => + Effect.gen(function* () { + const { service, hub, listed } = yield* discoveryFixture(); + yield* service.list; + + hub.mode = "hung"; + const listing = yield* service.list.pipe(Effect.forkScoped); + yield* Deferred.await(hub.requested); + yield* TestClock.adjust("40 seconds"); + const timedOut = yield* Fiber.join(listing); + expect(timedOut.devices).toEqual(listed); + expect(timedOut.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ + status: "ready", + detail: + "The device hub did not answer within 40 seconds. Devices found earlier stay listed.", + }); + + hub.mode = "listed"; + const recovered = yield* service.list; + expect(recovered.devices).toEqual(listed); + expect(recovered.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ status: "ready" }); + }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), +); + it.effect("retry keeps device and agent consent unchanged", () => Effect.gen(function* () { const { service, starts, agentStarts } = yield* fixture(); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index d91c9d8d0b53b..a655e2e0cfed4 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -529,11 +529,13 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ); const fetchDevices = Effect.fn("DeviceService.fetchDevices")(function* (ready: DeviceReadiness) { - const { devices, detail, androidDiscoveryIncomplete } = yield* ready.devices.list; + const { devices, detail, androidDiscoveryIncomplete, incompletePlatforms } = + yield* ready.devices.list; return { devices: devices.map((device): DeviceSummary => ({ ...device, hostId: ready.hostId })), detail, androidDiscoveryIncomplete, + incompletePlatforms: incompletePlatforms ?? [], }; }); @@ -544,7 +546,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const sessionKey = (session: DeviceSession) => `${session.hostId}\u0000${session.deviceId}\u0000${session.threadId}\u0000${session.openedAt}`; const before = new Set((yield* SynchronizedRef.get(stateRef)).state.sessions.map(sessionKey)); - const { devices, detail, androidDiscoveryIncomplete } = yield* fetchDevices(ready); + const { devices, detail, androidDiscoveryIncomplete, incompletePlatforms } = + yield* fetchDevices(ready); const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary); return yield* lifecycleLock.withPermit( Effect.gen(function* () { @@ -555,8 +558,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* hosts: hostSummaries, ...(ready.hostId === LOCAL_DEVICE_HOST_ID ? { hostStatusDetail: detail } : {}), devices: [ - ...state.devices.filter((device) => device.hostId !== ready.hostId), - ...devices, + ...state.devices.filter( + (device) => + device.hostId !== ready.hostId || incompletePlatforms.includes(device.platform), + ), + ...devices.filter((device) => !incompletePlatforms.includes(device.platform)), ], // A host that ends devices itself (EAS stops sessions at their limits) // leaves sessions on devices it no longer lists with nothing to stream. diff --git a/apps/web/src/components/device/DeviceSetup.test.ts b/apps/web/src/components/device/DeviceSetup.test.ts index 322354763b8d0..62915d9125238 100644 --- a/apps/web/src/components/device/DeviceSetup.test.ts +++ b/apps/web/src/components/device/DeviceSetup.test.ts @@ -43,6 +43,23 @@ describe("device setup guidance", () => { ).toContain("Device Manager"); }); + it("does not blame a missing runtime when device discovery reported a problem", () => { + const failed = deviceState({ + hostStatusDetail: "[apple-utils] Failed to run `xcrun simctl list devices --json`:", + hostStatuses: { + local: { + status: "ready", + detail: "[apple-utils] Failed to run `xcrun simctl list devices --json`:", + }, + }, + }); + expect(platformSetupStatus(failed, "ios")).toEqual({ + ready: false, + message: "No iOS devices were listed because device discovery reported a problem.", + }); + expect(platformSetupStatus(deviceState(), "ios").message).toContain("Xcode Settings"); + }); + it("counts a remote device host when this machine has no simulator tooling", () => { const state = deviceState({ hostStatus: "idle", diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx index 29b46e99319a4..594ff30fff158 100644 --- a/apps/web/src/components/device/DeviceSetup.tsx +++ b/apps/web/src/components/device/DeviceSetup.tsx @@ -103,6 +103,17 @@ export function platformSetupStatus(state: DeviceServiceState, platform: DeviceP (host.kind === "local" && state.hostStatus === "ready"), ); if (readyHosts.length > 0 && !hasDevices) { + if ( + readyHosts.some( + (host) => + state.hostStatuses[host.id]?.detail || (host.kind === "local" && state.hostStatusDetail), + ) + ) { + return { + ready: false, + message: `No ${platformName(platform)} devices were listed because device discovery reported a problem.`, + }; + } return { ready: false, message: From a0e2364b38450aee81e0731bf93f1d91635e63ed Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Tue, 27 Oct 2026 13:39:19 +0000 Subject: [PATCH 02/21] fix(devices): keep setup guidance specific to each platform --- apps/server/src/device/DeviceHubOperations.ts | 2 +- apps/server/src/device/DeviceService.test.ts | 45 +++++++++---------- .../src/components/device/DeviceSetup.test.ts | 17 +++++++ .../web/src/components/device/DeviceSetup.tsx | 17 +++++-- 4 files changed, 52 insertions(+), 29 deletions(-) diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index 91948b804573f..6653e2ea3b364 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -14,7 +14,7 @@ import type { DeviceHostReady, DeviceOperations, HostDevice } from "./DeviceHost const BOOT_TIMEOUT = Duration.minutes(3); const SCREENSHOT_TIMEOUT = Duration.seconds(20); -// Outlasts a hub-side 30 s simctl timeout, so the hub's own explanation can arrive first. +// Allow the proposed upstream 30 s simctl timeout to report its cause first. const LIST_TIMEOUT_SECONDS = 40; // simctl could not list simulators at all, so the hub's empty iOS list says nothing. const SIMULATOR_LIST_FAILED = diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index fba075275b6ba..c9df75384087d 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -1264,7 +1264,7 @@ const discoveryFixture = Effect.fn("discoveryFixture")(function* () { ? { simulators: [], emulators: [] } : { simulators: [], - emulators: hubDevices.slice(1), + emulators: [], errors: [ { message: "[apple-utils] Failed to run `xcrun simctl list devices --json`:", @@ -1327,7 +1327,7 @@ it.effect( hub.mode = "simctl_failed"; const failed = yield* service.list; - expect(failed.devices).toEqual(listed); + expect(failed.devices).toEqual(listed.filter((device) => device.platform === "ios")); expect(failed.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ status: "ready", detail: "[apple-utils] Failed to run `xcrun simctl list devices --json`:", @@ -1340,30 +1340,27 @@ it.effect( }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), ); -it.effect( - "waits past the hub's own simctl timeout and keeps devices when the hub never answers", - () => - Effect.gen(function* () { - const { service, hub, listed } = yield* discoveryFixture(); - yield* service.list; +it.effect("keeps devices with a warning when the hub never answers within 40 seconds", () => + Effect.gen(function* () { + const { service, hub, listed } = yield* discoveryFixture(); + yield* service.list; - hub.mode = "hung"; - const listing = yield* service.list.pipe(Effect.forkScoped); - yield* Deferred.await(hub.requested); - yield* TestClock.adjust("40 seconds"); - const timedOut = yield* Fiber.join(listing); - expect(timedOut.devices).toEqual(listed); - expect(timedOut.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ - status: "ready", - detail: - "The device hub did not answer within 40 seconds. Devices found earlier stay listed.", - }); + hub.mode = "hung"; + const listing = yield* service.list.pipe(Effect.forkScoped); + yield* Deferred.await(hub.requested); + yield* TestClock.adjust("40 seconds"); + const timedOut = yield* Fiber.join(listing); + expect(timedOut.devices).toEqual(listed); + expect(timedOut.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ + status: "ready", + detail: "The device hub did not answer within 40 seconds. Devices found earlier stay listed.", + }); - hub.mode = "listed"; - const recovered = yield* service.list; - expect(recovered.devices).toEqual(listed); - expect(recovered.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ status: "ready" }); - }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), + hub.mode = "listed"; + const recovered = yield* service.list; + expect(recovered.devices).toEqual(listed); + expect(recovered.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ status: "ready" }); + }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), ); it.effect("retry keeps device and agent consent unchanged", () => diff --git a/apps/web/src/components/device/DeviceSetup.test.ts b/apps/web/src/components/device/DeviceSetup.test.ts index 62915d9125238..6f81d15f0bac4 100644 --- a/apps/web/src/components/device/DeviceSetup.test.ts +++ b/apps/web/src/components/device/DeviceSetup.test.ts @@ -57,9 +57,26 @@ describe("device setup guidance", () => { ready: false, message: "No iOS devices were listed because device discovery reported a problem.", }); + expect(platformSetupStatus(failed, "android").message).toContain("Device Manager"); + const androidFailure = deviceState({ + hostStatuses: { + local: { status: "ready", detail: "[android-utils] Failed to run `avdmanager list avd`:" }, + }, + }); + expect(platformSetupStatus(androidFailure, "ios").message).toContain("Xcode Settings"); + expect(platformSetupStatus(androidFailure, "android").message).toContain("discovery reported"); expect(platformSetupStatus(deviceState(), "ios").message).toContain("Xcode Settings"); }); + it("reports an unanswered hub for either platform", () => { + const state = deviceState({ + hostStatusDetail: + "The device hub did not answer within 40 seconds. Devices found earlier stay listed.", + }); + expect(platformSetupStatus(state, "ios").message).toContain("discovery reported"); + expect(platformSetupStatus(state, "android").message).toContain("discovery reported"); + }); + it("counts a remote device host when this machine has no simulator tooling", () => { const state = deviceState({ hostStatus: "idle", diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx index 594ff30fff158..724c262483370 100644 --- a/apps/web/src/components/device/DeviceSetup.tsx +++ b/apps/web/src/components/device/DeviceSetup.tsx @@ -103,11 +103,20 @@ export function platformSetupStatus(state: DeviceServiceState, platform: DeviceP (host.kind === "local" && state.hostStatus === "ready"), ); if (readyHosts.length > 0 && !hasDevices) { + const diagnosticPrefix = platform === "ios" ? "[apple-utils] " : "[android-utils] "; if ( - readyHosts.some( - (host) => - state.hostStatuses[host.id]?.detail || (host.kind === "local" && state.hostStatusDetail), - ) + readyHosts.some((host) => { + const detail = + state.hostStatuses[host.id]?.detail ?? + (host.kind === "local" ? state.hostStatusDetail : undefined); + return detail + ?.split("\n") + .some( + (line) => + line.startsWith(diagnosticPrefix) || + line.startsWith("The device hub did not answer within "), + ); + }) ) { return { ready: false, From 84342a4206dc9f6fdc42b29c70f56b7da563c26f Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Tue, 27 Oct 2026 14:22:14 +0000 Subject: [PATCH 03/21] fix(devices): retain simulators after hub listing timeouts --- apps/server/src/device/DeviceHubOperations.ts | 2 +- apps/server/src/device/DeviceService.test.ts | 43 ++++++++++--------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index 6653e2ea3b364..69697c5f1e6be 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -18,7 +18,7 @@ const SCREENSHOT_TIMEOUT = Duration.seconds(20); const LIST_TIMEOUT_SECONDS = 40; // simctl could not list simulators at all, so the hub's empty iOS list says nothing. const SIMULATOR_LIST_FAILED = - /^\[apple-utils\] Failed to (?:run `xcrun simctl list devices|parse JSON output:|list devices:)/; + /^\[apple-utils\] (?:Failed to (?:run `xcrun simctl list devices|parse JSON output:|list devices:)|`xcrun simctl list devices --json` did not respond within )/; const HubDevice = Schema.Struct({ id: Schema.String, diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index c9df75384087d..20910e669588f 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -1240,7 +1240,9 @@ it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), ); -const discoveryFixture = Effect.fn("discoveryFixture")(function* () { +const discoveryFixture = Effect.fn("discoveryFixture")(function* ( + simctlError = "[apple-utils] Failed to run `xcrun simctl list devices --json`:", +) { const hubDevices = [ { id: "iphone", name: "iPhone", platform: "ios", physical: false }, { id: "emulator-5554", name: "Running_AVD", platform: "android", physical: false }, @@ -1267,7 +1269,7 @@ const discoveryFixture = Effect.fn("discoveryFixture")(function* () { emulators: [], errors: [ { - message: "[apple-utils] Failed to run `xcrun simctl list devices --json`:", + message: simctlError, error: "Unable to connect to CoreSimulatorService", }, ], @@ -1318,26 +1320,27 @@ const discoveryFixture = Effect.fn("discoveryFixture")(function* () { return { service, hub, listed }; }); -it.effect( - "keeps the last simulators while simctl fails and drops them once a listing succeeds", - () => - Effect.gen(function* () { - const { service, hub, listed } = yield* discoveryFixture(); - expect((yield* service.list).devices).toEqual(listed); +it.effect.each([ + "[apple-utils] Failed to run `xcrun simctl list devices --json`:", + "[apple-utils] `xcrun simctl list devices --json` did not respond within 30 s; CoreSimulatorService may need restarting", +])("keeps the last simulators after %s and drops them once a listing succeeds", (simctlError) => + Effect.gen(function* () { + const { service, hub, listed } = yield* discoveryFixture(simctlError); + expect((yield* service.list).devices).toEqual(listed); - hub.mode = "simctl_failed"; - const failed = yield* service.list; - expect(failed.devices).toEqual(listed.filter((device) => device.platform === "ios")); - expect(failed.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ - status: "ready", - detail: "[apple-utils] Failed to run `xcrun simctl list devices --json`:", - }); + hub.mode = "simctl_failed"; + const failed = yield* service.list; + expect(failed.devices).toEqual(listed.filter((device) => device.platform === "ios")); + expect(failed.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ + status: "ready", + detail: simctlError, + }); - hub.mode = "empty"; - const emptied = yield* service.list; - expect(emptied.devices).toEqual([]); - expect(emptied.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ status: "ready" }); - }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), + hub.mode = "empty"; + const emptied = yield* service.list; + expect(emptied.devices).toEqual([]); + expect(emptied.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ status: "ready" }); + }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), ); it.effect("keeps devices with a warning when the hub never answers within 40 seconds", () => From 945cab8be4c7aca5ba3f1b860ef9e4d3fef8d712 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 29 Oct 2026 02:46:37 +0000 Subject: [PATCH 04/21] fix(devices): show discovery problems as one quiet line The device list opened with a yellow banner holding the raw simctl error. Discovery problems now show as one muted line per host, "Some devices on could not be listed.", with the raw error behind Details. The Tools panel of an open device uses the same line. The timeout message no longer says earlier devices stay listed, since there may be none. The listing tests reuse the existing DeviceService fixture instead of a second one, and the setup guidance cases are one table. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceHubOperations.ts | 2 +- apps/server/src/device/DeviceService.test.ts | 181 ++++++------------ .../device/DeviceDiscoveryStatus.tsx | 33 ++++ .../web/src/components/device/DevicePanel.tsx | 39 +--- .../src/components/device/DeviceSetup.test.ts | 41 +--- .../components/device/DeviceToolsPanel.tsx | 12 +- .../src/components/device/DeviceWorkspace.tsx | 2 +- 7 files changed, 113 insertions(+), 197 deletions(-) create mode 100644 apps/web/src/components/device/DeviceDiscoveryStatus.tsx diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index 69697c5f1e6be..8d6d5e162b28f 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -127,7 +127,7 @@ export const makeDeviceHubOperations = (options: { if (Option.isNone(listed)) { return { devices: [], - detail: `The device hub did not answer within ${LIST_TIMEOUT_SECONDS} seconds. Devices found earlier stay listed.`, + detail: `The device hub did not answer within ${LIST_TIMEOUT_SECONDS} seconds.`, incompletePlatforms: ["ios", "android"] as const, }; } diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 20910e669588f..13aa7791b33c4 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -101,6 +101,7 @@ const fixture = Effect.fn("fixture")(function* ( const discoveryErrors = yield* Ref.make>([]); const androidAvailable = yield* Ref.make(true); const encoderResult = yield* Ref.make({ code: 0, stdout: " V....D libx264", stderr: "" }); + const listing = { hangs: false, requested: yield* Deferred.make() }; let booted = false; let shutDown = false; const connection = { online: true, kind: "local" as "local" | "ssh", failShutdown: false }; @@ -165,6 +166,10 @@ const fixture = Effect.fn("fixture")(function* ( if (request.url.endsWith("/grid/api/start")) { return HttpClientResponse.fromWeb(request, Response.json({ ok: true })); } + if (listing.hangs) { + yield* Deferred.succeed(listing.requested, undefined); + return yield* Effect.never; + } return HttpClientResponse.fromWeb( request, Response.json({ @@ -327,6 +332,7 @@ const fixture = Effect.fn("fixture")(function* ( discoveryErrors, androidAvailable, endDevice, + listing, }; }); @@ -690,6 +696,55 @@ it.effect("keeps the cause from the hub's error in discovery warnings, without t }).pipe(Effect.scoped), ); +const simulator = { + id: "iphone", + name: "iPhone", + platform: "ios", + version: "iOS 26", + booted: true, + physical: false, +} as const; +const androidPhone = { ...simulator, id: "pixel", name: "Pixel", platform: "android" } as const; + +it.effect.each([ + "[apple-utils] Failed to run `xcrun simctl list devices --json`:", + "[apple-utils] `xcrun simctl list devices --json` did not respond within 30 s", +])("keeps the last simulators after %s while Android still refreshes", (message) => + Effect.gen(function* () { + const { service, devices, discoveryErrors } = yield* fixture(); + devices.push(simulator, androidPhone); + yield* service.configure({ enabled: true }); + devices.length = 0; + yield* Ref.set(discoveryErrors, [{ message }]); + const failed = yield* service.list; + expect(failed.devices.map((device) => device.id)).toEqual(["iphone", "Pixel_API_35"]); + expect(failed.hostStatuses.local).toEqual({ status: "ready", detail: message }); + + yield* Ref.set(discoveryErrors, []); + const emptied = yield* service.list; + expect(emptied.devices.map((device) => device.id)).toEqual(["Pixel_API_35"]); + expect(emptied.hostStatuses.local).toEqual({ status: "ready" }); + }).pipe(Effect.scoped), +); + +it.effect("keeps every device when the hub does not answer the listing within 40 seconds", () => + Effect.gen(function* () { + const { service, devices, listing } = yield* fixture(); + devices.push(simulator); + const before = yield* service.configure({ enabled: true }); + listing.hangs = true; + const listed = yield* service.list.pipe(Effect.forkScoped); + yield* Deferred.await(listing.requested); + yield* TestClock.adjust("40 seconds"); + const timedOut = yield* Fiber.join(listed); + expect(timedOut.devices).toEqual(before.devices); + expect(timedOut.hostStatuses.local).toEqual({ + status: "ready", + detail: "The device hub did not answer within 40 seconds.", + }); + }).pipe(Effect.scoped), +); + it.effect.each([ { diagnostic: "Insufficient disk space at /private/user/path", @@ -1240,132 +1295,6 @@ it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), ); -const discoveryFixture = Effect.fn("discoveryFixture")(function* ( - simctlError = "[apple-utils] Failed to run `xcrun simctl list devices --json`:", -) { - const hubDevices = [ - { id: "iphone", name: "iPhone", platform: "ios", physical: false }, - { id: "emulator-5554", name: "Running_AVD", platform: "android", physical: false }, - ].map((device) => ({ ...device, booted: true, version: "test" })); - const hub = { - mode: "listed" as "listed" | "empty" | "simctl_failed" | "hung", - requested: yield* Deferred.make(), - }; - const http = HttpClient.make((request) => - Effect.gen(function* () { - if (hub.mode === "hung") { - yield* Deferred.succeed(hub.requested, undefined); - return yield* Effect.never; - } - return HttpClientResponse.fromWeb( - request, - Response.json( - hub.mode === "listed" - ? { simulators: hubDevices.slice(0, 1), emulators: hubDevices.slice(1) } - : hub.mode === "empty" - ? { simulators: [], emulators: [] } - : { - simulators: [], - emulators: [], - errors: [ - { - message: simctlError, - error: "Unable to connect to CoreSimulatorService", - }, - ], - }, - ), - ); - }), - ); - const run = () => Effect.succeed({ code: 0, stdout: "", stderr: "" }); - const ready: DeviceHost.DeviceHostReady = { - nodePath: process.execPath, - hub: { origin: "http://device.test" }, - devices: makeDeviceHubOperations({ - hostId: LOCAL_DEVICE_HOST_ID, - origin: "http://device.test", - http, - run, - androidAvailable: Effect.succeed(true), - }), - helpers: { serveSimAxSettings: null, serveSimCli: null }, - run, - }; - const host: DeviceHost.DeviceHost["Service"] = { - id: LOCAL_DEVICE_HOST_ID, - label: "This machine", - summary: Effect.succeed({ - id: LOCAL_DEVICE_HOST_ID, - kind: "local", - label: "This machine", - platforms: [ - { platform: "ios", available: true }, - { platform: "android", available: true }, - ], - hubInstalled: true, - agentDeviceInstalled: false, - }), - platformAvailability: (platform) => Effect.succeed({ platform, available: true }), - ensureReady: () => Effect.succeed(ready), - ensureAgentReady: () => Effect.die("Agent access is not used in this test"), - current: Effect.succeed(ready), - stop: Effect.void, - stopAgent: Effect.void, - }; - const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe( - Effect.provide(NodeCrypto.layer), - ); - const listed = hubDevices.map((device) => ({ ...device, hostId: LOCAL_DEVICE_HOST_ID })); - return { service, hub, listed }; -}); - -it.effect.each([ - "[apple-utils] Failed to run `xcrun simctl list devices --json`:", - "[apple-utils] `xcrun simctl list devices --json` did not respond within 30 s; CoreSimulatorService may need restarting", -])("keeps the last simulators after %s and drops them once a listing succeeds", (simctlError) => - Effect.gen(function* () { - const { service, hub, listed } = yield* discoveryFixture(simctlError); - expect((yield* service.list).devices).toEqual(listed); - - hub.mode = "simctl_failed"; - const failed = yield* service.list; - expect(failed.devices).toEqual(listed.filter((device) => device.platform === "ios")); - expect(failed.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ - status: "ready", - detail: simctlError, - }); - - hub.mode = "empty"; - const emptied = yield* service.list; - expect(emptied.devices).toEqual([]); - expect(emptied.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ status: "ready" }); - }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), -); - -it.effect("keeps devices with a warning when the hub never answers within 40 seconds", () => - Effect.gen(function* () { - const { service, hub, listed } = yield* discoveryFixture(); - yield* service.list; - - hub.mode = "hung"; - const listing = yield* service.list.pipe(Effect.forkScoped); - yield* Deferred.await(hub.requested); - yield* TestClock.adjust("40 seconds"); - const timedOut = yield* Fiber.join(listing); - expect(timedOut.devices).toEqual(listed); - expect(timedOut.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ - status: "ready", - detail: "The device hub did not answer within 40 seconds. Devices found earlier stay listed.", - }); - - hub.mode = "listed"; - const recovered = yield* service.list; - expect(recovered.devices).toEqual(listed); - expect(recovered.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ status: "ready" }); - }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), -); - it.effect("retry keeps device and agent consent unchanged", () => Effect.gen(function* () { const { service, starts, agentStarts } = yield* fixture(); diff --git a/apps/web/src/components/device/DeviceDiscoveryStatus.tsx b/apps/web/src/components/device/DeviceDiscoveryStatus.tsx new file mode 100644 index 0000000000000..9ccc259c419fb --- /dev/null +++ b/apps/web/src/components/device/DeviceDiscoveryStatus.tsx @@ -0,0 +1,33 @@ +import { ChevronDown, CircleAlert } from "lucide-react"; +import { useState } from "react"; + +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "~/components/ui/collapsible"; +import { cn } from "~/lib/utils"; + +export function DeviceDiscoveryStatus(props: { readonly label: string; readonly detail: string }) { + const [open, setOpen] = useState(false); + return ( +
+ +
+ + + Some devices on {props.label} could not be listed. + + + Details + + +
+ +

+ {props.detail} +

+
+
+
+ ); +} diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index cee7a3e4e4bcd..b0043e838a599 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -30,6 +30,7 @@ import { } from "~/state/device"; import { formatEnvironmentQueryError } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; +import { DeviceDiscoveryStatus } from "./DeviceDiscoveryStatus"; import { DeviceLoadingView } from "./DeviceLoadingView"; import { DeviceSetup, deviceHubReadiness, isAndroidDiscoveryUncertain } from "./DeviceSetup"; import { DeviceWorkspace } from "./DeviceWorkspace"; @@ -213,8 +214,8 @@ export function DevicePanel(props: { > ) : null} {operationError ? ( - setOperation(null)}> - {operationError} - + setOperation(null)}>{operationError} ) : null}
@@ -241,9 +240,7 @@ export function DevicePanel(props: { device={activeDevice} hostLabel={activeHost?.label ?? "Device host"} hostKind={activeHost?.kind} - hostDiagnostics={ - diagnostics.find(({ hostId }) => hostId === activeDevice.hostId)?.detail - } + hostDiagnostics={diagnostics.find(({ hostId }) => hostId === activeDevice.hostId)} visible={props.visible} onFloat={floatActive} onClose={() => closeActive(false)} @@ -373,20 +370,11 @@ export function DevicePanel(props: { ); } -function DeviceNotice(props: { - readonly tone: "warning" | "error"; - readonly onDismiss: () => void; - readonly children: ReactNode; -}) { +function DeviceError(props: { readonly onDismiss: () => void; readonly children: ReactNode }) { return (
@@ -407,17 +395,6 @@ function DeviceNotice(props: { ); } -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/apps/web/src/components/device/DeviceSetup.test.ts b/apps/web/src/components/device/DeviceSetup.test.ts index 6f81d15f0bac4..a275ab299354c 100644 --- a/apps/web/src/components/device/DeviceSetup.test.ts +++ b/apps/web/src/components/device/DeviceSetup.test.ts @@ -43,38 +43,15 @@ describe("device setup guidance", () => { ).toContain("Device Manager"); }); - it("does not blame a missing runtime when device discovery reported a problem", () => { - const failed = deviceState({ - hostStatusDetail: "[apple-utils] Failed to run `xcrun simctl list devices --json`:", - hostStatuses: { - local: { - status: "ready", - detail: "[apple-utils] Failed to run `xcrun simctl list devices --json`:", - }, - }, - }); - expect(platformSetupStatus(failed, "ios")).toEqual({ - ready: false, - message: "No iOS devices were listed because device discovery reported a problem.", - }); - expect(platformSetupStatus(failed, "android").message).toContain("Device Manager"); - const androidFailure = deviceState({ - hostStatuses: { - local: { status: "ready", detail: "[android-utils] Failed to run `avdmanager list avd`:" }, - }, - }); - expect(platformSetupStatus(androidFailure, "ios").message).toContain("Xcode Settings"); - expect(platformSetupStatus(androidFailure, "android").message).toContain("discovery reported"); - expect(platformSetupStatus(deviceState(), "ios").message).toContain("Xcode Settings"); - }); - - it("reports an unanswered hub for either platform", () => { - const state = deviceState({ - hostStatusDetail: - "The device hub did not answer within 40 seconds. Devices found earlier stay listed.", - }); - expect(platformSetupStatus(state, "ios").message).toContain("discovery reported"); - expect(platformSetupStatus(state, "android").message).toContain("discovery reported"); + it.each([ + ["[apple-utils] Failed to run `xcrun simctl list devices --json`:", true, false], + ["[android-utils] Failed to run `avdmanager list avd`:", false, true], + ["The device hub did not answer within 40 seconds.", true, true], + ])("blames discovery, not missing tools, after %s", (detail, ios, android) => { + const state = deviceState({ hostStatuses: { local: { status: "ready", detail } } }); + const blamesDiscovery = (platform: "ios" | "android") => + platformSetupStatus(state, platform).message.includes("discovery reported a problem"); + expect([blamesDiscovery("ios"), blamesDiscovery("android")]).toEqual([ios, android]); }); it("counts a remote device host when this machine has no simulator tooling", () => { diff --git a/apps/web/src/components/device/DeviceToolsPanel.tsx b/apps/web/src/components/device/DeviceToolsPanel.tsx index 23c5d0097082c..c9cba105080c4 100644 --- a/apps/web/src/components/device/DeviceToolsPanel.tsx +++ b/apps/web/src/components/device/DeviceToolsPanel.tsx @@ -18,6 +18,7 @@ import { Spinner } from "~/components/ui/spinner"; import { Switch } from "~/components/ui/switch"; import { Toggle, ToggleGroup } from "~/components/ui/toggle-group"; import { cn } from "~/lib/utils"; +import { DeviceDiscoveryStatus } from "./DeviceDiscoveryStatus"; import type { DeviceControls } from "./useDeviceControls"; import { deviceEventLogAtom } from "./deviceHubAtoms"; @@ -78,7 +79,7 @@ const LOCATION_PRESETS = [ export function DeviceToolsPanel(props: { readonly controls: DeviceControls; - readonly hostDiagnostics: string | undefined; + readonly hostDiagnostics: { readonly label: string; readonly detail: string } | undefined; readonly device: DeviceSummary; readonly access: DeviceHubAccess | null; readonly axOverlay: boolean; @@ -119,11 +120,10 @@ export function DeviceToolsPanel(props: { ) : null} {props.hostDiagnostics ? ( -
-

- {props.hostDiagnostics} -

-
+ ) : null}
diff --git a/apps/web/src/components/device/DeviceWorkspace.tsx b/apps/web/src/components/device/DeviceWorkspace.tsx index 08ce51c1c548c..1d3ed021a52bb 100644 --- a/apps/web/src/components/device/DeviceWorkspace.tsx +++ b/apps/web/src/components/device/DeviceWorkspace.tsx @@ -17,7 +17,7 @@ export function DeviceWorkspace(props: { device: DeviceSummary; hostLabel: string; hostKind: DeviceHostSummary["kind"] | undefined; - hostDiagnostics: string | undefined; + hostDiagnostics: { readonly label: string; readonly detail: string } | undefined; visible: boolean; onFloat: () => void; onClose: () => void; From 28a8863ac95822aca30440c58189f62dda53f641 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 29 Oct 2026 02:48:20 +0000 Subject: [PATCH 05/21] fix(devices): use the panel for diagnostic scrolling --- apps/web/src/components/device/DeviceDiscoveryStatus.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/web/src/components/device/DeviceDiscoveryStatus.tsx b/apps/web/src/components/device/DeviceDiscoveryStatus.tsx index 9ccc259c419fb..b973a6f333c01 100644 --- a/apps/web/src/components/device/DeviceDiscoveryStatus.tsx +++ b/apps/web/src/components/device/DeviceDiscoveryStatus.tsx @@ -20,12 +20,7 @@ export function DeviceDiscoveryStatus(props: { readonly label: string; readonly
-

- {props.detail} -

+

{props.detail}

From acaca5a5caaf0c1fd2311c273635e80d245b07a7 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 29 Oct 2026 03:53:30 +0000 Subject: [PATCH 06/21] fix(devices): keep warning details in the existing notice --- .../device/DeviceDiscoveryStatus.tsx | 28 ---------- .../web/src/components/device/DevicePanel.tsx | 52 ++++++++++++++++--- .../components/device/DeviceToolsPanel.tsx | 12 ++--- .../src/components/device/DeviceWorkspace.tsx | 2 +- 4 files changed, 51 insertions(+), 43 deletions(-) delete mode 100644 apps/web/src/components/device/DeviceDiscoveryStatus.tsx diff --git a/apps/web/src/components/device/DeviceDiscoveryStatus.tsx b/apps/web/src/components/device/DeviceDiscoveryStatus.tsx deleted file mode 100644 index b973a6f333c01..0000000000000 --- a/apps/web/src/components/device/DeviceDiscoveryStatus.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { ChevronDown, CircleAlert } from "lucide-react"; -import { useState } from "react"; - -import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "~/components/ui/collapsible"; -import { cn } from "~/lib/utils"; - -export function DeviceDiscoveryStatus(props: { readonly label: string; readonly detail: string }) { - const [open, setOpen] = useState(false); - return ( -
- -
- - - Some devices on {props.label} could not be listed. - - - Details - - -
- -

{props.detail}

-
-
-
- ); -} diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index b0043e838a599..0ba918610c822 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -16,6 +16,7 @@ import { type RightPanelSurface, } from "~/rightPanelStore"; import { Button } from "~/components/ui/button"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "~/components/ui/collapsible"; import { DiscoveryList } from "~/components/ui/discovery-list"; import { Dialog } from "~/components/ui/dialog"; import { WizardPopup } from "~/components/ui/wizard"; @@ -30,7 +31,6 @@ import { } from "~/state/device"; import { formatEnvironmentQueryError } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; -import { DeviceDiscoveryStatus } from "./DeviceDiscoveryStatus"; import { DeviceLoadingView } from "./DeviceLoadingView"; import { DeviceSetup, deviceHubReadiness, isAndroidDiscoveryUncertain } from "./DeviceSetup"; import { DeviceWorkspace } from "./DeviceWorkspace"; @@ -214,8 +214,8 @@ export function DevicePanel(props: { > ) : null} {operationError ? ( - setOperation(null)}>{operationError} + setOperation(null)}> + {operationError} + ) : null}
@@ -240,7 +242,9 @@ export function DevicePanel(props: { device={activeDevice} hostLabel={activeHost?.label ?? "Device host"} hostKind={activeHost?.kind} - hostDiagnostics={diagnostics.find(({ hostId }) => hostId === activeDevice.hostId)} + hostDiagnostics={ + diagnostics.find(({ hostId }) => hostId === activeDevice.hostId)?.detail + } visible={props.visible} onFloat={floatActive} onClose={() => closeActive(false)} @@ -370,11 +374,20 @@ export function DevicePanel(props: { ); } -function DeviceError(props: { readonly onDismiss: () => void; readonly children: ReactNode }) { +function DeviceNotice(props: { + readonly tone: "warning" | "error"; + readonly onDismiss: () => void; + readonly children: ReactNode; +}) { return (
void; readonly children:
setOpen(false)}> From cc426702c24a7cdebf7376a6e7bf1aa0a8cfd946 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 29 Oct 2026 04:21:37 +0000 Subject: [PATCH 08/21] fix(devices): use the default discovery trigger layout --- apps/web/src/components/device/DevicePanel.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index db15073f9e7b2..8ed182fdbb358 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -420,9 +420,7 @@ function DeviceDiscoveryWarning(props: { readonly label: string; readonly detail Some devices on {props.label} could not be listed. - - Details - + Details
setOpen(false)}> From 46b77249d71ff52899d00f7594003779f58aadf2 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 30 Oct 2026 01:57:14 +0000 Subject: [PATCH 09/21] fix(devices): pin expo-device-hub with the simctl list timeout Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceHubOperations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index 8d6d5e162b28f..7b45bc2a459df 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -14,7 +14,7 @@ import type { DeviceHostReady, DeviceOperations, HostDevice } from "./DeviceHost const BOOT_TIMEOUT = Duration.minutes(3); const SCREENSHOT_TIMEOUT = Duration.seconds(20); -// Allow the proposed upstream 30 s simctl timeout to report its cause first. +// Let the hub's 30 s simctl timeout report its cause first. const LIST_TIMEOUT_SECONDS = 40; // simctl could not list simulators at all, so the hub's empty iOS list says nothing. const SIMULATOR_LIST_FAILED = From 0ece20b288a34e66d65d3f6073aa6cfd5b6b493e Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 30 Oct 2026 02:03:44 +0000 Subject: [PATCH 10/21] fix(devices): recognize the hub's boot timeout --- apps/server/src/device/DeviceHubOperations.ts | 2 +- apps/server/src/device/DeviceService.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index 7b45bc2a459df..eb27cde17eaed 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -208,7 +208,7 @@ export const makeDeviceHubOperations = (options: { diagnostics, ) ? "disk_space" - : /timed? out|timeout/i.test(diagnostics) + : /timed? out|timeout|did not respond within/i.test(diagnostics) ? "timeout" : "launch_failed", ...(diagnostics ? { diagnostics: boundDiagnostics(diagnostics) } : {}), diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 13aa7791b33c4..a23916025e4e2 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -756,6 +756,12 @@ it.effect.each([ reason: "timeout", message: "did not become ready in time", }, + { + diagnostic: + "[apple-utils] `xcrun simctl boot iphone` did not respond within 120 s; CoreSimulatorService may need restarting", + reason: "timeout", + message: "did not become ready in time", + }, { diagnostic: "The emulator process exited with code 1.\nTry manually: emulator -avd Pixel_API_35", From 925da4d6a1dfa06b2860d647a9c8b93c00a51c28 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 31 Oct 2026 01:01:11 +0000 Subject: [PATCH 11/21] fix(devices): opens of one device take turns Two opens of the same device (the Device panel and an agent's device_open) could start its stream at the same time. The hub rejects the overlapping grid start, so one open failed while the stream kept running. Opens now hold a per-host-and-device lock; other devices and hosts still open concurrently. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceService.test.ts | 41 +++++++++++++++++++- apps/server/src/device/DeviceService.ts | 13 +++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index a23916025e4e2..db18ab0a5ede1 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -1795,6 +1795,7 @@ describe("locating a device id across hosts", () => { const listingHost = ( host: { readonly id: string; readonly label: string }, ids: ReadonlyArray, + attach: DeviceHost.DeviceOperations["attach"] = () => Effect.void, ): DeviceHost.DeviceHost["Service"] => { const devices = ids.map((id) => ({ id, @@ -1810,7 +1811,7 @@ describe("locating a device id across hosts", () => { devices: { list: Effect.succeed({ devices, detail: undefined }), boot: (device) => Effect.succeed(device.id), - attach: () => Effect.void, + attach, shutdown: () => Effect.void, screenshot: () => Effect.succeed(new Uint8Array()), }, @@ -1865,6 +1866,44 @@ describe("locating a device id across hosts", () => { platform: deviceId.startsWith("Pixel") ? "android" : "ios", }); + // The hub fails a stream start that overlaps another one on the same device. + it.effect.each([ + { case: "one device take turns", other: "MAC-UDID", overlap: 1 }, + { case: "different devices run together", other: "MAC-UDID-2", overlap: 2 }, + ])("concurrent opens of $case", ({ other, overlap }) => + Effect.gen(function* () { + const attaching = yield* Queue.unbounded>(); + let active = 0; + let most = 0; + const host = listingHost(macMini, ["MAC-UDID", "MAC-UDID-2"], () => + Effect.gen(function* () { + most = Math.max(most, ++active); + const done = yield* Deferred.make(); + yield* Queue.offer(attaching, done); + yield* Deferred.await(done); + active--; + }), + ); + const service = yield* DeviceService.makeWithHosts(new Map([[macMini.id, host]])).pipe( + Effect.provide(NodeCrypto.layer), + ); + const opening = yield* Effect.forkChild( + Effect.all( + [ + open(service, macMini.id, "MAC-UDID"), + open(service, macMini.id, other, ThreadId.make("thread-other")), + ], + { concurrency: "unbounded" }, + ), + ); + yield* Deferred.succeed(yield* Queue.take(attaching), undefined); + yield* Deferred.succeed(yield* Queue.take(attaching), undefined); + yield* Fiber.join(opening); + expect(most).toBe(overlap); + expect((yield* service.state).sessions).toHaveLength(2); + }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), + ); + it.effect("finds a device id that exists only on another host", () => withTwoHosts((service) => Effect.gen(function* () { diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index a655e2e0cfed4..231bde0a69f0f 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -734,6 +734,18 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* } }); + // The hub fails stream setup that overlaps another on the same device, so its opens take turns. + const openLocks = new Map(); + const withOpenLock = (hostId: DeviceHostId, deviceId: DeviceId) => { + const key = `${hostId}\u0000${deviceId}`; + let lock = openLocks.get(key); + if (lock === undefined) { + lock = Semaphore.makeUnsafe(1); + openLocks.set(key, lock); + } + return lock.withPermit; + }; + const open: DeviceService["Service"]["open"] = Effect.fn("DeviceService.open")( function* (input, context) { const host = yield* resolveHost(input.hostId); @@ -841,6 +853,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ); return session; }, + (effect, input) => withOpenLock(input.hostId ?? LOCAL_DEVICE_HOST_ID, input.deviceId)(effect), ); const refreshAfterShutdown = (ready: DeviceReadiness) => From 93960dec61607a1d9e49221e348b4b76294416d7 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 31 Oct 2026 01:10:51 +0000 Subject: [PATCH 12/21] fix(devices): keep Android AVD opens concurrent --- apps/server/src/device/DeviceService.test.ts | 40 ++++++++++++-------- apps/server/src/device/DeviceService.ts | 7 +++- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index db18ab0a5ede1..03a82fca1bb12 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -399,21 +399,31 @@ describe("device setup consent", () => { }).pipe(Effect.scoped), ); - it.effect("boots a stopped Android AVD and uses its emulator serial without duplicating it", () => - Effect.gen(function* () { - const { service, requests } = yield* fixture(); - yield* service.configure({ enabled: true }); - const session = yield* service.open({ - threadId: ThreadId.make("thread-1"), - deviceId: "Pixel_API_35", - platform: "android", - }); - expect(session.deviceId).toBe("emulator-5554"); - expect(requests.filter((url) => url.endsWith("/boot"))).toHaveLength(1); - const state = yield* service.state; - expect(state.devices.map((device) => device.id)).toEqual(["emulator-5554"]); - expect(state.bootingDevices).toEqual([]); - }).pipe(Effect.scoped), + it.effect.each([1, 2])( + "opens a stopped Android AVD from %s threads using its emulator serial", + (count) => + Effect.gen(function* () { + const { service, requests } = yield* fixture(); + yield* service.configure({ enabled: true }); + const sessions = yield* Effect.all( + Array.from({ length: count }, (_, index) => + service.open({ + threadId: ThreadId.make(`thread-${index}`), + deviceId: "Pixel_API_35", + platform: "android", + }), + ), + { concurrency: "unbounded" }, + ); + expect(sessions.map((session) => session.deviceId)).toEqual( + Array(count).fill("emulator-5554"), + ); + expect(requests.filter((url) => url.endsWith("/boot"))).toHaveLength(count); + const state = yield* service.state; + expect(state.sessions).toHaveLength(count); + expect(state.devices.map((device) => device.id)).toEqual(["emulator-5554"]); + expect(state.bootingDevices).toEqual([]); + }).pipe(Effect.scoped), ); it.effect("blocks only emulator video until ffmpeg is usable, then allows retry", () => diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 231bde0a69f0f..58b08f4c0bc6b 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -734,7 +734,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* } }); - // The hub fails stream setup that overlaps another on the same device, so its opens take turns. + // serve-sim rejects overlapping iOS stream setup on the same device. const openLocks = new Map(); const withOpenLock = (hostId: DeviceHostId, deviceId: DeviceId) => { const key = `${hostId}\u0000${deviceId}`; @@ -853,7 +853,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ); return session; }, - (effect, input) => withOpenLock(input.hostId ?? LOCAL_DEVICE_HOST_ID, input.deviceId)(effect), + (effect, input) => + input.platform === "ios" + ? withOpenLock(input.hostId ?? LOCAL_DEVICE_HOST_ID, input.deviceId)(effect) + : effect, ); const refreshAfterShutdown = (ready: DeviceReadiness) => From 8e8470770a3b3c7c18933c2cbf7fe524d700c599 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 31 Oct 2026 01:30:59 +0000 Subject: [PATCH 13/21] fix(devices): reject mismatched platforms before opening --- apps/server/src/device/DeviceService.test.ts | 43 ++++++++++++++++---- apps/server/src/device/DeviceService.ts | 2 +- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 03a82fca1bb12..0d51df5557c94 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -403,18 +403,27 @@ describe("device setup consent", () => { "opens a stopped Android AVD from %s threads using its emulator serial", (count) => Effect.gen(function* () { - const { service, requests } = yield* fixture(); + const booting = yield* Queue.unbounded(); + const allowBoot = yield* Deferred.make(); + const { service, requests } = yield* fixture( + Queue.offer(booting, undefined).pipe(Effect.andThen(Deferred.await(allowBoot))), + ); yield* service.configure({ enabled: true }); - const sessions = yield* Effect.all( - Array.from({ length: count }, (_, index) => - service.open({ - threadId: ThreadId.make(`thread-${index}`), - deviceId: "Pixel_API_35", - platform: "android", - }), + const opening = yield* Effect.forkChild( + Effect.all( + Array.from({ length: count }, (_, index) => + service.open({ + threadId: ThreadId.make(`thread-${index}`), + deviceId: "Pixel_API_35", + platform: "android", + }), + ), + { concurrency: "unbounded" }, ), - { concurrency: "unbounded" }, ); + yield* Effect.forEach(Array.from({ length: count }), () => Queue.take(booting)); + yield* Deferred.succeed(allowBoot, undefined); + const sessions = yield* Fiber.join(opening); expect(sessions.map((session) => session.deviceId)).toEqual( Array(count).fill("emulator-5554"), ); @@ -1876,6 +1885,22 @@ describe("locating a device id across hosts", () => { platform: deviceId.startsWith("Pixel") ? "android" : "ios", }); + it.effect("rejects an open whose platform does not match the device", () => + withTwoHosts((service) => + Effect.gen(function* () { + const error = yield* service + .open({ threadId, hostId: macMini.id, deviceId: "MAC-UDID", platform: "android" }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "DeviceNotFoundError", + hostId: macMini.id, + deviceId: "MAC-UDID", + }); + expect((yield* service.state).sessions).toEqual([]); + }), + ), + ); + // The hub fails a stream start that overlaps another one on the same device. it.effect.each([ { case: "one device take turns", other: "MAC-UDID", overlap: 1 }, diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 58b08f4c0bc6b..d96ace23256ca 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -753,7 +753,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const ready = yield* readiness(host.id); let state = yield* refresh(ready); let device = findDevice(state, host.id, input.deviceId); - if (!device) { + if (!device || device.platform !== input.platform) { return yield* new DeviceNotFoundError({ hostId: host.id, deviceId: input.deviceId }); } // Hosts that route their own streams never encode emulator video with serve-emu's ffmpeg. From ec0f438804b7252d10e2ff73f67980f1a8a8292a Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 22:11:41 +0000 Subject: [PATCH 14/21] fix(devices): release idle iOS open locks --- apps/server/src/device/DeviceService.test.ts | 33 ++++++++++++++++++-- apps/server/src/device/DeviceService.ts | 22 ++++++------- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 0d51df5557c94..83619292e189e 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -1931,14 +1931,43 @@ describe("locating a device id across hosts", () => { { concurrency: "unbounded" }, ), ); - yield* Deferred.succeed(yield* Queue.take(attaching), undefined); - yield* Deferred.succeed(yield* Queue.take(attaching), undefined); + const first = yield* Queue.take(attaching); + if (overlap === 2) { + const second = yield* Queue.take(attaching); + yield* Deferred.succeed(first, undefined); + yield* Deferred.succeed(second, undefined); + } else { + yield* Deferred.succeed(first, undefined); + yield* Deferred.succeed(yield* Queue.take(attaching), undefined); + } yield* Fiber.join(opening); expect(most).toBe(overlap); expect((yield* service.state).sessions).toHaveLength(2); }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), ); + it.effect("an interrupted open releases its device lock", () => + Effect.gen(function* () { + const attached = yield* Deferred.make(); + let calls = 0; + const host = listingHost(macMini, ["MAC-UDID"], () => + ++calls === 1 + ? Deferred.succeed(attached, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + ); + const service = yield* DeviceService.makeWithHosts(new Map([[macMini.id, host]])).pipe( + Effect.provide(NodeCrypto.layer), + ); + const opening = yield* Effect.forkChild(open(service, macMini.id, "MAC-UDID")); + yield* Deferred.await(attached); + yield* Fiber.interrupt(opening); + const session = yield* open(service, macMini.id, "MAC-UDID"); + expect(session.deviceId).toBe("MAC-UDID"); + expect(calls).toBe(2); + expect((yield* service.state).sessions).toEqual([session]); + }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), + ); + it.effect("finds a device id that exists only on another host", () => withTwoHosts((service) => Effect.gen(function* () { diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index d96ace23256ca..b43af5640c689 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -36,6 +36,7 @@ import { SshCommandError } from "@t3tools/ssh/errors"; import * as FileSystem from "effect/FileSystem"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Path from "effect/Path"; +import * as RcMap from "effect/RcMap"; import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts"; import { agentDeviceCloseOutcome, @@ -735,16 +736,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }); // serve-sim rejects overlapping iOS stream setup on the same device. - const openLocks = new Map(); - const withOpenLock = (hostId: DeviceHostId, deviceId: DeviceId) => { - const key = `${hostId}\u0000${deviceId}`; - let lock = openLocks.get(key); - if (lock === undefined) { - lock = Semaphore.makeUnsafe(1); - openLocks.set(key, lock); - } - return lock.withPermit; - }; + const openLocks = yield* RcMap.make({ + lookup: (_key: string) => Semaphore.make(1), + }); const open: DeviceService["Service"]["open"] = Effect.fn("DeviceService.open")( function* (input, context) { @@ -855,7 +849,13 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }, (effect, input) => input.platform === "ios" - ? withOpenLock(input.hostId ?? LOCAL_DEVICE_HOST_ID, input.deviceId)(effect) + ? RcMap.get( + openLocks, + `${input.hostId ?? LOCAL_DEVICE_HOST_ID}\u0000${input.deviceId}`, + ).pipe( + Effect.flatMap((lock) => lock.withPermit(effect)), + Effect.scoped, + ) : effect, ); From 2cf242706fe836469a3f0e1b04d5d8a20625755d Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:23:03 +0000 Subject: [PATCH 15/21] fix(devices): limit this fix to device discovery Drop the iOS open lock, its platform guard and the Android concurrent-open and boot-timeout classification changes. Keep discovery retention, the 40-second listing fallback, setup guidance and the warning UI, with two regression cases. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceHubOperations.ts | 5 +- apps/server/src/device/DeviceService.test.ts | 209 ++++-------------- apps/server/src/device/DeviceService.ts | 18 +- .../src/components/device/DeviceSetup.test.ts | 11 - 4 files changed, 52 insertions(+), 191 deletions(-) diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index eb27cde17eaed..f6f85bd086ed3 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -16,7 +16,8 @@ const BOOT_TIMEOUT = Duration.minutes(3); const SCREENSHOT_TIMEOUT = Duration.seconds(20); // Let the hub's 30 s simctl timeout report its cause first. const LIST_TIMEOUT_SECONDS = 40; -// simctl could not list simulators at all, so the hub's empty iOS list says nothing. +// simctl could not list simulators, so the empty iOS list says nothing. expo-device-hub 0.19.1 +// errors carry only text (their id is a hash), so this matches its listing messages. const SIMULATOR_LIST_FAILED = /^\[apple-utils\] (?:Failed to (?:run `xcrun simctl list devices|parse JSON output:|list devices:)|`xcrun simctl list devices --json` did not respond within )/; @@ -208,7 +209,7 @@ export const makeDeviceHubOperations = (options: { diagnostics, ) ? "disk_space" - : /timed? out|timeout|did not respond within/i.test(diagnostics) + : /timed? out|timeout/i.test(diagnostics) ? "timeout" : "launch_failed", ...(diagnostics ? { diagnostics: boundDiagnostics(diagnostics) } : {}), diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 83619292e189e..c5e68a6a0005a 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -167,8 +167,9 @@ const fixture = Effect.fn("fixture")(function* ( return HttpClientResponse.fromWeb(request, Response.json({ ok: true })); } if (listing.hangs) { - yield* Deferred.succeed(listing.requested, undefined); - return yield* Effect.never; + return yield* Deferred.succeed(listing.requested, undefined).pipe( + Effect.andThen(Effect.never), + ); } return HttpClientResponse.fromWeb( request, @@ -331,8 +332,8 @@ const fixture = Effect.fn("fixture")(function* ( encoderResult, discoveryErrors, androidAvailable, - endDevice, listing, + endDevice, }; }); @@ -399,40 +400,21 @@ describe("device setup consent", () => { }).pipe(Effect.scoped), ); - it.effect.each([1, 2])( - "opens a stopped Android AVD from %s threads using its emulator serial", - (count) => - Effect.gen(function* () { - const booting = yield* Queue.unbounded(); - const allowBoot = yield* Deferred.make(); - const { service, requests } = yield* fixture( - Queue.offer(booting, undefined).pipe(Effect.andThen(Deferred.await(allowBoot))), - ); - yield* service.configure({ enabled: true }); - const opening = yield* Effect.forkChild( - Effect.all( - Array.from({ length: count }, (_, index) => - service.open({ - threadId: ThreadId.make(`thread-${index}`), - deviceId: "Pixel_API_35", - platform: "android", - }), - ), - { concurrency: "unbounded" }, - ), - ); - yield* Effect.forEach(Array.from({ length: count }), () => Queue.take(booting)); - yield* Deferred.succeed(allowBoot, undefined); - const sessions = yield* Fiber.join(opening); - expect(sessions.map((session) => session.deviceId)).toEqual( - Array(count).fill("emulator-5554"), - ); - expect(requests.filter((url) => url.endsWith("/boot"))).toHaveLength(count); - const state = yield* service.state; - expect(state.sessions).toHaveLength(count); - expect(state.devices.map((device) => device.id)).toEqual(["emulator-5554"]); - expect(state.bootingDevices).toEqual([]); - }).pipe(Effect.scoped), + it.effect("boots a stopped Android AVD and uses its emulator serial without duplicating it", () => + Effect.gen(function* () { + const { service, requests } = yield* fixture(); + yield* service.configure({ enabled: true }); + const session = yield* service.open({ + threadId: ThreadId.make("thread-1"), + deviceId: "Pixel_API_35", + platform: "android", + }); + expect(session.deviceId).toBe("emulator-5554"); + expect(requests.filter((url) => url.endsWith("/boot"))).toHaveLength(1); + const state = yield* service.state; + expect(state.devices.map((device) => device.id)).toEqual(["emulator-5554"]); + expect(state.bootingDevices).toEqual([]); + }).pipe(Effect.scoped), ); it.effect("blocks only emulator video until ffmpeg is usable, then allows retry", () => @@ -715,48 +697,43 @@ it.effect("keeps the cause from the hub's error in discovery warnings, without t }).pipe(Effect.scoped), ); -const simulator = { - id: "iphone", - name: "iPhone", - platform: "ios", - version: "iOS 26", - booted: true, - physical: false, -} as const; -const androidPhone = { ...simulator, id: "pixel", name: "Pixel", platform: "android" } as const; - -it.effect.each([ - "[apple-utils] Failed to run `xcrun simctl list devices --json`:", - "[apple-utils] `xcrun simctl list devices --json` did not respond within 30 s", -])("keeps the last simulators after %s while Android still refreshes", (message) => - Effect.gen(function* () { - const { service, devices, discoveryErrors } = yield* fixture(); - devices.push(simulator, androidPhone); - yield* service.configure({ enabled: true }); - devices.length = 0; - yield* Ref.set(discoveryErrors, [{ message }]); - const failed = yield* service.list; - expect(failed.devices.map((device) => device.id)).toEqual(["iphone", "Pixel_API_35"]); - expect(failed.hostStatuses.local).toEqual({ status: "ready", detail: message }); - - yield* Ref.set(discoveryErrors, []); - const emptied = yield* service.list; - expect(emptied.devices.map((device) => device.id)).toEqual(["Pixel_API_35"]); - expect(emptied.hostStatuses.local).toEqual({ status: "ready" }); - }).pipe(Effect.scoped), +it.effect( + "keeps the last simulators while simctl cannot list them, and drops them once it can", + () => + Effect.gen(function* () { + const { service, devices, discoveryErrors } = yield* fixture(); + const simulator = { + id: DeviceId.make("iphone"), + name: "iPhone", + platform: "ios", + version: "iOS 26", + booted: true, + physical: false, + } as const; + devices.push(simulator, { ...simulator, id: DeviceId.make("pixel"), platform: "android" }); + yield* service.configure({ enabled: true }); + devices.length = 0; + yield* Ref.set(discoveryErrors, [{ message: simctlMessage }]); + const failed = yield* service.list; + expect(failed.devices.map((device) => device.id)).toEqual(["iphone", "Pixel_API_35"]); + expect(failed.hostStatuses.local).toEqual({ status: "ready", detail: simctlMessage }); + yield* Ref.set(discoveryErrors, []); + const recovered = yield* service.list; + expect(recovered.devices.map((device) => device.id)).toEqual(["Pixel_API_35"]); + expect(recovered.hostStatuses.local).toEqual({ status: "ready" }); + }).pipe(Effect.scoped), ); it.effect("keeps every device when the hub does not answer the listing within 40 seconds", () => Effect.gen(function* () { - const { service, devices, listing } = yield* fixture(); - devices.push(simulator); - const before = yield* service.configure({ enabled: true }); + const { service, listing } = yield* fixture(); + yield* service.configure({ enabled: true }); listing.hangs = true; const listed = yield* service.list.pipe(Effect.forkScoped); yield* Deferred.await(listing.requested); yield* TestClock.adjust("40 seconds"); const timedOut = yield* Fiber.join(listed); - expect(timedOut.devices).toEqual(before.devices); + expect(timedOut.devices.map((device) => device.id)).toEqual(["Pixel_API_35"]); expect(timedOut.hostStatuses.local).toEqual({ status: "ready", detail: "The device hub did not answer within 40 seconds.", @@ -775,12 +752,6 @@ it.effect.each([ reason: "timeout", message: "did not become ready in time", }, - { - diagnostic: - "[apple-utils] `xcrun simctl boot iphone` did not respond within 120 s; CoreSimulatorService may need restarting", - reason: "timeout", - message: "did not become ready in time", - }, { diagnostic: "The emulator process exited with code 1.\nTry manually: emulator -avd Pixel_API_35", @@ -1814,7 +1785,6 @@ describe("locating a device id across hosts", () => { const listingHost = ( host: { readonly id: string; readonly label: string }, ids: ReadonlyArray, - attach: DeviceHost.DeviceOperations["attach"] = () => Effect.void, ): DeviceHost.DeviceHost["Service"] => { const devices = ids.map((id) => ({ id, @@ -1830,7 +1800,7 @@ describe("locating a device id across hosts", () => { devices: { list: Effect.succeed({ devices, detail: undefined }), boot: (device) => Effect.succeed(device.id), - attach, + attach: () => Effect.void, shutdown: () => Effect.void, screenshot: () => Effect.succeed(new Uint8Array()), }, @@ -1885,89 +1855,6 @@ describe("locating a device id across hosts", () => { platform: deviceId.startsWith("Pixel") ? "android" : "ios", }); - it.effect("rejects an open whose platform does not match the device", () => - withTwoHosts((service) => - Effect.gen(function* () { - const error = yield* service - .open({ threadId, hostId: macMini.id, deviceId: "MAC-UDID", platform: "android" }) - .pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "DeviceNotFoundError", - hostId: macMini.id, - deviceId: "MAC-UDID", - }); - expect((yield* service.state).sessions).toEqual([]); - }), - ), - ); - - // The hub fails a stream start that overlaps another one on the same device. - it.effect.each([ - { case: "one device take turns", other: "MAC-UDID", overlap: 1 }, - { case: "different devices run together", other: "MAC-UDID-2", overlap: 2 }, - ])("concurrent opens of $case", ({ other, overlap }) => - Effect.gen(function* () { - const attaching = yield* Queue.unbounded>(); - let active = 0; - let most = 0; - const host = listingHost(macMini, ["MAC-UDID", "MAC-UDID-2"], () => - Effect.gen(function* () { - most = Math.max(most, ++active); - const done = yield* Deferred.make(); - yield* Queue.offer(attaching, done); - yield* Deferred.await(done); - active--; - }), - ); - const service = yield* DeviceService.makeWithHosts(new Map([[macMini.id, host]])).pipe( - Effect.provide(NodeCrypto.layer), - ); - const opening = yield* Effect.forkChild( - Effect.all( - [ - open(service, macMini.id, "MAC-UDID"), - open(service, macMini.id, other, ThreadId.make("thread-other")), - ], - { concurrency: "unbounded" }, - ), - ); - const first = yield* Queue.take(attaching); - if (overlap === 2) { - const second = yield* Queue.take(attaching); - yield* Deferred.succeed(first, undefined); - yield* Deferred.succeed(second, undefined); - } else { - yield* Deferred.succeed(first, undefined); - yield* Deferred.succeed(yield* Queue.take(attaching), undefined); - } - yield* Fiber.join(opening); - expect(most).toBe(overlap); - expect((yield* service.state).sessions).toHaveLength(2); - }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), - ); - - it.effect("an interrupted open releases its device lock", () => - Effect.gen(function* () { - const attached = yield* Deferred.make(); - let calls = 0; - const host = listingHost(macMini, ["MAC-UDID"], () => - ++calls === 1 - ? Deferred.succeed(attached, undefined).pipe(Effect.andThen(Effect.never)) - : Effect.void, - ); - const service = yield* DeviceService.makeWithHosts(new Map([[macMini.id, host]])).pipe( - Effect.provide(NodeCrypto.layer), - ); - const opening = yield* Effect.forkChild(open(service, macMini.id, "MAC-UDID")); - yield* Deferred.await(attached); - yield* Fiber.interrupt(opening); - const session = yield* open(service, macMini.id, "MAC-UDID"); - expect(session.deviceId).toBe("MAC-UDID"); - expect(calls).toBe(2); - expect((yield* service.state).sessions).toEqual([session]); - }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), - ); - it.effect("finds a device id that exists only on another host", () => withTwoHosts((service) => Effect.gen(function* () { diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index b43af5640c689..a655e2e0cfed4 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -36,7 +36,6 @@ import { SshCommandError } from "@t3tools/ssh/errors"; import * as FileSystem from "effect/FileSystem"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Path from "effect/Path"; -import * as RcMap from "effect/RcMap"; import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts"; import { agentDeviceCloseOutcome, @@ -735,11 +734,6 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* } }); - // serve-sim rejects overlapping iOS stream setup on the same device. - const openLocks = yield* RcMap.make({ - lookup: (_key: string) => Semaphore.make(1), - }); - const open: DeviceService["Service"]["open"] = Effect.fn("DeviceService.open")( function* (input, context) { const host = yield* resolveHost(input.hostId); @@ -747,7 +741,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const ready = yield* readiness(host.id); let state = yield* refresh(ready); let device = findDevice(state, host.id, input.deviceId); - if (!device || device.platform !== input.platform) { + if (!device) { return yield* new DeviceNotFoundError({ hostId: host.id, deviceId: input.deviceId }); } // Hosts that route their own streams never encode emulator video with serve-emu's ffmpeg. @@ -847,16 +841,6 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ); return session; }, - (effect, input) => - input.platform === "ios" - ? RcMap.get( - openLocks, - `${input.hostId ?? LOCAL_DEVICE_HOST_ID}\u0000${input.deviceId}`, - ).pipe( - Effect.flatMap((lock) => lock.withPermit(effect)), - Effect.scoped, - ) - : effect, ); const refreshAfterShutdown = (ready: DeviceReadiness) => diff --git a/apps/web/src/components/device/DeviceSetup.test.ts b/apps/web/src/components/device/DeviceSetup.test.ts index a275ab299354c..322354763b8d0 100644 --- a/apps/web/src/components/device/DeviceSetup.test.ts +++ b/apps/web/src/components/device/DeviceSetup.test.ts @@ -43,17 +43,6 @@ describe("device setup guidance", () => { ).toContain("Device Manager"); }); - it.each([ - ["[apple-utils] Failed to run `xcrun simctl list devices --json`:", true, false], - ["[android-utils] Failed to run `avdmanager list avd`:", false, true], - ["The device hub did not answer within 40 seconds.", true, true], - ])("blames discovery, not missing tools, after %s", (detail, ios, android) => { - const state = deviceState({ hostStatuses: { local: { status: "ready", detail } } }); - const blamesDiscovery = (platform: "ios" | "android") => - platformSetupStatus(state, platform).message.includes("discovery reported a problem"); - expect([blamesDiscovery("ios"), blamesDiscovery("android")]).toEqual([ios, android]); - }); - it("counts a remote device host when this machine has no simulator tooling", () => { const state = deviceState({ hostStatus: "idle", From 2b340cceb0e9b31e0b10c762289a0af45c5e4236 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:32:23 +0000 Subject: [PATCH 16/21] test(devices): retain iOS simulators in hub timeout coverage --- apps/server/src/device/DeviceService.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index c5e68a6a0005a..461000e0ae893 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -726,14 +726,22 @@ it.effect( it.effect("keeps every device when the hub does not answer the listing within 40 seconds", () => Effect.gen(function* () { - const { service, listing } = yield* fixture(); + const { service, devices, listing } = yield* fixture(); + devices.push({ + id: DeviceId.make("iphone"), + name: "iPhone", + platform: "ios", + version: "iOS 26", + booted: true, + physical: false, + }); yield* service.configure({ enabled: true }); listing.hangs = true; const listed = yield* service.list.pipe(Effect.forkScoped); yield* Deferred.await(listing.requested); yield* TestClock.adjust("40 seconds"); const timedOut = yield* Fiber.join(listed); - expect(timedOut.devices.map((device) => device.id)).toEqual(["Pixel_API_35"]); + expect(timedOut.devices.map((device) => device.id)).toEqual(["iphone", "Pixel_API_35"]); expect(timedOut.hostStatuses.local).toEqual({ status: "ready", detail: "The device hub did not answer within 40 seconds.", From 883109311f536d87a840ca04efe5b4c38001286d Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:04:36 +0000 Subject: [PATCH 17/21] fix(devices): send incomplete discovery platforms to clients --- apps/server/src/device/DeviceService.test.ts | 12 ++++-- apps/server/src/device/DeviceService.ts | 1 + .../web/src/components/device/DevicePanel.tsx | 20 ++++++++-- .../src/components/device/DeviceSetup.test.ts | 40 +++++++++++++++---- .../web/src/components/device/DeviceSetup.tsx | 16 ++------ packages/contracts/src/device.ts | 1 + 6 files changed, 64 insertions(+), 26 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 461000e0ae893..8293da0b7c390 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -716,11 +716,15 @@ it.effect( yield* Ref.set(discoveryErrors, [{ message: simctlMessage }]); const failed = yield* service.list; expect(failed.devices.map((device) => device.id)).toEqual(["iphone", "Pixel_API_35"]); - expect(failed.hostStatuses.local).toEqual({ status: "ready", detail: simctlMessage }); + expect(failed.hostStatuses.local).toEqual({ + status: "ready", + detail: simctlMessage, + incompletePlatforms: ["ios"], + }); yield* Ref.set(discoveryErrors, []); const recovered = yield* service.list; expect(recovered.devices.map((device) => device.id)).toEqual(["Pixel_API_35"]); - expect(recovered.hostStatuses.local).toEqual({ status: "ready" }); + expect(recovered.hostStatuses.local).toEqual({ status: "ready", incompletePlatforms: [] }); }).pipe(Effect.scoped), ); @@ -745,6 +749,7 @@ it.effect("keeps every device when the hub does not answer the listing within 40 expect(timedOut.hostStatuses.local).toEqual({ status: "ready", detail: "The device hub did not answer within 40 seconds.", + incompletePlatforms: ["ios", "android"], }); }).pipe(Effect.scoped), ); @@ -1273,6 +1278,7 @@ it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( expect(incomplete.hostStatuses[hostId]).toEqual({ status: "ready", androidDiscoveryIncomplete: true, + incompletePlatforms: [], detail: "Could not list Android virtual devices (emulator -list-avds, exit code 1). Discovered devices remain available.", }); @@ -1294,7 +1300,7 @@ it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( physical: false, }, ]); - expect(recovered.hostStatuses[hostId]).toEqual({ status: "ready" }); + expect(recovered.hostStatuses[hostId]).toEqual({ status: "ready", incompletePlatforms: [] }); if (hostId === LOCAL_DEVICE_HOST_ID) expect(recovered.hostStatusDetail).toBeUndefined(); }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), ); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index a655e2e0cfed4..168725634e486 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -579,6 +579,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ...state.hostStatuses, [ready.hostId]: { status: "ready", + incompletePlatforms, ...(detail ? { detail } : {}), ...(androidDiscoveryIncomplete ? { androidDiscoveryIncomplete } : {}), }, diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 8ed182fdbb358..ab6d9211ccdaf 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -179,7 +179,14 @@ export function DevicePanel(props: { 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 }] + ? [ + { + hostId: host.id, + label: host.label, + detail, + incompletePlatforms: status?.incompletePlatforms, + }, + ] : []; }); const unavailablePlatforms = state.hosts.flatMap((host) => @@ -218,6 +225,7 @@ export function DevicePanel(props: { key={`${environmentId}\u0000${diagnostic.hostId}\u0000${diagnostic.detail}`} label={diagnostic.label} detail={diagnostic.detail} + incomplete={!!diagnostic.incompletePlatforms?.length} /> ))}
@@ -408,7 +416,11 @@ function DeviceNotice(props: { ); } -function DeviceDiscoveryWarning(props: { readonly label: string; readonly detail: string }) { +function DeviceDiscoveryWarning(props: { + readonly label: string; + readonly detail: string; + readonly incomplete: boolean; +}) { const [open, setOpen] = useState(false); return ( @@ -418,7 +430,9 @@ function DeviceDiscoveryWarning(props: { readonly label: string; readonly detail > - Some devices on {props.label} could not be listed. + {props.incomplete + ? `Some devices on ${props.label} could not be listed.` + : `Device discovery reported a warning on ${props.label}.`} Details diff --git a/apps/web/src/components/device/DeviceSetup.test.ts b/apps/web/src/components/device/DeviceSetup.test.ts index 322354763b8d0..b5e783525ef5b 100644 --- a/apps/web/src/components/device/DeviceSetup.test.ts +++ b/apps/web/src/components/device/DeviceSetup.test.ts @@ -29,18 +29,44 @@ const deviceState = (overrides: Partial = {}): DeviceService }); describe("device setup guidance", () => { - it("waits for complete Android discovery before advising creation of an AVD", () => { + it.each([ + { platform: "android", androidDiscoveryIncomplete: true, incompletePlatforms: [] }, + { platform: "ios", androidDiscoveryIncomplete: false, incompletePlatforms: ["ios"] }, + { platform: "android", androidDiscoveryIncomplete: false, incompletePlatforms: ["android"] }, + ] as const)("waits for complete $platform discovery before advising device setup", (input) => { const state = deviceState({ hostStatus: "idle", hosts: [{ ...deviceState().hosts[0]!, id: "remote", kind: "ssh" }], - hostStatuses: { remote: { status: "ready", androidDiscoveryIncomplete: true } }, + hostStatuses: { + remote: { + status: "ready", + detail: "Host discovery did not finish.", + incompletePlatforms: input.incompletePlatforms, + ...(input.androidDiscoveryIncomplete ? { androidDiscoveryIncomplete: true } : {}), + }, + }, }); - expect(platformSetupStatus(state, "android").message).toContain("may be incomplete"); - expect(platformSetupStatus(state, "ios").message).toContain("Xcode Settings"); + expect(platformSetupStatus(state, input.platform).message).toContain( + input.androidDiscoveryIncomplete ? "may be incomplete" : "discovery reported a problem", + ); + const otherPlatform = input.platform === "ios" ? "android" : "ios"; + expect(platformSetupStatus(state, otherPlatform).message).toContain( + otherPlatform === "ios" ? "Xcode Settings" : "Device Manager", + ); expect( - platformSetupStatus({ ...state, hostStatuses: { remote: { status: "ready" } } }, "android") - .message, - ).toContain("Device Manager"); + platformSetupStatus( + { + ...state, + hostStatuses: { + remote: { + status: "ready", + detail: "[apple-utils] A warning unrelated to listing devices.", + }, + }, + }, + input.platform, + ).message, + ).toContain(input.platform === "ios" ? "Xcode Settings" : "Device Manager"); }); it("counts a remote device host when this machine has no simulator tooling", () => { diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx index 724c262483370..e4f8831fe19c7 100644 --- a/apps/web/src/components/device/DeviceSetup.tsx +++ b/apps/web/src/components/device/DeviceSetup.tsx @@ -103,20 +103,10 @@ export function platformSetupStatus(state: DeviceServiceState, platform: DeviceP (host.kind === "local" && state.hostStatus === "ready"), ); if (readyHosts.length > 0 && !hasDevices) { - const diagnosticPrefix = platform === "ios" ? "[apple-utils] " : "[android-utils] "; if ( - readyHosts.some((host) => { - const detail = - state.hostStatuses[host.id]?.detail ?? - (host.kind === "local" ? state.hostStatusDetail : undefined); - return detail - ?.split("\n") - .some( - (line) => - line.startsWith(diagnosticPrefix) || - line.startsWith("The device hub did not answer within "), - ); - }) + readyHosts.some((host) => + state.hostStatuses[host.id]?.incompletePlatforms?.includes(platform), + ) ) { return { ready: false, diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 24b72dbe26d7a..f5c911e02109c 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -252,6 +252,7 @@ export const DeviceHostStatuses = Schema.Record( status: DeviceHostStatus, detail: Schema.optional(Schema.String), androidDiscoveryIncomplete: Schema.optional(Schema.Boolean), + incompletePlatforms: Schema.optional(Schema.Array(DevicePlatform)), }), ); From d58289aa54cb7c0aeec58374ceb900c04eb7efea Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:03:49 +0000 Subject: [PATCH 18/21] refactor(devices): trim the discovery retention fix Only report incompletePlatforms when some are incomplete, so existing host status expectations stay as on main, and fold the setup guidance test into the existing case. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceHubOperations.ts | 3 +- apps/server/src/device/DeviceService.test.ts | 5 +-- apps/server/src/device/DeviceService.ts | 2 +- .../web/src/components/device/DevicePanel.tsx | 4 +- .../src/components/device/DeviceSetup.test.ts | 42 +++++-------------- 5 files changed, 17 insertions(+), 39 deletions(-) diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index f6f85bd086ed3..687b72c995804 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -16,8 +16,7 @@ const BOOT_TIMEOUT = Duration.minutes(3); const SCREENSHOT_TIMEOUT = Duration.seconds(20); // Let the hub's 30 s simctl timeout report its cause first. const LIST_TIMEOUT_SECONDS = 40; -// simctl could not list simulators, so the empty iOS list says nothing. expo-device-hub 0.19.1 -// errors carry only text (their id is a hash), so this matches its listing messages. +// expo-device-hub 0.19.1 reports a failed simulator listing only as text; its error ids are hashes. const SIMULATOR_LIST_FAILED = /^\[apple-utils\] (?:Failed to (?:run `xcrun simctl list devices|parse JSON output:|list devices:)|`xcrun simctl list devices --json` did not respond within )/; diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 8293da0b7c390..f75889269f69d 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -724,7 +724,7 @@ it.effect( yield* Ref.set(discoveryErrors, []); const recovered = yield* service.list; expect(recovered.devices.map((device) => device.id)).toEqual(["Pixel_API_35"]); - expect(recovered.hostStatuses.local).toEqual({ status: "ready", incompletePlatforms: [] }); + expect(recovered.hostStatuses.local).toEqual({ status: "ready" }); }).pipe(Effect.scoped), ); @@ -1278,7 +1278,6 @@ it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( expect(incomplete.hostStatuses[hostId]).toEqual({ status: "ready", androidDiscoveryIncomplete: true, - incompletePlatforms: [], detail: "Could not list Android virtual devices (emulator -list-avds, exit code 1). Discovered devices remain available.", }); @@ -1300,7 +1299,7 @@ it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( physical: false, }, ]); - expect(recovered.hostStatuses[hostId]).toEqual({ status: "ready", incompletePlatforms: [] }); + expect(recovered.hostStatuses[hostId]).toEqual({ status: "ready" }); if (hostId === LOCAL_DEVICE_HOST_ID) expect(recovered.hostStatusDetail).toBeUndefined(); }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), ); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 168725634e486..c584efa511094 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -579,9 +579,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ...state.hostStatuses, [ready.hostId]: { status: "ready", - incompletePlatforms, ...(detail ? { detail } : {}), ...(androidDiscoveryIncomplete ? { androidDiscoveryIncomplete } : {}), + ...(incompletePlatforms.length > 0 ? { incompletePlatforms } : {}), }, }, })); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index ab6d9211ccdaf..c02356d60d1b7 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -184,7 +184,7 @@ export function DevicePanel(props: { hostId: host.id, label: host.label, detail, - incompletePlatforms: status?.incompletePlatforms, + incomplete: (status?.incompletePlatforms?.length ?? 0) > 0, }, ] : []; @@ -225,7 +225,7 @@ export function DevicePanel(props: { key={`${environmentId}\u0000${diagnostic.hostId}\u0000${diagnostic.detail}`} label={diagnostic.label} detail={diagnostic.detail} - incomplete={!!diagnostic.incompletePlatforms?.length} + incomplete={diagnostic.incomplete} /> ))} diff --git a/apps/web/src/components/device/DeviceSetup.test.ts b/apps/web/src/components/device/DeviceSetup.test.ts index b5e783525ef5b..f0158641e2a19 100644 --- a/apps/web/src/components/device/DeviceSetup.test.ts +++ b/apps/web/src/components/device/DeviceSetup.test.ts @@ -29,44 +29,24 @@ const deviceState = (overrides: Partial = {}): DeviceService }); describe("device setup guidance", () => { - it.each([ - { platform: "android", androidDiscoveryIncomplete: true, incompletePlatforms: [] }, - { platform: "ios", androidDiscoveryIncomplete: false, incompletePlatforms: ["ios"] }, - { platform: "android", androidDiscoveryIncomplete: false, incompletePlatforms: ["android"] }, - ] as const)("waits for complete $platform discovery before advising device setup", (input) => { + it("waits for complete discovery before advising device setup", () => { const state = deviceState({ hostStatus: "idle", hosts: [{ ...deviceState().hosts[0]!, id: "remote", kind: "ssh" }], - hostStatuses: { - remote: { - status: "ready", - detail: "Host discovery did not finish.", - incompletePlatforms: input.incompletePlatforms, - ...(input.androidDiscoveryIncomplete ? { androidDiscoveryIncomplete: true } : {}), - }, - }, + hostStatuses: { remote: { status: "ready", androidDiscoveryIncomplete: true } }, }); - expect(platformSetupStatus(state, input.platform).message).toContain( - input.androidDiscoveryIncomplete ? "may be incomplete" : "discovery reported a problem", - ); - const otherPlatform = input.platform === "ios" ? "android" : "ios"; - expect(platformSetupStatus(state, otherPlatform).message).toContain( - otherPlatform === "ios" ? "Xcode Settings" : "Device Manager", - ); + expect(platformSetupStatus(state, "android").message).toContain("may be incomplete"); + expect(platformSetupStatus(state, "ios").message).toContain("Xcode Settings"); + expect( + platformSetupStatus({ ...state, hostStatuses: { remote: { status: "ready" } } }, "android") + .message, + ).toContain("Device Manager"); expect( platformSetupStatus( - { - ...state, - hostStatuses: { - remote: { - status: "ready", - detail: "[apple-utils] A warning unrelated to listing devices.", - }, - }, - }, - input.platform, + { ...state, hostStatuses: { remote: { status: "ready", incompletePlatforms: ["ios"] } } }, + "ios", ).message, - ).toContain(input.platform === "ios" ? "Xcode Settings" : "Device Manager"); + ).toContain("discovery reported a problem"); }); it("counts a remote device host when this machine has no simulator tooling", () => { From 62e719063d5d9fe4d0411dea52b2b8a5bf115a6e Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:47:38 +0000 Subject: [PATCH 19/21] fix(web): device discovery warning can be dismissed and keeps Details open Co-Authored-By: Claude Opus 5.5 --- .../device/DevicePanel.browser.test.tsx | 28 +++++++++++ .../web/src/components/device/DevicePanel.tsx | 50 +++++++++++-------- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/device/DevicePanel.browser.test.tsx b/apps/web/src/components/device/DevicePanel.browser.test.tsx index 8e8e0d27eb966..d3d1f07cd7d90 100644 --- a/apps/web/src/components/device/DevicePanel.browser.test.tsx +++ b/apps/web/src/components/device/DevicePanel.browser.test.tsx @@ -213,3 +213,31 @@ describe("DevicePanel host probe", () => { ]); }); }); + +describe("DevicePanel discovery warning", () => { + it("keeps Details open across causes and a dismissal only for the cause it hid", async () => { + setup(); + const withCause = (detail: string): DeviceServiceState => ({ + ...deviceState, + hostStatuses: { local: { status: "ready", detail, incompletePlatforms: ["ios"] } }, + }); + const warning = "Some devices on This Mac could not be listed."; + currentState = withCause("first cause"); + await using screen = await render(devicePanel(threadA), { container }); + + await screen.getByRole("button", { name: "Details for This Mac" }).click(); + await expect.element(screen.getByText("first cause")).toBeVisible(); + + currentState = withCause("second cause"); + await screen.rerender(devicePanel(threadA)); + await expect.element(screen.getByText("second cause")).toBeVisible(); + + await screen.getByRole("button", { name: "Dismiss device warning for This Mac" }).click(); + await screen.rerender(devicePanel(threadA)); + await expect.element(screen.getByText(warning)).not.toBeInTheDocument(); + + currentState = withCause("third cause"); + await screen.rerender(devicePanel(threadA)); + await expect.element(screen.getByText(warning)).toBeVisible(); + }); +}); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index c02356d60d1b7..e5405e9bebb59 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -222,7 +222,7 @@ export function DevicePanel(props: {
@@ -382,20 +380,11 @@ export function DevicePanel(props: { ); } -function DeviceNotice(props: { - readonly tone: "warning" | "error"; - readonly onDismiss: () => void; - readonly children: ReactNode; -}) { +function DeviceNotice(props: { readonly onDismiss: () => void; readonly children: ReactNode }) { return (
@@ -422,11 +411,13 @@ function DeviceDiscoveryWarning(props: { readonly incomplete: boolean; }) { const [open, setOpen] = useState(false); + const [dismissedDetail, setDismissedDetail] = useState(null); + if (dismissedDetail === props.detail) return null; return (
@@ -434,12 +425,31 @@ function DeviceDiscoveryWarning(props: { ? `Some devices on ${props.label} could not be listed.` : `Device discovery reported a warning on ${props.label}.`} - Details + } + > + Details + +
- setOpen(false)}> +
{props.detail} - +
); From d8df073e28821e67a1963072b2ba125fb6236a56 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:15:05 +0000 Subject: [PATCH 20/21] fix(devices): incomplete discovery no longer opens a stale AVD or advises creating one Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceService.test.ts | 23 +++++++++++++++++++ apps/server/src/device/DeviceService.ts | 7 +++++- .../src/components/device/DeviceSetup.test.ts | 12 +++++++++- .../web/src/components/device/DeviceSetup.tsx | 4 +++- .../settings/DeviceSourcesSettings.tsx | 11 +++++---- 5 files changed, 50 insertions(+), 7 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index f75889269f69d..77605e3e358ac 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -754,6 +754,29 @@ it.effect("keeps every device when the hub does not answer the listing within 40 }).pipe(Effect.scoped), ); +it.effect("does not open the pre-boot AVD when the post-boot listing times out", () => + Effect.gen(function* () { + const hang = { listing: () => {} }; + const { service, listing } = yield* fixture(Effect.sync(() => hang.listing())); + hang.listing = () => { + listing.hangs = true; + }; + yield* service.configure({ enabled: true }); + const opening = yield* service + .open({ + threadId: ThreadId.make("boot-timeout"), + deviceId: "Pixel_API_35", + platform: "android", + }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Deferred.await(listing.requested); + yield* TestClock.adjust("40 seconds"); + const error = yield* Fiber.join(opening); + expect(error._tag).toBe("DeviceNotFoundError"); + expect((yield* service.state).sessions).toEqual([]); + }).pipe(Effect.scoped), +); + it.effect.each([ { diagnostic: "Insufficient disk space at /private/user/path", diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index c584efa511094..8e671b8b744f2 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -795,7 +795,12 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ), ); state = yield* refresh(ready); - device = findDevice(state, host.id, bootedId) ?? findDevice(state, host.id, device.id); + // Retained entries predate the boot, so fall back to the pre-boot id only after a complete listing. + device = + findDevice(state, host.id, bootedId) ?? + (state.hostStatuses[host.id]?.incompletePlatforms?.includes(device.platform) + ? undefined + : findDevice(state, host.id, device.id)); if (!device) { return yield* new DeviceNotFoundError({ hostId: host.id, deviceId: bootedId }); } diff --git a/apps/web/src/components/device/DeviceSetup.test.ts b/apps/web/src/components/device/DeviceSetup.test.ts index f0158641e2a19..12e757e239fe8 100644 --- a/apps/web/src/components/device/DeviceSetup.test.ts +++ b/apps/web/src/components/device/DeviceSetup.test.ts @@ -1,7 +1,11 @@ import type { DeviceServiceState } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { deviceHubReadiness, platformSetupStatus } from "./DeviceSetup"; +import { + deviceHubReadiness, + isAndroidDiscoveryUncertain, + platformSetupStatus, +} from "./DeviceSetup"; const deviceState = (overrides: Partial = {}): DeviceServiceState => ({ hosts: [ @@ -47,6 +51,12 @@ describe("device setup guidance", () => { "ios", ).message, ).toContain("discovery reported a problem"); + expect( + isAndroidDiscoveryUncertain({ + ...state, + hostStatuses: { remote: { status: "ready", incompletePlatforms: ["ios", "android"] } }, + }), + ).toBe(true); }); it("counts a remote device host when this machine has no simulator tooling", () => { diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx index e4f8831fe19c7..69b30899d0413 100644 --- a/apps/web/src/components/device/DeviceSetup.tsx +++ b/apps/web/src/components/device/DeviceSetup.tsx @@ -26,7 +26,9 @@ const agentDeviceDescription = export const isAndroidDiscoveryUncertain = (state: DeviceServiceState) => Object.values(state.hostStatuses).some( - (status) => status.status === "ready" && status.androidDiscoveryIncomplete, + (status) => + status.status === "ready" && + (status.androidDiscoveryIncomplete || status.incompletePlatforms?.includes("android")), ); /** diff --git a/apps/web/src/components/settings/DeviceSourcesSettings.tsx b/apps/web/src/components/settings/DeviceSourcesSettings.tsx index 09ccc32fd61b9..3d586d9b28139 100644 --- a/apps/web/src/components/settings/DeviceSourcesSettings.tsx +++ b/apps/web/src/components/settings/DeviceSourcesSettings.tsx @@ -115,10 +115,13 @@ function hostStatus( if (!state) return { tone: "pending", label: "Loading" }; const status = state.hostStatuses[hostId]; switch (status?.status) { - case "ready": - return status.androidDiscoveryIncomplete - ? { tone: "warning", label: "Android list may be incomplete", detail: status.detail } - : undefined; + case "ready": { + const incomplete = new Set(status.incompletePlatforms); + if (status.androidDiscoveryIncomplete) incomplete.add("android"); + if (incomplete.size === 0) return undefined; + const list = incomplete.size > 1 ? "Device" : incomplete.has("ios") ? "iOS" : "Android"; + return { tone: "warning", label: `${list} list may be incomplete`, detail: status.detail }; + } case "installing": case "starting": return { tone: "pending", label: "Starting", detail: status.detail }; From e26916d86bff27085b22e1b663cbe665dc580c0c Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:40:37 +0000 Subject: [PATCH 21/21] fix(devices): incomplete discovery survives readiness updates and never confirms a boot Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceHubOperations.ts | 4 +- apps/server/src/device/DeviceService.test.ts | 121 ++++++++++++------ apps/server/src/device/DeviceService.ts | 53 +++++--- 3 files changed, 117 insertions(+), 61 deletions(-) diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index 687b72c995804..3ecfb6ba4affa 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -128,7 +128,9 @@ export const makeDeviceHubOperations = (options: { return { devices: [], detail: `The device hub did not answer within ${LIST_TIMEOUT_SECONDS} seconds.`, - incompletePlatforms: ["ios", "android"] as const, + incompletePlatforms: (yield* options.androidAvailable) + ? (["ios", "android"] as const) + : (["ios"] as const), }; } const hubList = listed.value; diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 77605e3e358ac..22bdc34930b7e 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -159,7 +159,11 @@ const fixture = Effect.fn("fixture")(function* ( Response.json( bootError ? { ok: false, ...(typeof bootError === "string" ? { error: bootError } : bootError) } - : { ok: true, serial: "emulator-5554" }, + : // The hub reports a serial only for emulators; a simulator keeps its UDID. + request.body._tag === "Uint8Array" && + new TextDecoder().decode(request.body.body).includes('"platform":"ios"') + ? { ok: true } + : { ok: true, serial: "emulator-5554" }, ), ); } @@ -250,14 +254,16 @@ const fixture = Effect.fn("fixture")(function* ( if (runtimeFailure && runtimeFailure._tag !== "AgentDeviceStartError") return yield* runtimeFailure; starts.push("start"); - yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…"); + // A running hub reports no phase, as the real hosts do. + if (starts.length === 1) + yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…"); return ready; }), ensureAgentReady: (onPhase) => Effect.gen(function* () { if (runtimeFailure) return yield* runtimeFailure; agentStarts.push("start"); - yield* onPhase("starting"); + if (agentStarts.length === 1) yield* onPhase("starting"); return { ...ready, agentDevice: { baseUrl: "http://agent.test", token: "test", entryPath: "/agent" }, @@ -347,12 +353,19 @@ describe("device setup consent", () => { reason: "daemon_lock_not_directory", cause: { stdout: "private startup diagnostics", code: 1 }, }); - const { service, settings } = yield* fixture(Effect.void, undefined, false, startupError); + const { service, settings, discoveryErrors } = yield* fixture( + Effect.void, + undefined, + false, + startupError, + ); yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true, enableAgentDeviceAccess: true, })); + yield* Ref.set(discoveryErrors, [{ message: simctlMessage }]); + yield* service.list; const error = yield* service.agentReadinessIfSupported().pipe(Effect.flip); expect(error).toMatchObject({ _tag: "DeviceHostUnavailableError", @@ -364,6 +377,7 @@ describe("device setup consent", () => { expect((yield* service.state).hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ status: "failed", detail: startupError.message, + incompletePlatforms: ["ios"], }); }).pipe(Effect.scoped), ); @@ -711,7 +725,8 @@ it.effect( physical: false, } as const; devices.push(simulator, { ...simulator, id: DeviceId.make("pixel"), platform: "android" }); - yield* service.configure({ enabled: true }); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + yield* service.agentReadinessIfSupported(); devices.length = 0; yield* Ref.set(discoveryErrors, [{ message: simctlMessage }]); const failed = yield* service.list; @@ -721,6 +736,8 @@ it.effect( detail: simctlMessage, incompletePlatforms: ["ios"], }); + yield* service.agentReadinessIfSupported(); + expect((yield* service.state).hostStatuses.local).toEqual(failed.hostStatuses.local); yield* Ref.set(discoveryErrors, []); const recovered = yield* service.list; expect(recovered.devices.map((device) => device.id)).toEqual(["Pixel_API_35"]); @@ -728,52 +745,76 @@ it.effect( }).pipe(Effect.scoped), ); -it.effect("keeps every device when the hub does not answer the listing within 40 seconds", () => - Effect.gen(function* () { - const { service, devices, listing } = yield* fixture(); - devices.push({ - id: DeviceId.make("iphone"), - name: "iPhone", - platform: "ios", - version: "iOS 26", - booted: true, - physical: false, - }); - yield* service.configure({ enabled: true }); - listing.hangs = true; - const listed = yield* service.list.pipe(Effect.forkScoped); - yield* Deferred.await(listing.requested); - yield* TestClock.adjust("40 seconds"); - const timedOut = yield* Fiber.join(listed); - expect(timedOut.devices.map((device) => device.id)).toEqual(["iphone", "Pixel_API_35"]); - expect(timedOut.hostStatuses.local).toEqual({ - status: "ready", - detail: "The device hub did not answer within 40 seconds.", - incompletePlatforms: ["ios", "android"], - }); - }).pipe(Effect.scoped), +it.effect.each([ + { android: true, incompletePlatforms: ["ios", "android"], kept: ["iphone", "Pixel_API_35"] }, + { android: false, incompletePlatforms: ["ios"], kept: ["iphone"] }, +] as const)( + "keeps every device when the hub does not answer within 40 seconds (Android SDK: $android)", + ({ android, incompletePlatforms, kept }) => + Effect.gen(function* () { + const { service, devices, listing, androidAvailable } = yield* fixture(); + yield* Ref.set(androidAvailable, android); + devices.push({ + id: DeviceId.make("iphone"), + name: "iPhone", + platform: "ios", + version: "iOS 26", + booted: true, + physical: false, + }); + yield* service.configure({ enabled: true }); + listing.hangs = true; + const listed = yield* service.list.pipe(Effect.forkScoped); + yield* Deferred.await(listing.requested); + yield* TestClock.adjust("40 seconds"); + const timedOut = yield* Fiber.join(listed); + expect(timedOut.devices.map((device) => device.id)).toEqual(kept); + expect(timedOut.hostStatuses.local).toEqual({ + status: "ready", + detail: "The device hub did not answer within 40 seconds.", + incompletePlatforms, + }); + }).pipe(Effect.scoped), ); -it.effect("does not open the pre-boot AVD when the post-boot listing times out", () => +it.effect("does not open a retained pre-boot entry when the post-boot listing is incomplete", () => Effect.gen(function* () { - const hang = { listing: () => {} }; - const { service, listing } = yield* fixture(Effect.sync(() => hang.listing())); - hang.listing = () => { - listing.hangs = true; - }; - yield* service.configure({ enabled: true }); - const opening = yield* service + const afterBoot = { run: Effect.void as Effect.Effect }; + const android = yield* fixture(Effect.suspend(() => afterBoot.run)); + afterBoot.run = Effect.sync(() => { + android.listing.hangs = true; + }); + yield* android.service.configure({ enabled: true }); + const opening = yield* android.service .open({ threadId: ThreadId.make("boot-timeout"), deviceId: "Pixel_API_35", platform: "android", }) .pipe(Effect.flip, Effect.forkScoped); - yield* Deferred.await(listing.requested); + yield* Deferred.await(android.listing.requested); yield* TestClock.adjust("40 seconds"); - const error = yield* Fiber.join(opening); + expect((yield* Fiber.join(opening))._tag).toBe("DeviceNotFoundError"); + expect((yield* android.service.state).sessions).toEqual([]); + + const ios = yield* fixture(Effect.suspend(() => afterBoot.run)); + afterBoot.run = Effect.sync(() => { + ios.devices.length = 0; + }).pipe(Effect.andThen(Ref.set(ios.discoveryErrors, [{ message: simctlMessage }]))); + ios.devices.push({ + id: DeviceId.make("iphone"), + name: "iPhone", + platform: "ios", + version: "iOS 26", + booted: false, + physical: false, + }); + yield* ios.service.configure({ enabled: true }); + const error = yield* ios.service + .open({ threadId: ThreadId.make("boot-simctl"), deviceId: "iphone", platform: "ios" }) + .pipe(Effect.flip); expect(error._tag).toBe("DeviceNotFoundError"); - expect((yield* service.state).sessions).toEqual([]); + expect((yield* ios.service.state).sessions).toEqual([]); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 8e671b8b744f2..76a88f119f4c8 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -390,21 +390,36 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* Effect.suspend(() => !hosts.has(hostId) ? SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)) - : publish((state) => ({ - ...state, - ...(hostId === LOCAL_DEVICE_HOST_ID - ? { hostStatus: status.status, hostStatusDetail: status.detail } - : {}), - hostStatuses: { - ...state.hostStatuses, - [hostId]: { - ...(state.hostStatuses[hostId]?.androidDiscoveryIncomplete - ? { androidDiscoveryIncomplete: true } - : {}), - ...status, + : publish((state) => { + const previous = state.hostStatuses[hostId]; + // Discovery problems outlive readiness updates until a listing clears them. + const detail = + status.detail ?? + (status.status === "ready" && + previous?.status === "ready" && + previous.incompletePlatforms + ? previous.detail + : undefined); + return { + ...state, + ...(hostId === LOCAL_DEVICE_HOST_ID + ? { hostStatus: status.status, hostStatusDetail: detail } + : {}), + hostStatuses: { + ...state.hostStatuses, + [hostId]: { + ...(previous?.androidDiscoveryIncomplete + ? { androidDiscoveryIncomplete: true } + : {}), + ...(previous?.incompletePlatforms + ? { incompletePlatforms: previous.incompletePlatforms } + : {}), + ...status, + ...(detail ? { detail } : {}), + }, }, - }, - })), + }; + }), ); const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")( @@ -795,12 +810,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ), ); state = yield* refresh(ready); - // Retained entries predate the boot, so fall back to the pre-boot id only after a complete listing. - device = - findDevice(state, host.id, bootedId) ?? - (state.hostStatuses[host.id]?.incompletePlatforms?.includes(device.platform) - ? undefined - : findDevice(state, host.id, device.id)); + // Retained entries predate the boot, so only a complete listing confirms the booted device. + device = state.hostStatuses[host.id]?.incompletePlatforms?.includes(device.platform) + ? undefined + : (findDevice(state, host.id, bootedId) ?? findDevice(state, host.id, device.id)); if (!device) { return yield* new DeviceNotFoundError({ hostId: host.id, deviceId: bootedId }); }