Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4983016
fix(devices): a failed simulator listing keeps the last devices
Oct 27, 2026
a0e2364
fix(devices): keep setup guidance specific to each platform
Oct 27, 2026
84342a4
fix(devices): retain simulators after hub listing timeouts
Oct 27, 2026
945cab8
fix(devices): show discovery problems as one quiet line
Oct 29, 2026
28a8863
fix(devices): use the panel for diagnostic scrolling
Oct 29, 2026
acaca5a
fix(devices): keep warning details in the existing notice
Oct 29, 2026
2dfb98e
fix(devices): name discovery details for each host
Oct 29, 2026
cc42670
fix(devices): use the default discovery trigger layout
Oct 29, 2026
46b7724
fix(devices): pin expo-device-hub with the simctl list timeout
Oct 30, 2026
0ece20b
fix(devices): recognize the hub's boot timeout
Oct 30, 2026
925da4d
fix(devices): opens of one device take turns
Oct 31, 2026
93960de
fix(devices): keep Android AVD opens concurrent
Oct 31, 2026
8e84707
fix(devices): reject mismatched platforms before opening
Oct 31, 2026
ec0f438
fix(devices): release idle iOS open locks
Nov 1, 2026
2cf2427
fix(devices): limit this fix to device discovery
shivamhwp Nov 3, 2026
2b340cc
test(devices): retain iOS simulators in hub timeout coverage
shivamhwp Nov 3, 2026
8831093
fix(devices): send incomplete discovery platforms to clients
shivamhwp Nov 3, 2026
d58289a
refactor(devices): trim the discovery retention fix
shivamhwp Nov 3, 2026
62e7190
fix(web): device discovery warning can be dismissed and keeps Details…
shivamhwp Nov 4, 2026
d8df073
fix(devices): incomplete discovery no longer opens a stale AVD or adv…
shivamhwp Nov 4, 2026
e26916d
fix(devices): incomplete discovery survives readiness updates and nev…
shivamhwp Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/src/device/DeviceHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DevicePlatform>;
},
DeviceOperationError
>;
Expand Down
54 changes: 40 additions & 14 deletions apps/server/src/device/DeviceHubOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@ 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";

import type { DeviceHostReady, DeviceOperations, HostDevice } from "./DeviceHost.ts";

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.
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 )/;

const HubDevice = Schema.Struct({
id: Schema.String,
Expand Down Expand Up @@ -69,25 +76,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 = <A, I>(
request: HttpClientRequest.HttpClientRequest,
schema: Schema.Codec<A, I>,
) =>
httpClient
.execute(request)
.pipe(
Effect.flatMap(HttpClientResponse.filterStatusOk),
Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
Effect.scoped,
);

const hubJson = <A, I>(
request: HttpClientRequest.HttpClientRequest,
schema: Schema.Codec<A, I>,
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<string, string>, operation: string) =>
Expand All @@ -106,11 +118,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.`,
incompletePlatforms: ["ios", "android"] as const,
};
}
const hubList = listed.value;
const devices: Array<HostDevice> = [...hubList.simulators, ...hubList.emulators];
const androidAvailable = yield* options.androidAvailable;
const warnings = hubList.errors?.filter(
Expand Down Expand Up @@ -155,10 +177,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"));

Expand Down
52 changes: 52 additions & 0 deletions apps/server/src/device/DeviceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -100,6 +101,7 @@ const fixture = Effect.fn("fixture")(function* (
const discoveryErrors = yield* Ref.make<Array<{ message: string; error?: string }>>([]);
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<void>() };
let booted = false;
let shutDown = false;
const connection = { online: true, kind: "local" as "local" | "ssh", failShutdown: false };
Expand Down Expand Up @@ -164,6 +166,11 @@ const fixture = Effect.fn("fixture")(function* (
if (request.url.endsWith("/grid/api/start")) {
return HttpClientResponse.fromWeb(request, Response.json({ ok: true }));
}
if (listing.hangs) {
return yield* Deferred.succeed(listing.requested, undefined).pipe(
Effect.andThen(Effect.never),
);
}
return HttpClientResponse.fromWeb(
request,
Response.json({
Expand Down Expand Up @@ -325,6 +332,7 @@ const fixture = Effect.fn("fixture")(function* (
encoderResult,
discoveryErrors,
androidAvailable,
listing,
endDevice,
};
});
Expand Down Expand Up @@ -689,6 +697,50 @@ it.effect("keeps the cause from the hub's error in discovery warnings, without t
}).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, 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.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.",
});
}).pipe(Effect.scoped),
);

it.effect.each([
{
diagnostic: "Insufficient disk space at /private/user/path",
Expand Down
14 changes: 10 additions & 4 deletions apps/server/src/device/DeviceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [],
};
});

Expand All @@ -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* () {
Expand All @@ -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)),
Comment thread
shivamhwp marked this conversation as resolved.
Resolved
],
// A host that ends devices itself (EAS stops sessions at their limits)
// leaves sessions on devices it no longer lists with nothing to stream.
Expand Down
25 changes: 19 additions & 6 deletions apps/web/src/components/device/DevicePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -408,13 +409,25 @@ function DeviceNotice(props: {
}

function DeviceDiscoveryWarning(props: { readonly label: string; readonly detail: string }) {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
const [open, setOpen] = useState(false);
return (
<DeviceNotice tone="warning" onDismiss={() => setDismissed(true)}>
<p className="font-medium">Device discovery warning · {props.label}</p>
{props.detail}
</DeviceNotice>
<Collapsible open={open} onOpenChange={setOpen}>
<div
role="status"
className="flex items-center gap-2 border-b px-3 py-1.5 text-xs text-muted-foreground"
>
<CircleAlert className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">
Some devices on {props.label} could not be listed.
</span>
<CollapsibleTrigger aria-label={`Details for ${props.label}`}>Details</CollapsibleTrigger>
</div>
<CollapsiblePanel>
<DeviceNotice tone="warning" onDismiss={() => setOpen(false)}>
{props.detail}
</DeviceNotice>
</CollapsiblePanel>
</Collapsible>
);
}

Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/components/device/DeviceSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,26 @@ 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 "),
);
})
) {
return {
ready: false,
message: `No ${platformName(platform)} devices were listed because device discovery reported a problem.`,
};
}
return {
ready: false,
message:
Expand Down
Loading