/** * The device host that is this machine. * * Runs expo-device-hub as a supervised child on a loopback port and starts the * agent-device daemon in HTTP mode under a T3-owned state directory. Both are * lazy: the device service requires explicit setup consent before it calls * ensureReady to install tools or start helper processes. * * The hub runs in its standalone mode (origin root). The T3 proxy strips its * own prefix, and the Device panel derives stream and socket URLs from the * prefix itself rather than from anything the hub prints. */import { type DeviceHostSummary, type DevicePlatform, type DevicePlatformAvailability, LOCAL_DEVICE_HOST_ID,} from "@t3tools/contracts";import { waitForHttpReady } from "@t3tools/shared/httpReadiness";import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";import * as NetService from "@t3tools/shared/Net";import { isCommandAvailable } from "@t3tools/shared/shell";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 FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Path from "effect/Path";import * as Ref from "effect/Ref";import * as Schema from "effect/Schema";import * as Scope from "effect/Scope";import * as Semaphore from "effect/Semaphore";import * as Stream from "effect/Stream";import { HttpClient } from "effect/unstable/http";import * as ChildProcess from "effect/unstable/process/ChildProcess";import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ServerConfig from "../config.ts";import * as ProcessRunner from "../processRunner.ts";import * as DeviceHost from "./DeviceHost.ts";import { agentDeviceStateDir, type DeviceToolPaths, ensureAgentDevice, ensureDeviceHub, isAgentDeviceInstalled, isDeviceHubInstalled,} from "./DeviceToolchain.ts"; const HUB_READY_TIMEOUT_MS = 30_000;const DAEMON_READY_TIMEOUT_MS = 30_000;const DAEMON_POLL_MS = 100;const HUB_RESTART_STABLE_UPTIME_MS = 60_000;const HUB_RESTART_MAX_DELAY_MS = 30_000; /** * Written beside the agent-device state so a server that dies without running * its finalizers (SIGKILL, dev-runner restarts) does not leave a hub bound to * a loopback port forever. The next start reads it, kills only a process that * is still that hub, and replaces the file. */const HubStateFile = Schema.Struct({ pid: Schema.Int, port: Schema.Int, entryPath: Schema.String,});const decodeHubStateFile = Schema.decodeUnknownEffect(Schema.fromJsonString(HubStateFile));const encodeHubStateFile = Schema.encodeUnknownEffect(Schema.fromJsonString(HubStateFile)); const AgentDeviceDaemonFile = Schema.Struct({ httpPort: Schema.Int, token: Schema.String, pid: Schema.optional(Schema.Int),});const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); interface HubProcess { readonly child: ChildProcessSpawner.ChildProcessHandle; readonly scope: Scope.Closeable; readonly origin: string; readonly startedAtMillis: number;} interface RunningHost { readonly hub: HubProcess; readonly agentDevice: DeviceHost.AgentDeviceEndpoint | null; readonly helpers: DeviceHost.DeviceHostReady["helpers"];} const platformReason = Effect.fn("LocalDeviceHost.platformReason")(function* ( platform: DevicePlatform,): Effect.fn.Return<string | null, never, FileSystem.FileSystem | Path.Path> { const hostPlatform = yield* HostProcessPlatform; if (platform === "ios") { if (hostPlatform !== "darwin") return "iOS Simulators need macOS with Xcode."; if (!(yield* isCommandAvailable("xcrun"))) return "Xcode command line tools were not found."; return null; } const sdk = yield* androidSdk; if (!sdk.root) return "Android SDK was not found. Install it with Android Studio or set ANDROID_HOME to your SDK directory."; if (!sdk.adb) return `Android SDK Platform-Tools are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`; if (!sdk.emulator) return `Android Emulator is missing from ${sdk.root}. Install it in Android Studio's SDK Manager.`; if (!sdk.avdmanager) return `Android SDK Command-line Tools (latest) are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`; return null;}); /** Resolve the SDK once for both diagnostics and the environment passed to helpers. */const androidSdk = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const environment = yield* HostProcessEnvironment; const platform = yield* HostProcessPlatform; const home = environment.HOME ?? environment.USERPROFILE ?? ""; const explicit = environment.ANDROID_HOME?.trim() || environment.ANDROID_SDK_ROOT?.trim(); const candidates = explicit ? [explicit] : [ path.join(home, "Library", "Android", "sdk"), path.join(home, "Android", "Sdk"), path.join( environment.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Android", "Sdk", ), ]; if (!explicit) { for (const directory of (environment.PATH ?? "").split(platform === "win32" ? ";" : ":")) { if (!directory) continue; const resolved = yield* fs .realPath(path.join(directory, platform === "win32" ? "adb.exe" : "adb")) .pipe(Effect.option); if (resolved._tag === "Some") candidates.push(path.dirname(path.dirname(resolved.value))); } } const exists = (file: string) => fs.exists(file).pipe(Effect.orElseSucceed(() => false)); for (const root of candidates) { const adb = yield* exists( path.join(root, "platform-tools", platform === "win32" ? "adb.exe" : "adb"), ); const emulator = yield* exists( path.join(root, "emulator", platform === "win32" ? "emulator.exe" : "emulator"), ); if (explicit || adb || emulator) { const avdmanager = yield* exists( path.join( root, "cmdline-tools", "latest", "bin", platform === "win32" ? "avdmanager.bat" : "avdmanager", ), ); return { root, adb, emulator, avdmanager }; } } return { root: null, adb: false, emulator: false, avdmanager: false };}); const deviceHostEnvironment = ( environment: NodeJS.ProcessEnv, sdkRoot: string | null, hostPlatform: NodeJS.Platform, path: Path.Path,): NodeJS.ProcessEnv => { return sdkRoot ? { ...environment, ANDROID_HOME: sdkRoot, PATH: [ path.join(sdkRoot, "platform-tools"), path.join(sdkRoot, "emulator"), environment.PATH ?? environment.Path ?? "", ].join(hostPlatform === "win32" ? ";" : ":"), } : environment;}; export const make = Effect.fn("LocalDeviceHost.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const config = yield* ServerConfig.ServerConfig; const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const net = yield* NetService.NetService; const runner = yield* ProcessRunner.ProcessRunner; const httpClient = yield* HttpClient.HttpClient; const environment = yield* HostProcessEnvironment; const hostPlatform = yield* HostProcessPlatform; const sdk = yield* androidSdk; const hostEnvironment = deviceHostEnvironment(environment, sdk.root, hostPlatform, path); const startLock = yield* Semaphore.make(1); const runningRef = yield* Ref.make<RunningHost | null>(null); const restartDelayRef = yield* Ref.make(0); const hostId = LOCAL_DEVICE_HOST_ID; const platformAvailability = Effect.fn("LocalDeviceHost.platformAvailability")(function* ( platform: DevicePlatform, ): Effect.fn.Return<DevicePlatformAvailability> { const reason = yield* platformReason(platform).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); return reason === null ? { platform, available: true } : { platform, available: false, reason }; }); const summary: Effect.Effect<DeviceHostSummary> = Effect.gen(function* () { const [platforms, hubInstalled, agentDeviceInstalled] = yield* Effect.all([ Effect.all([platformAvailability("ios"), platformAvailability("android")]), isDeviceHubInstalled(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ), isAgentDeviceInstalled(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ), ]); return { id: hostId, kind: "local", label: "This machine", platforms, hubInstalled, agentDeviceInstalled, }; }); const hubEnvironment = (): NodeJS.ProcessEnv => ({ ...hostEnvironment, FORCE_COLOR: "0", NO_COLOR: "1", }); const stopHub = (hub: HubProcess | undefined) => hub ? Scope.close(hub.scope, Exit.void).pipe(Effect.ignore) : Effect.void; const hubStatePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "hub.json"); const isProcessAlive = (pid: number) => Effect.sync(() => { try { process.kill(pid, 0); return true; } catch { return false; } }); /** * A hub left behind by a previous server is identified by pid plus the * command line's entry path, so a recycled pid belonging to something else * is never touched. */ const reapStaleHub = Effect.gen(function* () { const previous = yield* fs .readFileString(hubStatePath()) .pipe(Effect.flatMap(decodeHubStateFile), Effect.option); if (previous._tag === "None") return; const alive = yield* isProcessAlive(previous.value.pid); if (alive) { const commandLine = yield* runner .run({ command: "ps", args: ["-o", "command=", "-p", String(previous.value.pid)], timeout: Duration.seconds(5), timeoutBehavior: "timedOutResult", }) .pipe( Effect.map((result) => result.stdout), Effect.orElseSucceed(() => ""), ); if (commandLine.includes(previous.value.entryPath)) { yield* Effect.logWarning("Stopping a device hub left behind by a previous server", { pid: previous.value.pid, port: previous.value.port, }); yield* Effect.sync(() => { try { process.kill(previous.value.pid, "SIGTERM"); } catch { // Already gone. } }); } } yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); }).pipe(Effect.catchCause(() => Effect.void)); const recordHub = (hub: HubProcess, hubTool: DeviceToolPaths) => encodeHubStateFile({ pid: Number(hub.child.pid), port: Number(new URL(hub.origin).port), entryPath: hubTool.entryPath, }).pipe( Effect.flatMap((json) => fs.writeFileString(hubStatePath(), json)), Effect.ignore, ); const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* ( hubTool: DeviceToolPaths, ): Effect.fn.Return<HubProcess, DeviceHost.DeviceHostError> { yield* reapStaleHub; yield* fs .makeDirectory(agentDeviceStateDir(path, config.stateDir), { recursive: true }) .pipe(Effect.ignore); const port = yield* net.reserveLoopbackPort("127.0.0.1").pipe( Effect.mapError( (cause) => new DeviceHost.DeviceHostError({ hostId, step: "reserving a port for the device hub", cause, }), ), ); const origin = `http://127.0.0.1:${port}`; const scope = yield* Scope.make("sequential"); const child = yield* spawner .spawn( ChildProcess.make( process.execPath, [ hubTool.entryPath, "--port", String(port), "--host", "127.0.0.1", "--hide-sidebar", "--hide-boot-device", ], { detached: false, shell: false, stdout: "pipe", stderr: "pipe", env: hubEnvironment(), }, ), ) .pipe( Effect.provideService(Scope.Scope, scope), Effect.mapError( (cause) => new DeviceHost.DeviceHostError({ hostId, step: "starting the device hub", cause, }), ), ); const startedAtMillis = yield* Clock.currentTimeMillis; const hub: HubProcess = { child, scope, origin, startedAtMillis }; yield* Effect.forkIn(observeHubOutput(hub), scope); yield* waitForHttpReady({ baseUrl: origin, path: "/readyz", timeoutMs: HUB_READY_TIMEOUT_MS, makeError: (info) => new DeviceHost.DeviceHostError({ hostId, step: "waiting for the device hub to answer", cause: info.cause, }), }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.tapError(() => stopHub(hub)), ); yield* recordHub(hub, hubTool); yield* Effect.logInfo("Device hub started", { pid: Number(child.pid), port }); return hub; }); const observeHubOutput = (hub: HubProcess) => hub.child.all.pipe( Stream.decodeText(), Stream.splitLines, Stream.map((line) => line.trim()), Stream.filter((line) => line.length > 0), Stream.runForEach((line) => Effect.logDebug("Device hub output", { pid: Number(hub.child.pid), output: line }), ), Effect.catchCause(() => Effect.void), ); /** * Restart the hub when it dies under us, with the same doubling backoff the * relay connector uses so a hub that crashes on boot cannot spin. */ const superviseHub = (hub: HubProcess, hubTool: DeviceToolPaths): Effect.Effect<void> => Effect.gen(function* () { yield* Effect.result(hub.child.exitCode); const running = yield* Ref.get(runningRef); if (running?.hub.child.pid !== hub.child.pid) return; const uptime = (yield* Clock.currentTimeMillis) - hub.startedAtMillis; const delay = yield* Ref.modify(restartDelayRef, (current) => { if (uptime >= HUB_RESTART_STABLE_UPTIME_MS) return [0, 0]; const next = current === 0 ? 1_000 : Math.min(current * 2, HUB_RESTART_MAX_DELAY_MS); return [current, next]; }); yield* Effect.logWarning("Device hub exited; restarting", { pid: Number(hub.child.pid), delayMs: delay, }); yield* Effect.sleep(Duration.millis(delay)); yield* startLock.withPermits(1)( Effect.gen(function* () { const current = yield* Ref.get(runningRef); if (current?.hub.child.pid !== hub.child.pid) return; const replacement = yield* spawnHub(hubTool); yield* Ref.set(runningRef, { ...current, hub: replacement }); yield* Effect.forkDetach(superviseHub(replacement, hubTool)); }), ); }).pipe( Effect.catchCause((cause) => Effect.logWarning("Device hub supervisor failed", { cause })), ); const daemonFilePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "daemon.json"); const readDaemonFile = Effect.fn("LocalDeviceHost.readDaemonFile")(function* () { const raw = yield* fs.readFileString(daemonFilePath()); return yield* decodeDaemonFile(raw); }); /** * agent-device auto-starts its daemon on any command. A trivial `devices` * call in HTTP mode is the documented way to bring it up; its output is the * daemon.json this reads back. */ const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* ( agentTool: DeviceToolPaths, ): Effect.fn.Return<DeviceHost.AgentDeviceEndpoint, DeviceHost.DeviceHostTimeoutError> { const stateDir = agentDeviceStateDir(path, config.stateDir); yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore); const existing = yield* readDaemonFile().pipe(Effect.option); const daemonEnvironment: NodeJS.ProcessEnv = { ...hostEnvironment, AGENT_DEVICE_STATE_DIR: stateDir, AGENT_DEVICE_DAEMON_SERVER_MODE: "http", // The daemon idles out after five minutes by default; the server owns // its lifetime here and stops it explicitly. AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: "0", AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1", FORCE_COLOR: "0", NO_COLOR: "1", }; const toEndpoint = ( file: typeof AgentDeviceDaemonFile.Type, ): DeviceHost.AgentDeviceEndpoint => ({ baseUrl: `http://127.0.0.1:${file.httpPort}`, token: file.token, entryPath: agentTool.entryPath, }); if (existing._tag === "Some") { const alive = yield* HttpClient.withScope(httpClient) .get(`http://127.0.0.1:${existing.value.httpPort}/health`) .pipe( Effect.timeout(Duration.seconds(2)), Effect.flatMap((response) => response.arrayBuffer.pipe(Effect.as(response.status === 200)), ), Effect.scoped, Effect.orElseSucceed(() => false), ); if (alive) return toEndpoint(existing.value); yield* fs.remove(daemonFilePath(), { force: true }).pipe(Effect.ignore); } // There is no `daemon start`; the first command in a state dir spawns the // daemon and blocks until it answers. `devices` is the cheapest one. yield* runner .run({ command: process.execPath, args: [agentTool.entryPath, "devices", "--json"], env: daemonEnvironment, timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS), timeoutBehavior: "timedOutResult", }) .pipe(Effect.ignore); const deadline = (yield* Clock.currentTimeMillis) + DAEMON_READY_TIMEOUT_MS; while (true) { const file = yield* readDaemonFile().pipe(Effect.option); if (file._tag === "Some") return toEndpoint(file.value); if ((yield* Clock.currentTimeMillis) > deadline) { return yield* new DeviceHost.DeviceHostTimeoutError({ hostId, timeoutMs: DAEMON_READY_TIMEOUT_MS, }); } yield* Effect.sleep(Duration.millis(DAEMON_POLL_MS)); } }); const stopAgentDeviceDaemon = (agentTool: DeviceToolPaths | null) => agentTool ? runner .run({Lines 1–500 of 698. Load more
/** * The device host that is this machine. * * Runs expo-device-hub as a supervised child on a loopback port and starts the * agent-device daemon in HTTP mode under a T3-owned state directory. Both are * lazy: the device service requires explicit setup consent before it calls * ensureReady to install tools or start helper processes. * * The hub runs in its standalone mode (origin root). The T3 proxy strips its * own prefix, and the Device panel derives stream and socket URLs from the * prefix itself rather than from anything the hub prints. */ import { type DeviceHostSummary, type DevicePlatform, type DevicePlatformAvailability, LOCAL_DEVICE_HOST_ID, } from "@t3tools/contracts"; import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as NetService from "@t3tools/shared/Net"; import { isCommandAvailable } from "@t3tools/shared/shell"; 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 FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as DeviceHost from "./DeviceHost.ts"; import { agentDeviceStateDir, type DeviceToolPaths, ensureAgentDevice, ensureDeviceHub, isAgentDeviceInstalled, isDeviceHubInstalled, } from "./DeviceToolchain.ts"; const HUB_READY_TIMEOUT_MS = 30_000; const DAEMON_READY_TIMEOUT_MS = 30_000; const DAEMON_POLL_MS = 100; const HUB_RESTART_STABLE_UPTIME_MS = 60_000; const HUB_RESTART_MAX_DELAY_MS = 30_000; /** * Written beside the agent-device state so a server that dies without running * its finalizers (SIGKILL, dev-runner restarts) does not leave a hub bound to * a loopback port forever. The next start reads it, kills only a process that * is still that hub, and replaces the file. */ const HubStateFile = Schema.Struct({ pid: Schema.Int, port: Schema.Int, entryPath: Schema.String, }); const decodeHubStateFile = Schema.decodeUnknownEffect(Schema.fromJsonString(HubStateFile)); const encodeHubStateFile = Schema.encodeUnknownEffect(Schema.fromJsonString(HubStateFile)); const AgentDeviceDaemonFile = Schema.Struct({ httpPort: Schema.Int, token: Schema.String, pid: Schema.optional(Schema.Int), }); const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); interface HubProcess { readonly child: ChildProcessSpawner.ChildProcessHandle; readonly scope: Scope.Closeable; readonly origin: string; readonly startedAtMillis: number; } interface RunningHost { readonly hub: HubProcess; readonly agentDevice: DeviceHost.AgentDeviceEndpoint | null; readonly helpers: DeviceHost.DeviceHostReady["helpers"]; } const platformReason = Effect.fn("LocalDeviceHost.platformReason")(function* ( platform: DevicePlatform, ): Effect.fn.Return<string | null, never, FileSystem.FileSystem | Path.Path> { const hostPlatform = yield* HostProcessPlatform; if (platform === "ios") { if (hostPlatform !== "darwin") return "iOS Simulators need macOS with Xcode."; if (!(yield* isCommandAvailable("xcrun"))) return "Xcode command line tools were not found."; return null; } const sdk = yield* androidSdk; if (!sdk.root) return "Android SDK was not found. Install it with Android Studio or set ANDROID_HOME to your SDK directory."; if (!sdk.adb) return `Android SDK Platform-Tools are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`; if (!sdk.emulator) return `Android Emulator is missing from ${sdk.root}. Install it in Android Studio's SDK Manager.`; if (!sdk.avdmanager) return `Android SDK Command-line Tools (latest) are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`; return null; }); /** Resolve the SDK once for both diagnostics and the environment passed to helpers. */ const androidSdk = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const environment = yield* HostProcessEnvironment; const platform = yield* HostProcessPlatform; const home = environment.HOME ?? environment.USERPROFILE ?? ""; const explicit = environment.ANDROID_HOME?.trim() || environment.ANDROID_SDK_ROOT?.trim(); const candidates = explicit ? [explicit] : [ path.join(home, "Library", "Android", "sdk"), path.join(home, "Android", "Sdk"), path.join( environment.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Android", "Sdk", ), ]; if (!explicit) { for (const directory of (environment.PATH ?? "").split(platform === "win32" ? ";" : ":")) { if (!directory) continue; const resolved = yield* fs .realPath(path.join(directory, platform === "win32" ? "adb.exe" : "adb")) .pipe(Effect.option); if (resolved._tag === "Some") candidates.push(path.dirname(path.dirname(resolved.value))); } } const exists = (file: string) => fs.exists(file).pipe(Effect.orElseSucceed(() => false)); for (const root of candidates) { const adb = yield* exists( path.join(root, "platform-tools", platform === "win32" ? "adb.exe" : "adb"), ); const emulator = yield* exists( path.join(root, "emulator", platform === "win32" ? "emulator.exe" : "emulator"), ); if (explicit || adb || emulator) { const avdmanager = yield* exists( path.join( root, "cmdline-tools", "latest", "bin", platform === "win32" ? "avdmanager.bat" : "avdmanager", ), ); return { root, adb, emulator, avdmanager }; } } return { root: null, adb: false, emulator: false, avdmanager: false }; }); const deviceHostEnvironment = ( environment: NodeJS.ProcessEnv, sdkRoot: string | null, hostPlatform: NodeJS.Platform, path: Path.Path, ): NodeJS.ProcessEnv => { return sdkRoot ? { ...environment, ANDROID_HOME: sdkRoot, PATH: [ path.join(sdkRoot, "platform-tools"), path.join(sdkRoot, "emulator"), environment.PATH ?? environment.Path ?? "", ].join(hostPlatform === "win32" ? ";" : ":"), } : environment; }; export const make = Effect.fn("LocalDeviceHost.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const config = yield* ServerConfig.ServerConfig; const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const net = yield* NetService.NetService; const runner = yield* ProcessRunner.ProcessRunner; const httpClient = yield* HttpClient.HttpClient; const environment = yield* HostProcessEnvironment; const hostPlatform = yield* HostProcessPlatform; const sdk = yield* androidSdk; const hostEnvironment = deviceHostEnvironment(environment, sdk.root, hostPlatform, path); const startLock = yield* Semaphore.make(1); const runningRef = yield* Ref.make<RunningHost | null>(null); const restartDelayRef = yield* Ref.make(0); const hostId = LOCAL_DEVICE_HOST_ID; const platformAvailability = Effect.fn("LocalDeviceHost.platformAvailability")(function* ( platform: DevicePlatform, ): Effect.fn.Return<DevicePlatformAvailability> { const reason = yield* platformReason(platform).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); return reason === null ? { platform, available: true } : { platform, available: false, reason }; }); const summary: Effect.Effect<DeviceHostSummary> = Effect.gen(function* () { const [platforms, hubInstalled, agentDeviceInstalled] = yield* Effect.all([ Effect.all([platformAvailability("ios"), platformAvailability("android")]), isDeviceHubInstalled(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ), isAgentDeviceInstalled(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ), ]); return { id: hostId, kind: "local", label: "This machine", platforms, hubInstalled, agentDeviceInstalled, }; }); const hubEnvironment = (): NodeJS.ProcessEnv => ({ ...hostEnvironment, FORCE_COLOR: "0", NO_COLOR: "1", }); const stopHub = (hub: HubProcess | undefined) => hub ? Scope.close(hub.scope, Exit.void).pipe(Effect.ignore) : Effect.void; const hubStatePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "hub.json"); const isProcessAlive = (pid: number) => Effect.sync(() => { try { process.kill(pid, 0); return true; } catch { return false; } }); /** * A hub left behind by a previous server is identified by pid plus the * command line's entry path, so a recycled pid belonging to something else * is never touched. */ const reapStaleHub = Effect.gen(function* () { const previous = yield* fs .readFileString(hubStatePath()) .pipe(Effect.flatMap(decodeHubStateFile), Effect.option); if (previous._tag === "None") return; const alive = yield* isProcessAlive(previous.value.pid); if (alive) { const commandLine = yield* runner .run({ command: "ps", args: ["-o", "command=", "-p", String(previous.value.pid)], timeout: Duration.seconds(5), timeoutBehavior: "timedOutResult", }) .pipe( Effect.map((result) => result.stdout), Effect.orElseSucceed(() => ""), ); if (commandLine.includes(previous.value.entryPath)) { yield* Effect.logWarning("Stopping a device hub left behind by a previous server", { pid: previous.value.pid, port: previous.value.port, }); yield* Effect.sync(() => { try { process.kill(previous.value.pid, "SIGTERM"); } catch { // Already gone. } }); } } yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); }).pipe(Effect.catchCause(() => Effect.void)); const recordHub = (hub: HubProcess, hubTool: DeviceToolPaths) => encodeHubStateFile({ pid: Number(hub.child.pid), port: Number(new URL(hub.origin).port), entryPath: hubTool.entryPath, }).pipe( Effect.flatMap((json) => fs.writeFileString(hubStatePath(), json)), Effect.ignore, ); const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* ( hubTool: DeviceToolPaths, ): Effect.fn.Return<HubProcess, DeviceHost.DeviceHostError> { yield* reapStaleHub; yield* fs .makeDirectory(agentDeviceStateDir(path, config.stateDir), { recursive: true }) .pipe(Effect.ignore); const port = yield* net.reserveLoopbackPort("127.0.0.1").pipe( Effect.mapError( (cause) => new DeviceHost.DeviceHostError({ hostId, step: "reserving a port for the device hub", cause, }), ), ); const origin = `http://127.0.0.1:${port}`; const scope = yield* Scope.make("sequential"); const child = yield* spawner .spawn( ChildProcess.make( process.execPath, [ hubTool.entryPath, "--port", String(port), "--host", "127.0.0.1", "--hide-sidebar", "--hide-boot-device", ], { detached: false, shell: false, stdout: "pipe", stderr: "pipe", env: hubEnvironment(), }, ), ) .pipe( Effect.provideService(Scope.Scope, scope), Effect.mapError( (cause) => new DeviceHost.DeviceHostError({ hostId, step: "starting the device hub", cause, }), ), ); const startedAtMillis = yield* Clock.currentTimeMillis; const hub: HubProcess = { child, scope, origin, startedAtMillis }; yield* Effect.forkIn(observeHubOutput(hub), scope); yield* waitForHttpReady({ baseUrl: origin, path: "/readyz", timeoutMs: HUB_READY_TIMEOUT_MS, makeError: (info) => new DeviceHost.DeviceHostError({ hostId, step: "waiting for the device hub to answer", cause: info.cause, }), }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.tapError(() => stopHub(hub)), ); yield* recordHub(hub, hubTool); yield* Effect.logInfo("Device hub started", { pid: Number(child.pid), port }); return hub; }); const observeHubOutput = (hub: HubProcess) => hub.child.all.pipe( Stream.decodeText(), Stream.splitLines, Stream.map((line) => line.trim()), Stream.filter((line) => line.length > 0), Stream.runForEach((line) => Effect.logDebug("Device hub output", { pid: Number(hub.child.pid), output: line }), ), Effect.catchCause(() => Effect.void), ); /** * Restart the hub when it dies under us, with the same doubling backoff the * relay connector uses so a hub that crashes on boot cannot spin. */ const superviseHub = (hub: HubProcess, hubTool: DeviceToolPaths): Effect.Effect<void> => Effect.gen(function* () { yield* Effect.result(hub.child.exitCode); const running = yield* Ref.get(runningRef); if (running?.hub.child.pid !== hub.child.pid) return; const uptime = (yield* Clock.currentTimeMillis) - hub.startedAtMillis; const delay = yield* Ref.modify(restartDelayRef, (current) => { if (uptime >= HUB_RESTART_STABLE_UPTIME_MS) return [0, 0]; const next = current === 0 ? 1_000 : Math.min(current * 2, HUB_RESTART_MAX_DELAY_MS); return [current, next]; }); yield* Effect.logWarning("Device hub exited; restarting", { pid: Number(hub.child.pid), delayMs: delay, }); yield* Effect.sleep(Duration.millis(delay)); yield* startLock.withPermits(1)( Effect.gen(function* () { const current = yield* Ref.get(runningRef); if (current?.hub.child.pid !== hub.child.pid) return; const replacement = yield* spawnHub(hubTool); yield* Ref.set(runningRef, { ...current, hub: replacement }); yield* Effect.forkDetach(superviseHub(replacement, hubTool)); }), ); }).pipe( Effect.catchCause((cause) => Effect.logWarning("Device hub supervisor failed", { cause })), ); const daemonFilePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "daemon.json"); const readDaemonFile = Effect.fn("LocalDeviceHost.readDaemonFile")(function* () { const raw = yield* fs.readFileString(daemonFilePath()); return yield* decodeDaemonFile(raw); }); /** * agent-device auto-starts its daemon on any command. A trivial `devices` * call in HTTP mode is the documented way to bring it up; its output is the * daemon.json this reads back. */ const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* ( agentTool: DeviceToolPaths, ): Effect.fn.Return<DeviceHost.AgentDeviceEndpoint, DeviceHost.DeviceHostTimeoutError> { const stateDir = agentDeviceStateDir(path, config.stateDir); yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore); const existing = yield* readDaemonFile().pipe(Effect.option); const daemonEnvironment: NodeJS.ProcessEnv = { ...hostEnvironment, AGENT_DEVICE_STATE_DIR: stateDir, AGENT_DEVICE_DAEMON_SERVER_MODE: "http", // The daemon idles out after five minutes by default; the server owns // its lifetime here and stops it explicitly. AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: "0", AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1", FORCE_COLOR: "0", NO_COLOR: "1", }; const toEndpoint = ( file: typeof AgentDeviceDaemonFile.Type, ): DeviceHost.AgentDeviceEndpoint => ({ baseUrl: `http://127.0.0.1:${file.httpPort}`, token: file.token, entryPath: agentTool.entryPath, }); if (existing._tag === "Some") { const alive = yield* HttpClient.withScope(httpClient) .get(`http://127.0.0.1:${existing.value.httpPort}/health`) .pipe( Effect.timeout(Duration.seconds(2)), Effect.flatMap((response) => response.arrayBuffer.pipe(Effect.as(response.status === 200)), ), Effect.scoped, Effect.orElseSucceed(() => false), ); if (alive) return toEndpoint(existing.value); yield* fs.remove(daemonFilePath(), { force: true }).pipe(Effect.ignore); } // There is no `daemon start`; the first command in a state dir spawns the // daemon and blocks until it answers. `devices` is the cheapest one. yield* runner .run({ command: process.execPath, args: [agentTool.entryPath, "devices", "--json"], env: daemonEnvironment, timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS), timeoutBehavior: "timedOutResult", }) .pipe(Effect.ignore); const deadline = (yield* Clock.currentTimeMillis) + DAEMON_READY_TIMEOUT_MS; while (true) { const file = yield* readDaemonFile().pipe(Effect.option); if (file._tag === "Some") return toEndpoint(file.value); if ((yield* Clock.currentTimeMillis) > deadline) { return yield* new DeviceHost.DeviceHostTimeoutError({ hostId, timeoutMs: DAEMON_READY_TIMEOUT_MS, }); } yield* Effect.sleep(Duration.millis(DAEMON_POLL_MS)); } }); const stopAgentDeviceDaemon = (agentTool: DeviceToolPaths | null) => agentTool ? runner .run({