authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-20 17:48:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 17:48:08-07:00
log58f89818e3ada7f680bede396d41ed27eb618203
treea25cbdea4ff22bafa1112c482a062019ae688c1c
parent051a4057a67ceebc7a36b13678f404b7ee871a46
signature Signed by PGP key B5690EEEBB952194

fix(server): tolerate shutting down an iOS simulator that is already off (#12807)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 139 insertions(+), 24 deletions(-)

apps/server/src/device/DeviceService.test.ts+102
......@@ -7,6 +7,7 @@ import {
77 type DeviceServiceState,
88} from "@t3tools/contracts";
99import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
1011import * as Deferred from "effect/Deferred";
1112import * as Fiber from "effect/Fiber";
1213import * as PubSub from "effect/PubSub";
......@@ -469,3 +470,104 @@ it.effect.each(["shutdown", "close"] as const)(
469470 Effect.scoped,
470471 ),
471472);
473
474it.effect.each([
475 { hubReports: "off", outcome: "succeeds" },
476 { hubReports: "booted", outcome: "fails" },
477 { hubReports: "missing", outcome: "fails" },
478] as const)(
479 "iOS shutdown $outcome when serve-sim rejects it and the hub reports the simulator $hubReports",
480 ({ hubReports, outcome }) =>
481 Effect.gen(function* () {
482 const deviceId = DeviceId.make("22222222-2222-2222-2222-222222222222");
483 const paths: string[] = [];
484 // The device list is stale until shutdown re-reads it from the hub.
485 let listed: "booted" | "off" | "missing" = "booted";
486 const ready: DeviceHost.DeviceHostReady = {
487 nodePath: process.execPath,
488 hub: { origin: "http://device.test" },
489 helpers: { serveSimAxSettings: null, serveSimCli: null },
490 run: () => Effect.succeed({ code: 0, stdout: "", stderr: "" }),
491 };
492 const host: DeviceHost.DeviceHost["Service"] = {
493 id: LOCAL_DEVICE_HOST_ID,
494 summary: Effect.succeed({
495 id: LOCAL_DEVICE_HOST_ID,
496 kind: "local",
497 label: "Simulator host",
498 platforms: [{ platform: "ios", available: true }],
499 hubInstalled: true,
500 agentDeviceInstalled: false,
501 }),
502 platformAvailability: (platform) => Effect.succeed({ platform, available: true }),
503 ensureReady: () => Effect.succeed(ready),
504 ensureAgentReady: () => Effect.die("Agent access is not used in this test"),
505 current: Effect.succeed(ready),
506 stopAgent: Effect.void,
507 stop: Effect.void,
508 };
509 const http = HttpClient.make((request) =>
510 Effect.sync(() => {
511 const path = new URL(request.url).pathname;
512 paths.push(path);
513 if (path === "/api/devices") {
514 return HttpClientResponse.fromWeb(
515 request,
516 Response.json({
517 emulators: [],
518 simulators:
519 listed === "missing"
520 ? []
521 : [
522 {
523 id: deviceId,
524 name: "iPhone",
525 platform: "ios",
526 version: "26",
527 physical: false,
528 booted: listed === "booted",
529 },
530 ],
531 // A partial listing still decodes; it must not read as "off".
532 errors: listed === "missing" ? [{ message: "simctl list failed" }] : [],
533 }),
534 );
535 }
536 if (path === "/vendor/serve-sim/grid/api/shutdown") {
537 // serve-sim runs `simctl shutdown` bare and returns its failure as-is.
538 listed = hubReports;
539 return HttpClientResponse.fromWeb(
540 request,
541 Response.json(
542 { ok: false, error: "Unable to shutdown device in current state: Shutdown" },
543 { status: 500 },
544 ),
545 );
546 }
547 throw new Error(`Unexpected hub path: ${path}`);
548 }),
549 );
550 const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe(
551 Effect.provideService(HttpClient.HttpClient, http),
552 );
553 yield* service.list;
554 const exit = yield* Effect.exit(service.shutdown({ deviceId, platform: "ios" }));
555 expect(paths.filter((path) => path.endsWith("shutdown"))).toEqual([
556 "/vendor/serve-sim/grid/api/shutdown",
557 ]);
558 if (outcome === "succeeds") {
559 expect(Exit.isSuccess(exit)).toBe(true);
560 expect(
561 (yield* service.state).devices.find((device) => device.id === deviceId)?.booted,
562 ).toBe(false);
563 } else {
564 expect(Exit.isFailure(exit)).toBe(true);
565 expect(
566 (yield* service.state).devices.find((device) => device.id === deviceId)?.booted,
567 ).toBe(true);
568 }
569 }).pipe(
570 Effect.provide(ServerSettingsService.layerTest({ enableDeviceSupport: true })),
571 Effect.scoped,
572 ),
573);
apps/server/src/device/DeviceService.ts+37-24
......@@ -670,31 +670,44 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
670670 platform: DevicePlatform,
671671 ) {
672672 const ready = yield* readiness(hostId);
673 // serve-sim's shutdown closes its in-process capture session before powering off.
674 // The hub's generic shutdown can leave that session cached across a reboot.
675 const path =
676 platform === "ios" ? `${vendorPrefix("ios")}/grid/api/shutdown` : "/api/devices/shutdown";
677 yield* HttpClientRequest.post(`${ready.hub.origin}${path}`).pipe(
678 HttpClientRequest.bodyJson(
679 platform === "ios" ? { udid: deviceId } : { platform, id: deviceId },
680 ),
681 Effect.mapError(
682 (cause) =>
683 new DeviceOperationError({ operation: "shutdown", reason: "invalid_payload", cause }),
684 ),
685 Effect.flatMap((request) => hubJson(request, HubActionResult, "shutdown")),
686 Effect.flatMap((result) =>
687 result.ok
688 ? Effect.void
689 : Effect.fail(
690 new DeviceOperationError({
691 operation: "shutdown",
692 reason: "hub_rejected",
693 cause: result,
694 }),
673 const postShutdown = (path: string, body: Record<string, string>) =>
674 HttpClientRequest.post(`${ready.hub.origin}${path}`).pipe(
675 HttpClientRequest.bodyJson(body),
676 Effect.mapError(
677 (cause) =>
678 new DeviceOperationError({ operation: "shutdown", reason: "invalid_payload", cause }),
679 ),
680 Effect.flatMap((request) => hubJson(request, HubActionResult, "shutdown")),
681 Effect.flatMap((result) =>
682 result.ok
683 ? Effect.void
684 : Effect.fail(
685 new DeviceOperationError({
686 operation: "shutdown",
687 reason: "hub_rejected",
688 cause: result,
689 }),
690 ),
691 ),
692 );
693 // serve-sim's shutdown closes its in-process capture session before it runs
694 // `simctl shutdown`; the hub's generic shutdown can leave that session cached
695 // across a reboot. serve-sim runs simctl bare, though, so a simulator that is
696 // already off fails there. Accept that failure only when the hub confirms
697 // the simulator is off; a failure on a running one still surfaces.
698 yield* platform === "ios"
699 ? postShutdown(`${vendorPrefix("ios")}/grid/api/shutdown`, { udid: deviceId }).pipe(
700 Effect.catch((cause) =>
701 fetchDevices(ready).pipe(
702 Effect.flatMap(({ devices }) =>
703 devices.find((device) => device.id === deviceId)?.booted === false
704 ? Effect.logInfo("iOS simulator was already shut down", { deviceId })
705 : Effect.fail(cause),
706 ),
695707 ),
696 ),
697 );
708 ),
709 )
710 : postShutdown("/api/devices/shutdown", { platform, id: deviceId });
698711 yield* publish((state) => ({
699712 ...state,
700713 devices: state.devices.map((device) =>