apps/server/src/device/EasDeviceHost.ts

/**
 * Cloud simulators from EAS Simulator. A device exists only while a session
 * runs, and every session bills the Expo account until it stops, so this host
 * bounds each one, stops all of them when it stops, and stops sessions a
 * previous server left behind. It never touches sessions it did not start.
 */
import {
  DeviceBootError,
  type DeviceHostSummary,
  DeviceHostUnavailableError,
  DeviceOperationError,
  type DevicePlatform,
  type EasDeviceHostConfig,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Clock from "effect/Clock";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as FiberSet from "effect/FiberSet";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Random from "effect/Random";
import * as Redacted from "effect/Redacted";
import * as Schedule from "effect/Schedule";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
import * as HostProcess from "@t3tools/shared/HostProcess";

import * as ServerConfig from "../config.ts";
import * as DeviceHost from "./DeviceHost.ts";
import { findEasProjectId } from "./easProjectId.ts";
import { type ExpoApi, makeExpoApi, resolveExpoCredential } from "./ExpoApi.ts";

const POLL_INTERVAL = Duration.seconds(5);
// How stale a running session's status may get before listing rechecks it.
const STATUS_TTL_MS = 15_000;
// Expo's own CLI waits this long once a worker picks the session up.
const START_TIMEOUT = Duration.minutes(15);
const PREVIEW_TIMEOUT = Duration.seconds(20);
const SERVE_SIM_PREFIX = "/vendor/serve-sim";

/** EAS picks the simulator model; one entry starts a session. */
const CATALOG: ReadonlyArray<DeviceHost.HostDevice> = [
  {
    id: "eas-ios",
    platform: "ios",
    name: "iPhone (EAS Simulator)",
    version: "Cloud",
    booted: false,
    physical: false,
  },
];

interface RunningSession {
  readonly sessionId: string;
  readonly name: string;
  readonly udid: string;
  readonly preview: { readonly origin: string; readonly token: Redacted.Redacted<string> };
  readonly agent: DeviceHost.AgentDaemonEndpoint;
  /** Set once a stop has been asked for but Expo has not confirmed it. */
  stopping: boolean;
}

/**
 * Every session this host may have started, recorded by its unique name before
 * Expo is asked to create it, so a server that crashes at any point can still
 * find and stop the session on its next start.
 */
const LedgerEntry = Schema.Struct({
  name: Schema.String,
  appId: Schema.String,
  recordedAt: Schema.Number,
  /** Expo refused the create: nothing should run, but the reaper still looks. */
  refused: Schema.optional(Schema.Boolean),
});
type LedgerEntry = typeof LedgerEntry.Type;
const Ledger = Schema.fromJsonString(Schema.Array(LedgerEntry));
const decodeLedger = Schema.decodeUnknownEffect(Ledger);
const encodeLedger = Schema.encodeSync(Ledger);
// How often listing looks for recorded sessions this host no longer tracks.
const REAP_INTERVAL_MS = 60_000;
// Expo's ready queue is unbounded despite its 115-minute session limit and five-minute cleanup.
// Unlisted sessions stop counting after this lifetime; forget their records only once
// confirmed not running or after a day unconfirmed.
const RECORD_LIFETIME_MS = 180 * 60_000;
const RECORD_MAX_AGE_MS = 24 * 60 * 60_000;
// Expo lists a session it made within seconds; a create with an unknown
// outcome stops counting against the limit once it has had this long.
const UNCERTAIN_CREATE_MS = 5 * 60_000;
// One failed request to Expo during a minutes-long start does not end the start.
const TRANSIENT_RETRY = { times: 3, schedule: Schedule.spaced("2 seconds") } as const;
const decodePreviewApi = Schema.decodeUnknownEffect(Schema.Struct({ device: Schema.String }));

/** A malformed escape names no device, so the proxy answers 404 instead of failing. */
const decodePathSegment = (segment: string): string | null => {
  try {
    return decodeURIComponent(segment);
  } catch {
    return null;
  }
};

export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDeviceHostConfig) {
  const http = yield* HttpClient.HttpClient;
  const fs = yield* FileSystem.FileSystem;
  const path = yield* Path.Path;
  const server = yield* ServerConfig.ServerConfig;
  const services = yield* Effect.context<
    FileSystem.FileSystem | Path.Path | Effect.Services<ReturnType<typeof findEasProjectId>>
  >();
  const hostId = config.id;
  const ledgerPath = path.join(server.stateDir, "device", "eas", `${hostId}.json`);
  // Keyed by Expo's session id, which is also the device id: every EAS
  // simulator reports the same UDID, so the UDID cannot tell sessions apart.
  const sessions = new Map<string, RunningSession>();
  const checkedAt = new Map<string, number>();
  const startLock = yield* Semaphore.make(1);
  const ledgerLock = yield* Semaphore.make(1);
  // One sweep at a time: two would stop the same session twice.
  const reapLock = yield* Semaphore.make(1);
  // Starts run in the host rather than the caller: a client that disconnects
  // while a session boots leaves it running and tracked, and stopping or
  // closing the host interrupts the start, which then stops its session.
  const starts = yield* FiberSet.make<string, unknown>();
  // The startup sweep for recorded leftovers.
  const background = yield* FiberSet.make<void, never>();
  // Sessions this host owns that `sessions` does not hold: starts in progress,
  // and sessions whose stop after a failed start failed too. The reaper leaves
  // them alone and they count against the limit.
  const starting = new Set<string>();
  const unresolved = new Map<string, { readonly sessionId: string }>();
  // Creates whose outcome is unknown (no answer, or a server error), and
  // fresh records a previous server left: Expo may have made the session.
  // Each listing looks for it, and it counts against the limit until found
  // or until Expo would have listed it by now. The reaper handles these.
  const uncertain = new Map<string, { readonly entry: LedgerEntry; readonly until: number }>();
  const ownedNames = () =>
    new Set([
      ...[...sessions.values()].map((session) => session.name),
      ...starting,
      ...unresolved.keys(),
    ]);
  // Bumped whenever the host stops its sessions, so a start that finishes
  // afterwards stops its own session instead of leaving it untracked.
  let epoch = 0;
  // Set once the host's scope closes. A listing still running on this
  // instance then neither sweeps nor writes the ledger: a replacement with
  // the same id owns the sessions and the record now.
  let closed = false;
  let reapedAt = 0;
  let ready: DeviceHost.DeviceHostReady | null = null;

  const unavailable = (reason: string, cause?: unknown) =>
    new DeviceHostUnavailableError({
      hostId,
      hostLabel: config.label,
      reason,
      ...(cause === undefined ? {} : { cause }),
    });

  const api: Effect.Effect<ExpoApi, DeviceHostUnavailableError> = resolveExpoCredential(
    config.accessToken,
  ).pipe(
    Effect.map((credential) => makeExpoApi(http, credential)),
    Effect.provide(services),
    Effect.mapError((error) => unavailable(error.message, error)),
  );

  /** A missing ledger is empty; an unreadable one is an error, never an empty list. */
  const readLedger = fs.readFileString(ledgerPath).pipe(
    Effect.catchIf(
      (error) => error.reason._tag === "NotFound",
      () => Effect.succeed("[]"),
    ),
    Effect.flatMap(decodeLedger),
    Effect.mapError((cause) =>
      unavailable(
        `The record of running EAS sessions at ${ledgerPath} could not be read. Fix or remove it.`,
        cause,
      ),
    ),
  );
  const writeLedger = Effect.fn("EasDeviceHost.writeLedger")(function* (
    update: (entries: ReadonlyArray<LedgerEntry>) => ReadonlyArray<LedgerEntry>,
  ) {
    if (closed) return yield* unavailable(`${config.label} was replaced or removed.`);
    const entries = update(yield* readLedger);
    yield* writeFileStringAtomically({
      filePath: ledgerPath,
      contents: encodeLedger(entries),
    }).pipe(
      Effect.provide(services),
      Effect.mapError((cause) =>
        unavailable(
          `The record of running EAS sessions at ${ledgerPath} could not be written.`,
          cause,
        ),
      ),
    );
  }, ledgerLock.withPermit);
  const markRefused = (name: string) =>
    writeLedger((entries) =>
      entries.map((entry) => (entry.name === name ? { ...entry, refused: true } : entry)),
    ).pipe(
      Effect.catch((cause) =>
        Effect.logWarning("Could not update the EAS session record", { name, cause }),
      ),
    );
  const forgetEntry = (name: string) =>
    writeLedger((entries) => entries.filter((entry) => entry.name !== name)).pipe(
      Effect.catch((cause) =>
        Effect.logWarning("Could not update the EAS session record", { name, cause }),
      ),
    );

  /** Stops a session and, once Expo confirms it, drops it from the ledger. */
  const stopSession = (expo: ExpoApi, sessionId: string, name: string) =>
    expo.stop(sessionId).pipe(
      Effect.andThen(forgetEntry(name)),
      Effect.tapError((cause) =>
        Effect.logWarning("Could not stop EAS Simulator session", { sessionId, cause }),
      ),
    );

  /** Finds recorded sessions by name, so a session whose id never arrived is found too. */
  const stopRecorded = (expo: ExpoApi, entries: ReadonlyArray<LedgerEntry>) => {
    const found = new Set<string>();
    const listed = new Set<string>();
    return Effect.forEach(
      [...new Set(entries.map((entry) => entry.appId))],
      (appId) =>
        expo.activeSessions(appId).pipe(
          Effect.flatMap((active) =>
            Effect.forEach(
              entries.filter((entry) => entry.appId === appId),
              (entry) =>
                Effect.gen(function* () {
                  const running = active.filter((session) => session.name === entry.name);
                  if (running.length > 0) {
                    found.add(entry.name);
                    return yield* Effect.forEach(
                      running,
                      (session) =>
                        stopSession(expo, session.id, entry.name).pipe(
                          // Still running and billed: owned, counted, and retried by listing.
                          Effect.catch(() =>
                            Effect.sync(
                              () => void unresolved.set(entry.name, { sessionId: session.id }),
                            ),
                          ),
                        ),
                      { discard: true },
                    );
                  }
                }),
              { discard: true },
            ),
          ),
          Effect.tap(() => Effect.sync(() => void listed.add(appId))),
          Effect.ignore,
        ),
      { discard: true, concurrency: 4 },
    ).pipe(
      Effect.as({ found, listed } as {
        readonly found: ReadonlySet<string>;
        /** The apps whose listing succeeded; a failed one rules nothing out. */
        readonly listed: ReadonlySet<string>;
      }),
    );
  };

  /**
   * Retries the stop of a session whose stop failed. A session Expo already
   * ended is let go even if stopping it keeps failing.
   */
  const retryUnresolved = (expo: ExpoApi, name: string, sessionId: string) =>
    stopSession(expo, sessionId, name).pipe(
      Effect.catch(() =>
        expo
          .session(sessionId)
          .pipe(
            Effect.flatMap((current) =>
              current.status === "STOPPED" || current.status === "ERRORED"
                ? forgetEntry(name)
                : Effect.fail(current.status),
            ),
          ),
      ),
      Effect.andThen(Effect.sync(() => void unresolved.delete(name))),
      Effect.ignore,
    );

  /**
   * Records a previous server left count against the limit until a sweep
   * finds them (a fresh one until Expo would have listed it by now), so a
   * restart cannot start past it.
   */
  const adoptRecorded = Effect.gen(function* () {
    const now = yield* Clock.currentTimeMillis;
    const owned = ownedNames();
    for (const entry of yield* readLedger) {
      const until = Math.max(entry.recordedAt + UNCERTAIN_CREATE_MS, now);
      // A refused create made nothing: its record is for the reaper only.
      if (!owned.has(entry.name) && !entry.refused) uncertain.set(entry.name, { entry, until });
    }
  }).pipe(Effect.ignore);

  /**
   * Stops recorded sessions this host does not own: a previous server's, or
   * a failed or uncertain start's. An uncertain one found (stopped, or
   * unresolved if that failed) or long enough unlisted frees its slot; its
   * record stays until it expires.
   */
  const reapLeftovers = Effect.fn("EasDeviceHost.reapLeftovers")(
    function* (expo: ExpoApi) {
      if (closed) return;
      reapedAt = yield* Clock.currentTimeMillis;
      const entries = yield* readLedger;
      const owned = ownedNames();
      const leftovers = entries.filter((entry) => !owned.has(entry.name));
      const { found, listed } = yield* stopRecorded(expo, leftovers);
      for (const [name, { entry, until }] of uncertain) {
        if (found.has(name) || (reapedAt >= until && listed.has(entry.appId))) {
          uncertain.delete(name);
        }
      }
      // Expo has ended these by now unless they waited in its queue: the slot
      // goes whatever the listing says (the app may be gone, or belong to
      // another account); the record goes once a listing confirms Expo does
      // not run it, or once no queue could have held it.
      for (const entry of leftovers) {
        const age = reapedAt - entry.recordedAt;
        if (age > RECORD_LIFETIME_MS) uncertain.delete(entry.name);
        if (found.has(entry.name)) continue;
        if ((age > RECORD_LIFETIME_MS && listed.has(entry.appId)) || age > RECORD_MAX_AGE_MS) {
          yield* forgetEntry(entry.name);
        }
      }
    },
    reapLock.withPermit,
    Effect.catch((cause) =>
      Effect.logWarning("Could not stop recorded EAS Simulator sessions", { cause }),
    ),
  );

  const previewDevice = (origin: string, token: Redacted.Redacted<string>) =>
    http
      .execute(
        HttpClientRequest.get(`${origin}/api`).pipe(
          HttpClientRequest.bearerToken(Redacted.value(token)),
        ),
      )
      .pipe(
        Effect.flatMap(HttpClientResponse.filterStatusOk),
        Effect.flatMap((response) => response.json),
        Effect.flatMap(decodePreviewApi),
        Effect.map((api) => api.device),
        Effect.scoped,
        Effect.timeout(PREVIEW_TIMEOUT),
        Effect.retry(TRANSIENT_RETRY),
      );

  const awaitRunning = Effect.fn("EasDeviceHost.awaitRunning")(
    function* (expo: ExpoApi, sessionId: string) {
      for (;;) {
        const session = yield* expo.session(sessionId).pipe(
          Effect.retry(TRANSIENT_RETRY),
          Effect.mapError((error) => unavailable(error.message, error)),
        );
        if (session.status === "STOPPED" || session.status === "ERRORED") {
          return yield* new DeviceBootError({
            hostId,
            deviceId: sessionId,
            reason: "launch_failed",
            cause: new Error(`EAS Simulator session ended (${session.status}) before it was ready`),
          });
        }
        if (session.status === "IN_PROGRESS" && session.endpoints) return session.endpoints;
        yield* Effect.sleep(POLL_INTERVAL);
      }
    },
    (poll, _expo, sessionId) =>
      poll.pipe(
        Effect.timeoutOrElse({
          duration: START_TIMEOUT,
          orElse: () =>
            Effect.fail(
              new DeviceBootError({
                hostId,
                deviceId: sessionId,
                reason: "timeout",
                cause: new Error("EAS Simulator session did not start in time"),
              }),
            ),
        }),
      ),
  );

  const reserveSlot = (name: string) =>
    startLock.withPermit(
      Effect.suspend(() => {
        const pending = unresolved.size + uncertain.size;
        if (sessions.size + starting.size + pending >= config.maxConcurrentSessions) {
          const limit = `${config.label} is limited to ${config.maxConcurrentSessions} running ${config.maxConcurrentSessions === 1 ? "session" : "sessions"}.`;
          return Effect.fail(
            unavailable(
              pending > 0
                ? `${limit} ${pending === 1 ? "A session" : `${pending} sessions`} that failed to start or stop ${pending === 1 ? "is" : "are"} still being cleaned up; try again shortly.`
                : `${limit} Close one in the Device panel first.`,
            ),
          );
        }
        starting.add(name);
        return Effect.void;
      }),
    );

  const startSession = Effect.fn("EasDeviceHost.startSession")(function* (
    device: DeviceHost.HostDevice,
    context: DeviceHost.DeviceOpenContext | undefined,
    startedIn: number,
  ) {
    const name = `T3 Code · ${config.label} · ${(yield* Random.nextIntBetween(0, 0xffffff)).toString(16).padStart(6, "0")}`;
    // The credential comes first: without one, the answer is how to sign in,
    // not that the limit is reached.
    const expo = yield* api;
    return yield* Effect.acquireUseRelease(
      reserveSlot(name),
      () =>
        Effect.gen(function* () {
          const appId =
            config.appId ??
            Option.getOrUndefined(
              context?.workspaceRoot
                ? yield* findEasProjectId(context.workspaceRoot).pipe(Effect.provide(services))
                : Option.none<string>(),
            );
          if (!appId) {
            return yield* unavailable(
              "No EAS project to run the session under. Choose one for this host in Settings, or open the device from a thread whose project names extra.eas.projectId in app.json or its app config.",
            );
          }
          const access = yield* expo
            .availability(appId)
            .pipe(Effect.mapError((error) => unavailable(error.message, error)));
          if (!access.available) {
            return yield* unavailable(
              `EAS Simulator is not enabled for the ${access.account} Expo account.`,
            );
          }
          const entry = { name, appId, recordedAt: yield* Clock.currentTimeMillis };
          // Recorded first: a session the ledger cannot hold is never started.
          yield* writeLedger((entries) => [...entries, entry]);
          if (epoch !== startedIn) {
            yield* forgetEntry(name);
            return yield* unavailable(`${config.label} was stopped while the session started.`);
          }
          // Create runs uninterrupted (Expo's API answers within 30 seconds) and
          // the stop below is in place before anything can interrupt the start,
          // so a session Expo made is always either tracked or stopped.
          return yield* Effect.uninterruptibleMask((restore) =>
            Effect.gen(function* () {
              const sessionId = yield* expo
                .create({
                  appId,
                  platform: device.platform,
                  name,
                  maxRunTimeMinutes: config.maxSessionMinutes,
                  maxIdleTimeMinutes: config.idleMinutes,
                })
                .pipe(
                  // Unless Expo refused, it may have made the session before
                  // failing to answer. Unlisted, it stays counted for a while.
                  // Refused, it made nothing: the slot frees, but the record
                  // stays for the reaper in case the refusal was misread.
                  Effect.tapError((error) =>
                    error.rejected
                      ? stopRecorded(expo, [entry]).pipe(
                          Effect.flatMap(({ found }) =>
                            found.has(name) ? Effect.void : markRefused(name),
                          ),
                        )
                      : stopRecorded(expo, [entry]).pipe(
                          Effect.flatMap(({ found }) =>
                            found.has(name)
                              ? Effect.void
                              : Effect.map(Clock.currentTimeMillis, (now) => {
                                  uncertain.set(name, {
                                    entry,
                                    until: now + UNCERTAIN_CREATE_MS,
                                  });
                                }),
                          ),
                        ),
                  ),
                  Effect.mapError((error) => unavailable(error.message, error)),
                );
              return yield* restore(trackSession(expo, sessionId, name, startedIn)).pipe(
                Effect.onError(() =>
                  stopSession(expo, sessionId, name).pipe(
                    // Still running and billed: owned, counted, and retried by listing.
                    Effect.catch(() => Effect.sync(() => void unresolved.set(name, { sessionId }))),
                  ),
                ),
              );
            }),
          );
        }),
      () => Effect.sync(() => void starting.delete(name)),
    );
  });

  /** Waits for a created session and starts tracking it. */
  const trackSession = Effect.fn("EasDeviceHost.trackSession")(function* (
    expo: ExpoApi,
    sessionId: string,
    name: string,
    startedIn: number,
  ) {
    const endpoints = yield* awaitRunning(expo, sessionId);
    if (!endpoints.preview) {
      return yield* new DeviceBootError({
        hostId,
        deviceId: sessionId,
        reason: "launch_failed",
        cause: new Error("EAS Simulator returned no preview endpoint"),
      });
    }
    const origin = new URL(endpoints.preview.url).origin;
    const udid = yield* previewDevice(origin, endpoints.preview.token).pipe(
      Effect.mapError(
        (cause) =>
          new DeviceBootError({
            hostId,
            deviceId: sessionId,
            reason: "launch_failed",
            cause,
          }),
      ),
    );
    if (epoch !== startedIn) {
      return yield* unavailable(`${config.label} was stopped while the session started.`);
    }
    sessions.set(sessionId, {
      sessionId,
      name,
      udid,
      preview: { origin, token: endpoints.preview.token },
      agent: {
        baseUrl: endpoints.agentDevice.url,
        token: Redacted.value(endpoints.agentDevice.token),
      },
      stopping: false,
    });
    return sessionId;
  });

  const sessionFor = (deviceId: string) => Option.fromNullishOr(sessions.get(deviceId));

  const forget = (session: RunningSession) =>
    Effect.sync(() => {
      sessions.delete(session.sessionId);
      checkedAt.delete(session.sessionId);
    });

  /**
   * Expo ends sessions on its own at their run or idle limit; stop listing
   * those. A stop that failed earlier is retried, and recorded sessions nothing
   * tracks are reaped now and then.
   */
  const dropEnded = Effect.gen(function* () {
    if (closed) return;
    const now = yield* Clock.currentTimeMillis;
    const stale = [...sessions.values()].filter(
      (session) =>
        session.stopping || now - (checkedAt.get(session.sessionId) ?? 0) > STATUS_TTL_MS,
    );
    const reapDue = now - reapedAt > REAP_INTERVAL_MS;
    if (stale.length === 0 && unresolved.size === 0 && uncertain.size === 0 && !reapDue) return;
    const reap = reapDue || uncertain.size > 0;
    if (reap) reapedAt = now;
    const expo = yield* api.pipe(
      // Without a credential no sweep can look; a record Expo would have
      // listed by now still stops counting, so the limit is not held forever.
      Effect.tapError(() =>
        Effect.sync(() => {
          for (const [name, { until }] of uncertain) if (now >= until) uncertain.delete(name);
        }),
      ),
    );
    yield* Effect.forEach(
      [...unresolved],
      ([name, { sessionId }]) => retryUnresolved(expo, name, sessionId),
      { discard: true, concurrency: 4 },
    );
    yield* Effect.forEach(
      stale,
      (session) =>
        (session.stopping
          ? stopSession(expo, session.sessionId, session.name).pipe(
              Effect.andThen(forget(session)),
              // A session Expo already ended is let go even if stopping it keeps failing.
              Effect.catch(() =>
                expo
                  .session(session.sessionId)
                  .pipe(
                    Effect.flatMap((current) =>
                      current.status === "STOPPED" || current.status === "ERRORED"
                        ? forget(session).pipe(Effect.andThen(forgetEntry(session.name)))
                        : Effect.fail(current.status),
                    ),
                  ),
              ),
            )
          : expo
              .session(session.sessionId)
              .pipe(
                Effect.flatMap((current) =>
                  current.status === "STOPPED" || current.status === "ERRORED"
                    ? forget(session).pipe(Effect.andThen(forgetEntry(session.name)))
                    : Effect.sync(() => void checkedAt.set(session.sessionId, now)),
                ),
              )
        ).pipe(Effect.ignore),
      { discard: true, concurrency: 4 },
    );
    if (reap) yield* reapLeftovers(expo);
  }).pipe(Effect.ignore);

  /**
   * Stops a tracked session. Until Expo confirms, it stays tracked: listed,
   * counted against the limit, and retried by the next listing.
   */
  const stopTracked = (expo: ExpoApi, session: RunningSession) =>
    Effect.suspend(() => {
      session.stopping = true;
      return stopSession(expo, session.sessionId, session.name);
    }).pipe(Effect.andThen(forget(session)));

  const devices: DeviceHost.DeviceOperations = {
    list: dropEnded.pipe(
      Effect.andThen(
        Effect.sync(() => ({
          devices: [
            ...CATALOG,
            ...[...sessions.values()].map((session): DeviceHost.HostDevice => ({
              id: session.sessionId,
              platform: "ios",
              name: "iPhone (EAS Simulator)",
              version: "Cloud",
              booted: true,
              physical: false,
            })),
          ],
          detail: undefined,
        })),
      ),
    ),
    boot: (device, context) => {
      if (sessions.has(device.id)) return Effect.succeed(device.id);
      // Read before anything yields, so a stop that lands while the start
      // reads settings or files is seen.
      const startedIn = epoch;
      return startSession(device, context, startedIn).pipe(
        FiberSet.run(starts),
        Effect.flatMap(Fiber.await),
        Effect.flatMap((exit) =>
          Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
            ? Effect.fail(unavailable(`${config.label} was stopped while the session started.`))
            : exit,
        ),
      );
    },
    attach: () => Effect.void,
    shutdown: (device) =>
      Option.match(sessionFor(device.id), {
        onNone: () => Effect.void,
        onSome: (session) =>
          api.pipe(
            Effect.flatMap((expo) => stopTracked(expo, session)),
            Effect.asVoid,
            Effect.mapError(
              (cause) =>
                new DeviceOperationError({
                  operation: "shutdown",
                  reason: "request_failed",
                  cause,
                }),
            ),
          ),
      }),
    screenshot: (device) =>
      Option.match(sessionFor(device.id), {
        onNone: () =>
          Effect.fail(
            new DeviceOperationError({
              operation: "screenshot",
              reason: "request_failed",
              cause: new Error("The EAS Simulator session is not running"),
            }),
          ),
        onSome: (session) =>
          http
            .execute(
              HttpClientRequest.post(
                `${session.preview.origin}/api/screenshot?device=${encodeURIComponent(session.udid)}`,
              ).pipe(HttpClientRequest.bearerToken(Redacted.value(session.preview.token))),
            )
            .pipe(
              Effect.flatMap(HttpClientResponse.filterStatusOk),
              Effect.flatMap((response) => response.arrayBuffer),
              Effect.map((buffer) => new Uint8Array(buffer)),
              Effect.scoped,
              Effect.timeout(PREVIEW_TIMEOUT),
              Effect.mapError(
                (cause) =>
                  new DeviceOperationError({
                    operation: "screenshot",
                    reason: "request_failed",
                    cause,
                  }),
              ),
            ),
      }),
    agentEndpoint: (deviceId) =>
      Option.match(sessionFor(deviceId), {
        onNone: () => Effect.fail(unavailable("This EAS Simulator device is no longer running.")),
        // agent-device selects the simulator by its UDID, not the session id T3 shows.
        onSome: (session) => Effect.succeed({ ...session.agent, agentDeviceId: session.udid }),
      }),
    endsDevices: true,
  };

  /**
   * serve-sim runs at the root of each session's preview host and wants its
   * token on every request. Clients name the device by its session id; serve-sim
   * knows it by its UDID, so the id is swapped for the UDID in the path and in
   * the `device` parameter.
   */
  const upstream: NonNullable<DeviceHost.DeviceHostReady["upstream"]> = (hubPath, search) =>
    Effect.sync(() => {
      if (!hubPath.startsWith(`${SERVE_SIM_PREFIX}/`)) return null;
      const helper = /^\/vendor\/serve-sim\/helper\/([^/]+)\//.exec(hubPath);
      const deviceId = helper ? decodePathSegment(helper[1]!) : search.get("device");
      if (helper && deviceId === null) return null;
      const session =
        deviceId !== null && deviceId !== undefined
          ? sessions.get(deviceId)
          : sessions.size === 1
            ? [...sessions.values()][0]
            : undefined;
      if (!session) return null;
      const path = helper
        ? `/helper/${encodeURIComponent(session.udid)}${hubPath.slice(helper[0].length - 1)}`
        : hubPath.slice(SERVE_SIM_PREFIX.length);
      const params = new URLSearchParams(search);
      if (params.has("device")) params.set("device", session.udid);
      const query = params.size > 0 ? `?${params.toString()}` : "";
      const token = Redacted.value(session.preview.token);
      return {
        url: `${session.preview.origin}${path}${query}`,
        headers: { authorization: `Bearer ${token}` },
        protocols: [`serve-sim.token.${token}`],
      };
    });

  const nodePath = yield* HostProcess.ExecutablePath;
  const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = Effect.fn(
    "EasDeviceHost.ensureReady",
  )(function* () {
    if (ready) return ready;
    yield* adoptRecorded;
    // Without a credential the host is still ready: opening a device then
    // says how to sign in, and listing reaps once one is there.
    yield* FiberSet.run(background)(api.pipe(Effect.flatMap(reapLeftovers), Effect.ignore));
    ready = {
      nodePath,
      hub: { origin: "https://api.expo.dev" },
      devices,
      upstream,
      // Device settings run simctl on the simulator's own machine, which EAS does not expose.
      run: () =>
        Effect.succeed({
          stdout: "",
          stderr: "EAS Simulator devices cannot run host commands.",
          code: 127,
        }),
      helpers: { serveSimAxSettings: null, serveSimCli: null },
    };
    return ready;
  });

  // A session whose stop fails stays tracked for the next listing to retry,
  // and stays in the ledger for the next server if this one closes first.
  const stopAll = Effect.gen(function* () {
    epoch++;
    // Interrupted starts stop the sessions they created before this returns.
    yield* FiberSet.clear(starts);
    yield* FiberSet.clear(background);
    const expo = yield* api;
    yield* Effect.forEach(
      [...sessions.values()],
      (session) => stopTracked(expo, session).pipe(Effect.ignore),
      { discard: true, concurrency: 4 },
    );
    yield* Effect.forEach(
      [...unresolved],
      ([name, { sessionId }]) => retryUnresolved(expo, name, sessionId),
      { discard: true, concurrency: 4 },
    );
    // Also stop a recorded session a create with an unknown outcome made after all.
    yield* reapLeftovers(expo);
  }).pipe(Effect.ignore);
  yield* Effect.addFinalizer(() =>
    stopAll.pipe(
      // Under the ledger lock, so a write still in flight lands before a
      // replacement writes its own, which that write would overwrite.
      Effect.andThen(
        ledgerLock.withPermit(
          Effect.sync(() => {
            closed = true;
          }),
        ),
      ),
    ),
  );

  // Each server signs in on its own, so one with no Expo credential answers for itself. Reading
  // the credential is local; whether Expo accepts it is only known once a session starts.
  const platformAvailability = (platform: DevicePlatform) =>
    platform === "ios"
      ? resolveExpoCredential(config.accessToken).pipe(
          Effect.provide(services),
          Effect.as({ platform, available: true }),
          Effect.catch((error) =>
            Effect.succeed({ platform, available: false, reason: error.message }),
          ),
        )
      : Effect.succeed({
          platform,
          available: false,
          reason: "EAS Simulator Android emulators are not supported yet.",
        });

  const summary: Effect.Effect<DeviceHostSummary> = Effect.all([
    platformAvailability("ios"),
    platformAvailability("android"),
  ]).pipe(
    Effect.map((platforms) => ({
      id: hostId,
      label: config.label,
      kind: "eas" as const,
      platforms,
      // Nothing to install: the hub and agent-device daemon run in each session.
      hubInstalled: true,
      agentDeviceInstalled: true,
    })),
  );

  return DeviceHost.DeviceHost.of({
    id: hostId,
    label: config.label,
    summary,
    platformAvailability,
    ensureReady,
    ensureAgentReady: Effect.fn("DeviceHost.ensureAgentReady")(function (onPhase) {
      return ensureReady(onPhase).pipe(Effect.map((value) => ({ ...value, agentDevice: null })));
    }),
    current: Effect.sync(() => ready),
    stopAgent: Effect.void,
    // Unlike machines the user owns, every running session here is billed.
    stop: stopAll,
  });
});