Skip to content
Source
Sign inSign up

LocalDeviceHost.ts

903 lines33.5 KB

1import { pruneLocalDeviceTools } from "./deviceToolMaintenance.ts";
2import { deviceToolInstallMessage } from "@t3tools/contracts";
3// The hub runs at origin root; T3 strips its prefix and the Device panel derives stream URLs from it.
4import {
5 type DeviceHostSummary,
6 type DevicePlatform,
7 type DevicePlatformAvailability,
8 LOCAL_DEVICE_HOST_ID,
9 LOCAL_DEVICE_HOST_LABEL,
10} from "@t3tools/contracts";
11import { waitForHttpReady } from "@t3tools/shared/httpReadiness";
12import * as HostProcess from "@t3tools/shared/HostProcess";
13import {
14 resolveNodeExecutable,
15 type NodeRuntimeUnavailableError,
16} from "@t3tools/shared/nodeRuntime";
17import * as NetService from "@t3tools/shared/Net";
18import { isCommandAvailable } from "@t3tools/shared/shell";
19import * as Clock from "effect/Clock";
20import * as Duration from "effect/Duration";
21import * as Effect from "effect/Effect";
22import * as Exit from "effect/Exit";
23import * as FileSystem from "effect/FileSystem";
24import * as Layer from "effect/Layer";
25import * as Option from "effect/Option";
26import * as Path from "effect/Path";
27import * as Ref from "effect/Ref";
28import * as Schema from "effect/Schema";
29import * as Scope from "effect/Scope";
30import * as Semaphore from "effect/Semaphore";
31import * as Stream from "effect/Stream";
32import { HttpClient } from "effect/http";
33import * as ChildProcess from "effect/process/ChildProcess";
34import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
35​
36import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
37import * as ServerConfig from "../config.ts";
38import * as ProcessRunner from "../processRunner.ts";
39import { releaseAgentDeviceSession } from "./AgentDeviceTarget.ts";
40import * as DeviceHost from "./DeviceHost.ts";
41import { makeDeviceHubOperations } from "./DeviceHubOperations.ts";
42import {
43 agentDeviceStateDir,
44 type DeviceToolPaths,
45 ensureAgentDevice,
46 ensureDeviceHub,
47 isAgentDeviceInstalled,
48 isDeviceHubInstalled,
49 deviceToolVersions,
50 AGENT_DEVICE_VERSION,
51 DEVICE_HUB_VERSION,
52 installedAgentDeviceEntry,
53} from "./DeviceToolchain.ts";
54​
55const HUB_READY_TIMEOUT_MS = 30_000;
56const DAEMON_READY_TIMEOUT_MS = 30_000;
57const DAEMON_POLL_MS = 100;
58const HUB_RESTART_STABLE_UPTIME_MS = 60_000;
59const HUB_RESTART_MAX_DELAY_MS = 30_000;
60​
61// Persist hub state so restarts without finalizers can clean up orphaned loopback listeners.
62const HubStateFile = Schema.Struct({
63 pid: Schema.Int,
64 port: Schema.Int,
65 entryPath: Schema.String,
66});
67const decodeHubStateFile = Schema.decodeUnknownEffect(Schema.fromJsonString(HubStateFile));
68const encodeHubStateFile = Schema.encodeUnknownEffect(Schema.fromJsonString(HubStateFile));
69​
70const AgentDeviceDaemonFile = Schema.Struct({
71 httpPort: Schema.Int,
72 token: Schema.String,
73 pid: Schema.optional(Schema.Int),
74 version: Schema.optional(Schema.String),
75});
76const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile));
77​
78const decodeAgentDeviceFailure = Schema.decodeUnknownOption(
79 Schema.fromJsonString(
80 Schema.Struct({
81 error: Schema.Struct({
82 details: Schema.Struct({
83 reason: Schema.String,
84 inspection: Schema.optional(Schema.Struct({ reason: Schema.String })),
85 }),
86 }),
87 }),
88 ),
89);
90​
91interface HubProcess {
92 readonly nodePath: string;
93 readonly child: ChildProcessSpawner.ChildProcessHandle;
94 readonly scope: Scope.Closeable;
95 readonly origin: string;
96 readonly startedAtMillis: number;
98​
99interface RunningHost {
100 readonly hub: HubProcess;
101 readonly agentDevice: DeviceHost.AgentDeviceEndpoint | null;
102 readonly helpers: DeviceHost.DeviceHostReady["helpers"];
104​
105const platformReason = Effect.fn("LocalDeviceHost.platformReason")(function* (
106 platform: DevicePlatform,
107): Effect.fn.Return<string | null, never, FileSystem.FileSystem | Path.Path> {
108 const hostPlatform = yield* HostProcess.Platform;
109 if (platform === "ios") {
110 if (hostPlatform !== "darwin") return "iOS Simulators need macOS with Xcode.";
111 if (!(yield* isCommandAvailable("xcrun"))) return "Xcode command line tools were not found.";
112 return null;
113 }
114 const sdk = yield* androidSdk;
115 if (!sdk.root)
116 return "Android SDK was not found. Install it with Android Studio or set ANDROID_HOME to your SDK directory.";
117 if (!sdk.adb)
118 return `Android SDK Platform-Tools are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`;
119 if (!sdk.emulator)
120 return `Android Emulator is missing from ${sdk.root}. Install it in Android Studio's SDK Manager.`;
121 if (!sdk.avdmanager) {
122 if (sdk.legacyAvdmanager)
123 return `The Android SDK command-line tools in ${sdk.root} appear to be an older, unsupported version. Install Android SDK Command-line Tools (latest) in Android Studio's SDK Manager under SDK Tools.`;
124 return `Android SDK Command-line Tools (latest) are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`;
125 }
126 return null;
127});
128​
129const androidSdk = Effect.gen(function* () {
130 const fs = yield* FileSystem.FileSystem;
131 const path = yield* Path.Path;
132 const environment = yield* HostProcess.Environment;
133 const platform = yield* HostProcess.Platform;
134 const windows = platform === "win32";
135 const home = environment.HOME ?? environment.USERPROFILE ?? "";
136 const explicit = environment.ANDROID_HOME?.trim() || environment.ANDROID_SDK_ROOT?.trim();
137 const exists = (file: string) => fs.exists(file).pipe(Effect.orElseSucceed(() => false));
138 const adbBinary = windows ? "adb.exe" : "adb";
139 const avdmanagerBinary = windows ? "avdmanager.bat" : "avdmanager";
140 const inspect = Effect.fnUntraced(function* (root: string) {
141 const adb = yield* exists(path.join(root, "platform-tools", adbBinary));
142 const emulator = yield* exists(
143 path.join(root, "emulator", windows ? "emulator.exe" : "emulator"),
144 );
145 if (!explicit && !adb && !emulator) return null;
146 const avdmanager = yield* exists(
147 path.join(root, "cmdline-tools", "latest", "bin", avdmanagerBinary),
148 );
149 const legacyAvdmanager =
150 !avdmanager && (yield* exists(path.join(root, "tools", "bin", avdmanagerBinary)));
151 return { root, adb, emulator, avdmanager, legacyAvdmanager };
152 });
153 const candidates = explicit
154 ? [explicit]
155 : [
156 path.join(home, "Library", "Android", "sdk"),
157 path.join(home, "Android", "Sdk"),
158 path.join(
159 environment.LOCALAPPDATA ?? path.join(home, "AppData", "Local"),
160 "Android",
161 "Sdk",
162 ),
163 ];
164 for (const root of candidates) {
165 const sdk = yield* inspect(root);
166 if (sdk) return sdk;
167 }
168 // PATH entries can be slow to stat (network shares), so only consult them after the standard locations miss.
169 if (!explicit) {
170 const searchPath = environment.PATH ?? environment.Path ?? "";
171 for (const directory of searchPath.split(windows ? ";" : ":")) {
172 if (!directory) continue;
173 const adb = path.join(directory, adbBinary);
174 if (!(yield* exists(adb))) continue;
175 const resolved = yield* fs.realPath(adb).pipe(Effect.orElseSucceed(() => adb));
176 const sdk = yield* inspect(path.dirname(path.dirname(resolved)));
177 if (sdk) return sdk;
178 }
179 }
180 return { root: null, adb: false, emulator: false, avdmanager: false, legacyAvdmanager: false };
181});
182​
183const deviceHostEnvironment = (
184 environment: NodeJS.ProcessEnv,
185 sdkRoot: string | null,
186 hostPlatform: NodeJS.Platform,
187 path: Path.Path,
188): NodeJS.ProcessEnv => {
189 return sdkRoot
190 ? {
191 ...environment,
192 ANDROID_HOME: sdkRoot,
193 PATH: [
194 path.join(sdkRoot, "platform-tools"),
195 path.join(sdkRoot, "emulator"),
196 environment.PATH ?? environment.Path ?? "",
197 ].join(hostPlatform === "win32" ? ";" : ":"),
198 }
199 : environment;
200};
201​
202const hubEnvironment = Effect.fn("LocalDeviceHost.hubEnvironment")(function* (
203 environment: NodeJS.ProcessEnv,
204) {
205 const env: NodeJS.ProcessEnv = { ...environment, FORCE_COLOR: "0", NO_COLOR: "1" };
206 const platform = yield* HostProcess.Platform;
207 const uid = yield* HostProcess.UserId;
208 if (env.ANDROID_HOME) {
209 const path = yield* Path.Path;
210 const runner = yield* ProcessRunner.ProcessRunner;
211 const emulator = path.join(
212 env.ANDROID_HOME,
213 "emulator",
214 platform === "win32" ? "emulator.exe" : "emulator",
215 );
216 const cameraArgs = yield* Effect.forEach(["front", "back"], (facing) =>
217 runner
218 .run({
219 command: emulator,
220 args: [`-help-camera-${facing}`],
221 env,
222 timeout: Duration.seconds(5),
223 })
224 .pipe(
225 Effect.map((result) => {
226 const help = `${result.stdout}\n${result.stderr}`;
227 // The hub wires imagefile cameras unconditionally; older SDK emulators reject that mode.
228 return result.code === 0 && /\bemulated\b/.test(help) && !/\bimagefile\b/.test(help)
229 ? `-camera-${facing} emulated`
230 : "";
231 }),
232 Effect.orElseSucceed(() => ""),
233 ),
234 );
235 const overrides = cameraArgs.filter(Boolean).join(" ");
236 if (overrides) {
237 env.EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS = [overrides, env.EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS]
238 .filter(Boolean)
239 .join(" ");
240 }
241 }
242 if (platform === "linux" && env.XDG_RUNTIME_DIR === undefined && uid !== undefined) {
243 // SSH sessions may omit the directory where the emulator publishes its gRPC token.
244 const runtimeDir = `/run/user/${uid}`;
245 const fs = yield* FileSystem.FileSystem;
246 const stat = yield* fs.stat(runtimeDir).pipe(Effect.option);
247 if (
248 stat._tag === "Some" &&
249 stat.value.type === "Directory" &&
250 Option.contains(stat.value.uid, uid)
251 ) {
252 env.XDG_RUNTIME_DIR = runtimeDir;
253 }
254 }
255 return env;
256});
257​
258/** Only a Mac can list Apple simulators; elsewhere the hub's attempt surfaces as an error. */
259const hubPlatformArguments = (hostPlatform: NodeJS.Platform) =>
260 hostPlatform === "darwin" ? [] : ["--platform", "android"];
261​
262export const make = Effect.fn("LocalDeviceHost.make")(function* () {
263 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
264 const config = yield* ServerConfig.ServerConfig;
265 const path = yield* Path.Path;
266 const fs = yield* FileSystem.FileSystem;
267 const net = yield* NetService.NetService;
268 const runner = yield* ProcessRunner.ProcessRunner;
269 const httpClient = yield* HttpClient.HttpClient;
270 const environment = yield* HostProcess.Environment;
271 const workingDirectory = yield* HostProcess.WorkingDirectory;
272 const hostPlatform = yield* HostProcess.Platform;
273 const sdk = yield* androidSdk;
274 const hostEnvironment = deviceHostEnvironment(environment, sdk.root, hostPlatform, path);
275 const startLock = yield* Semaphore.make(1);
276 const runningRef = yield* Ref.make<RunningHost | null>(null);
277 const restartDelayRef = yield* Ref.make(0);
278 const hostId = LOCAL_DEVICE_HOST_ID;
279​
280 const platformAvailability = Effect.fn("LocalDeviceHost.platformAvailability")(function* (
281 platform: DevicePlatform,
282 ): Effect.fn.Return<DevicePlatformAvailability> {
283 const reason = yield* platformReason(platform).pipe(
284 Effect.provideService(FileSystem.FileSystem, fs),
285 Effect.provideService(Path.Path, path),
286 );
287 return reason === null ? { platform, available: true } : { platform, available: false, reason };
288 });
289​
290 const summary: Effect.Effect<DeviceHostSummary> = Effect.gen(function* () {
291 const [platforms, hubInstalled, agentDeviceInstalled] = yield* Effect.all([
292 Effect.all([platformAvailability("ios"), platformAvailability("android")]),
293 isDeviceHubInstalled(config.baseDir).pipe(
294 Effect.provideService(FileSystem.FileSystem, fs),
295 Effect.provideService(Path.Path, path),
296 ),
297 isAgentDeviceInstalled(config.baseDir).pipe(
298 Effect.provideService(FileSystem.FileSystem, fs),
299 Effect.provideService(Path.Path, path),
300 ),
301 ]);
302 const running = yield* Ref.get(runningRef);
303 const daemon = running?.agentDevice
304 ? yield* readDaemonFile().pipe(Effect.option)
305 : Option.none();
306 const hubAlive = running
307 ? yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false))
308 : false;
309 const agentAlive =
310 Option.isSome(daemon) && daemon.value.pid ? yield* isProcessAlive(daemon.value.pid) : false;
311 const tools = yield* deviceToolVersions(config.baseDir, {
312 ...(hubAlive ? { hub: DEVICE_HUB_VERSION } : {}),
313 ...(agentAlive && Option.isSome(daemon) && daemon.value.version
314 ? { agent: daemon.value.version }
315 : {}),
316 }).pipe(
317 Effect.provideService(FileSystem.FileSystem, fs),
318 Effect.provideService(Path.Path, path),
319 );
320 return {
321 tools,
322 id: hostId,
323 kind: "local",
324 label: LOCAL_DEVICE_HOST_LABEL,
325 platforms,
326 hubInstalled,
327 agentDeviceInstalled,
328 };
329 });
330​
331 const stopHub = (hub: HubProcess | undefined) =>
332 hub ? Scope.close(hub.scope, Exit.void).pipe(Effect.ignore) : Effect.void;
333​
334 const hubStatePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "hub.json");
335​
336 const isProcessAlive = (pid: number) =>
337 Effect.sync(() => {
338 try {
339 process.kill(pid, 0);
340 return true;
341 } catch {
342 return false;
343 }
344 });
345​
346 // Check PID and entry path before reaping; the PID may have been recycled.
347 const reapStaleHub = Effect.gen(function* () {
348 const previous = yield* fs
349 .readFileString(hubStatePath())
350 .pipe(Effect.flatMap(decodeHubStateFile), Effect.option);
351 if (previous._tag === "None") return;
352 const alive = yield* isProcessAlive(previous.value.pid);
353 if (alive) {
354 const commandLine = yield* runner
355 .run({
356 command: "ps",
357 args: ["-o", "command=", "-p", String(previous.value.pid)],
358 timeout: Duration.seconds(5),
359 timeoutBehavior: "timedOutResult",
360 })
361 .pipe(
362 Effect.map((result) => result.stdout),
363 Effect.orElseSucceed(() => ""),
364 );
365 if (commandLine.includes(previous.value.entryPath)) {
366 yield* Effect.logWarning("Stopping a device hub left behind by a previous server", {
367 pid: previous.value.pid,
368 port: previous.value.port,
369 });
370 yield* Effect.sync(() => {
371 try {
372 process.kill(previous.value.pid, "SIGTERM");
373 } catch {}
374 });
375 }
376 }
377 yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore);
378 }).pipe(Effect.ignoreCause);
379​
380 const recordHub = (hub: HubProcess, hubTool: DeviceToolPaths) =>
381 encodeHubStateFile({
382 pid: Number(hub.child.pid),
383 port: Number(new URL(hub.origin).port),
384 entryPath: hubTool.entryPath,
385 }).pipe(
386 Effect.flatMap((contents) =>
387 writeFileStringAtomically({ filePath: hubStatePath(), contents }),
388 ),
389 Effect.provideService(FileSystem.FileSystem, fs),
390 Effect.provideService(Path.Path, path),
391 Effect.ignore,
392 );
393​
394 const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* (
395 hubTool: DeviceToolPaths,
396 nodePath: string,
397 ): Effect.fn.Return<HubProcess, DeviceHost.DeviceHostError> {
398 yield* reapStaleHub;
399 yield* fs
400 .makeDirectory(agentDeviceStateDir(path, config.stateDir), { recursive: true })
401 .pipe(Effect.ignore);
402 const port = yield* net.reserveLoopbackPort("127.0.0.1").pipe(
403 Effect.mapError(
404 (cause) =>
405 new DeviceHost.DeviceHostError({
406 hostId,
407 step: "reserving a port for the device hub",
408 cause,
409 }),
410 ),
411 );
412 const origin = `http://127.0.0.1:${port}`;
413 const scope = yield* Scope.make("sequential");
414 // The hub is not recorded yet; stop it on failure or interruption from spawn onward.
415 return yield* Effect.gen(function* () {
416 const child = yield* spawner
417 .spawn(
418 ChildProcess.make(
419 nodePath,
420 [
421 hubTool.entryPath,
422 "--port",
423 String(port),
424 "--host",
425 "127.0.0.1",
426 "--hide-sidebar",
427 "--hide-boot-device",
428 ...hubPlatformArguments(hostPlatform),
429 ],
430 {
431 cwd: workingDirectory,
432 detached: false,
433 shell: false,
434 stdout: "pipe",
435 stderr: "pipe",
436 env: yield* hubEnvironment(hostEnvironment).pipe(
437 Effect.provideService(FileSystem.FileSystem, fs),
438 Effect.provideService(Path.Path, path),
439 Effect.provideService(ProcessRunner.ProcessRunner, runner),
440 Effect.provideService(HostProcess.Platform, hostPlatform),
441 ),
442 },
443 ),
444 )
445 .pipe(
446 Effect.provideService(Scope.Scope, scope),
447 Effect.mapError(
448 (cause) =>
449 new DeviceHost.DeviceHostError({
450 hostId,
451 step: "starting the device hub",
452 cause,
453 }),
454 ),
455 );
456 const startedAtMillis = yield* Clock.currentTimeMillis;
457 const hub: HubProcess = { child, scope, origin, startedAtMillis, nodePath };
458 yield* Effect.forkIn(observeHubOutput(hub), scope);
459 yield* waitForHttpReady({
460 baseUrl: origin,
461 path: "/readyz",
462 timeoutMs: HUB_READY_TIMEOUT_MS,
463 makeError: (info) =>
464 new DeviceHost.DeviceHostError({
465 hostId,
466 step: "waiting for the device hub to answer",
467 cause: info.cause,
468 }),
469 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
470 yield* recordHub(hub, hubTool);
471 yield* Effect.logInfo("Device hub started", { pid: Number(child.pid), port });
472 return hub;
473 }).pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)));
474 });
475​
476 const observeHubOutput = (hub: HubProcess) =>
477 hub.child.all.pipe(
478 Stream.decodeText(),
479 Stream.splitLines,
480 Stream.map((line) => line.trim()),
481 Stream.filter((line) => line.length > 0),
482 Stream.runForEach((line) =>
483 Effect.logDebug("Device hub output", { pid: Number(hub.child.pid), output: line }),
484 ),
485 Effect.ignoreCause,
486 );
487​
488 // Waits for the hub's lifetime and re-forks on restart; a span would nest across restarts.
489 const superviseHub: (hub: HubProcess, hubTool: DeviceToolPaths) => Effect.Effect<void> =
490 Effect.fnUntraced(
491 function* (hub: HubProcess, hubTool: DeviceToolPaths) {
492 yield* Effect.result(hub.child.exitCode);
493 const running = yield* Ref.get(runningRef);
494 if (running?.hub.child.pid !== hub.child.pid) return;
495 const uptime = (yield* Clock.currentTimeMillis) - hub.startedAtMillis;
496 const delay = yield* Ref.modify(restartDelayRef, (current) => {
497 if (uptime >= HUB_RESTART_STABLE_UPTIME_MS) return [0, 0];
498 const next = current === 0 ? 1_000 : Math.min(current * 2, HUB_RESTART_MAX_DELAY_MS);
499 return [current, next];
500 });
501 yield* Effect.logWarning("Device hub exited; restarting", {
502 pid: Number(hub.child.pid),
503 delayMs: delay,
504 });
505 yield* Effect.sleep(Duration.millis(delay));
506 yield* startLock.withPermits(1)(
507 Effect.gen(function* () {
508 const current = yield* Ref.get(runningRef);
509 if (current?.hub.child.pid !== hub.child.pid) return;
510 const replacement = yield* spawnHub(hubTool, hub.nodePath);
511 yield* Ref.set(runningRef, { ...current, hub: replacement });
512 yield* Effect.forkDetach(superviseHub(replacement, hubTool));
513 }),
514 );
515 },
516 Effect.catchCause((cause) => Effect.logWarning("Device hub supervisor failed", { cause })),
517 );
518​
519 const daemonFilePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "daemon.json");
520​
521 const readDaemonFile = Effect.fn("LocalDeviceHost.readDaemonFile")(function* () {
522 const raw = yield* fs.readFileString(daemonFilePath());
523 return yield* decodeDaemonFile(raw);
524 });
525​
526 const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* (
527 agentTool: Pick<DeviceToolPaths, "entryPath">,
528 nodePath: string,
529 ): Effect.fn.Return<
530 DeviceHost.AgentDeviceEndpoint,
531 | DeviceHost.DeviceHostError
532 | DeviceHost.DeviceHostTimeoutError
533 | DeviceHost.AgentDeviceStartError
534 > {
535 const stateDir = agentDeviceStateDir(path, config.stateDir);
536 yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore);
537 const existing = yield* readDaemonFile().pipe(Effect.option);
538 const daemonEnvironment: NodeJS.ProcessEnv = {
539 ...hostEnvironment,
540 AGENT_DEVICE_STATE_DIR: stateDir,
541 AGENT_DEVICE_DAEMON_SERVER_MODE: "http",
542 // Disable the five-minute idle timeout; the server owns shutdown.
543 AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: "0",
544 AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1",
545 FORCE_COLOR: "0",
546 NO_COLOR: "1",
547 };
548 const toEndpoint = (
549 file: typeof AgentDeviceDaemonFile.Type,
550 ): DeviceHost.AgentDeviceEndpoint => ({
551 baseUrl: `http://127.0.0.1:${file.httpPort}`,
552 token: file.token,
553 entryPath: agentTool.entryPath,
554 });
555 if (existing._tag === "Some") {
556 const alive = yield* HttpClient.withScope(httpClient)
557 .get(`http://127.0.0.1:${existing.value.httpPort}/health`)
558 .pipe(
559 Effect.timeout(Duration.seconds(2)),
560 Effect.flatMap((response) =>
561 response.arrayBuffer.pipe(Effect.as(response.status === 200)),
562 ),
563 Effect.scoped,
564 Effect.orElseSucceed(() => false),
565 );
566 if (alive) return toEndpoint(existing.value);
567 yield* fs.remove(daemonFilePath(), { force: true }).pipe(Effect.ignore);
568 }
569 // No `daemon start` exists; `devices` is the cheapest command that starts and awaits it.
570 const runResult = yield* runner
571 .run({
572 command: nodePath,
573 args: [agentTool.entryPath, "devices", "--json"],
574 env: daemonEnvironment,
575 timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS),
576 timeoutBehavior: "timedOutResult",
577 })
578 .pipe(Effect.result);
579 // Discovery may fail after startup; accept a published endpoint or preserve the CLI failure.
580 const started = yield* readDaemonFile().pipe(Effect.option);
581 if (started._tag === "Some") return toEndpoint(started.value);
582 if (runResult._tag === "Failure") {
583 return yield* new DeviceHost.DeviceHostError({
584 hostId,
585 step: "starting agent tools",
586 cause: runResult.failure,
587 });
588 }
589 const result = runResult.success;
590 if (result.timedOut) {
591 return yield* new DeviceHost.DeviceHostTimeoutError({
592 hostId,
593 timeoutMs: DAEMON_READY_TIMEOUT_MS,
594 });
595 }
596 if (result.code !== 0) {
597 const failure = decodeAgentDeviceFailure(result.stdout);
598 const details = Option.isSome(failure) ? failure.value.error.details : undefined;
599 const reason =
600 details?.reason === "daemon_registration_unproven"
601 ? details.inspection?.reason === "non-directory"
602 ? "daemon_lock_not_directory"
603 : "daemon_registration_unproven"
604 : details?.reason === "daemon_retirement_unconfirmed"
605 ? "daemon_retirement_unconfirmed"
606 : "command_failed";
607 return yield* new DeviceHost.AgentDeviceStartError({
608 hostId,
609 stateDir,
610 exitCode: result.code === null ? null : Number(result.code),
611 reason,
612 cause: result,
613 });
614 }
615 const deadline = (yield* Clock.currentTimeMillis) + DAEMON_READY_TIMEOUT_MS;
616 while (true) {
617 const file = yield* readDaemonFile().pipe(Effect.option);
618 if (file._tag === "Some") return toEndpoint(file.value);
619 if ((yield* Clock.currentTimeMillis) > deadline) {
620 return yield* new DeviceHost.DeviceHostTimeoutError({
621 hostId,
622 timeoutMs: DAEMON_READY_TIMEOUT_MS,
623 });
624 }
625 yield* Effect.sleep(Duration.millis(DAEMON_POLL_MS));
626 }
627 });
628​
629 // The daemon may predate this process (an earlier server was killed, possibly on an
630 // older pin), so stop it with the CLI of its recorded version when that is installed.
631 const stopAgentDeviceDaemon = Effect.gen(function* () {
632 if (!(yield* fs.exists(daemonFilePath()))) return;
633 const recorded = yield* readDaemonFile().pipe(Effect.option);
634 if (
635 Option.isSome(recorded) &&
636 recorded.value.pid &&
637 !(yield* isProcessAlive(recorded.value.pid))
638 ) {
639 return yield* fs.remove(daemonFilePath(), { force: true });
640 }
641 const entryPath = yield* installedAgentDeviceEntry(config.baseDir, [
642 ...(Option.isSome(recorded) && recorded.value.version ? [recorded.value.version] : []),
643 AGENT_DEVICE_VERSION,
644 ]);
645 if (!entryPath) return;
646 const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe(
647 Effect.provideService(HostProcess.Platform, hostPlatform),
648 );
649 yield* runner.run({
650 command: nodePath,
651 args: [
652 entryPath,
653 "daemon",
654 "stop",
655 "--state-dir",
656 agentDeviceStateDir(path, config.stateDir),
657 ],
658 env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" },
659 timeout: Duration.seconds(10),
660 timeoutBehavior: "timedOutResult",
661 });
662 }).pipe(
663 Effect.provideService(FileSystem.FileSystem, fs),
664 Effect.provideService(Path.Path, path),
665 Effect.ignore,
666 );
667​
668 const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* (
669 onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect<void>,
670 ): Effect.fn.Return<RunningHost, DeviceHost.DeviceHostError | NodeRuntimeUnavailableError> {
671 const running = yield* Ref.get(runningRef);
672 // A dead hub's record stays until a replacement is published: the agent
673 // daemon outlives the hub, and close needs its endpoint if the restart fails.
674 if (running) {
675 const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false));
676 if (alive) return running;
677 }
678 const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe(
679 Effect.provideService(FileSystem.FileSystem, fs),
680 Effect.provideService(Path.Path, path),
681 Effect.provideService(HostProcess.Platform, hostPlatform),
682 );
683 const installed = yield* isDeviceHubInstalled(config.baseDir).pipe(
684 Effect.provideService(FileSystem.FileSystem, fs),
685 Effect.provideService(Path.Path, path),
686 );
687 if (!installed) {
688 const inventory = yield* summary;
689 yield* onPhase("installing", deviceToolInstallMessage("device hub", inventory.tools?.hub));
690 }
691 const hubTool = yield* ensureDeviceHub(config.baseDir).pipe(
692 Effect.provideService(FileSystem.FileSystem, fs),
693 Effect.provideService(Path.Path, path),
694 Effect.provideService(ProcessRunner.ProcessRunner, runner),
695 Effect.mapError(
696 (cause) =>
697 new DeviceHost.DeviceHostError({
698 hostId,
699 step: "installing device support",
700 cause,
701 }),
702 ),
703 );
704 yield* onPhase("starting");
705 const hub = yield* spawnHub(hubTool, nodePath);
706 const next = yield* Effect.gen(function* () {
707 yield* pruneLocalDeviceTools(config.baseDir, nodePath, "hub").pipe(
708 Effect.provideService(Path.Path, path),
709 Effect.provideService(ProcessRunner.ProcessRunner, runner),
710 Effect.ignore,
711 );
712 const candidate = helperPaths(hubTool);
713 const [axExists, cliExists] = yield* Effect.all([
714 fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)),
715 fs.exists(candidate.serveSimCli).pipe(Effect.orElseSucceed(() => false)),
716 ]);
717 const next: RunningHost = {
718 hub,
719 agentDevice: running?.agentDevice ?? null,
720 helpers: {
721 serveSimAxSettings: axExists ? candidate.serveSimAxSettings : null,
722 serveSimCli: cliExists ? candidate.serveSimCli : null,
723 },
724 };
725 yield* Ref.set(runningRef, next);
726 return next;
727 }).pipe(Effect.onError(() => stopHub(hub)));
728 yield* Ref.set(restartDelayRef, 0);
729 yield* Effect.forkDetach(superviseHub(hub, hubTool));
730 return next;
731 });
732​
733 const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = (onPhase) =>
734 startLock.withPermits(1)(ensureHubReady(onPhase).pipe(Effect.map(toReady)));
735​
736 const ensureAgentReady: DeviceHost.DeviceHost["Service"]["ensureAgentReady"] = (onPhase) =>
737 startLock.withPermits(1)(
738 Effect.gen(function* (): Generator<
739 Effect.Effect<
740 unknown,
741 | DeviceHost.DeviceHostError
742 | DeviceHost.DeviceHostTimeoutError
743 | DeviceHost.AgentDeviceStartError
744 | NodeRuntimeUnavailableError
745 >,
746 DeviceHost.DeviceHostAgentReady
747 > {
748 const running = yield* ensureHubReady(onPhase);
749 if (running.agentDevice) {
750 const agentDevice = yield* startAgentDeviceDaemon(
751 running.agentDevice,
752 running.hub.nodePath,
753 );
754 yield* Ref.set(runningRef, { ...running, agentDevice });
755 return { ...toReady(running), agentDevice };
756 }
757 const installed = yield* isAgentDeviceInstalled(config.baseDir).pipe(
758 Effect.provideService(FileSystem.FileSystem, fs),
759 Effect.provideService(Path.Path, path),
760 );
761 if (!installed) {
762 const inventory = yield* summary;
763 yield* onPhase(
764 "installing",
765 deviceToolInstallMessage("agent tools", inventory.tools?.agent),
766 );
767 }
768 const agentTool = yield* ensureAgentDevice(config.baseDir).pipe(
769 Effect.provideService(FileSystem.FileSystem, fs),
770 Effect.provideService(Path.Path, path),
771 Effect.provideService(ProcessRunner.ProcessRunner, runner),
772 Effect.mapError(
773 (cause) =>
774 new DeviceHost.DeviceHostError({
775 hostId,
776 step: "installing agent tools",
777 cause,
778 }),
779 ),
780 );
781 yield* onPhase("starting");
782 const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath);
783 yield* pruneLocalDeviceTools(config.baseDir, running.hub.nodePath, "agent").pipe(
784 Effect.provideService(Path.Path, path),
785 Effect.provideService(ProcessRunner.ProcessRunner, runner),
786 Effect.ignore,
787 );
788 const next = { ...running, agentDevice };
789 yield* Ref.set(runningRef, next);
790 return { ...toReady(next), agentDevice };
791 }),
792 );
793​
794 const helperPaths = (hubTool: DeviceToolPaths) => {
795 const serveSimDist = path.join(
796 hubTool.installDir,
797 "node_modules",
798 "expo-device-hub",
799 "vendor",
800 "serve-sim",
801 "dist",
802 );
803 return {
804 serveSimAxSettings: path.join(serveSimDist, "simax", "serve-sim-ax-settings"),
805 serveSimCli: path.join(serveSimDist, "serve-sim.js"),
806 };
807 };
808​
809 const run: DeviceHost.DeviceHostReady["run"] = (command, args, options) =>
810 runner
811 .run({
812 command:
813 command === "emulator" && sdk.root
814 ? path.join(
815 sdk.root,
816 "emulator",
817 hostPlatform === "win32" ? "emulator.exe" : "emulator",
818 )
819 : command,
820 args,
821 cwd: workingDirectory,
822 env: hostEnvironment,
823 timeout: Duration.millis(options?.timeoutMs ?? 20_000),
824 timeoutBehavior: "timedOutResult",
825 ...(options?.stdin === undefined ? {} : { stdin: options.stdin }),
826 ...(options?.maxOutputBytes === undefined
827 ? {}
828 : { maxOutputBytes: options.maxOutputBytes }),
829 })
830 .pipe(
831 Effect.map((result) => ({
832 stdout: result.stdout,
833 stderr: result.stderr,
834 code: result.timedOut ? 124 : Number(result.code ?? 127),
835 })),
836 Effect.catch((cause) => Effect.succeed({ stdout: "", stderr: String(cause), code: 127 })),
837 );
838​
839 const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({
840 hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint,
841 devices: makeDeviceHubOperations({
842 hostId,
843 origin: running.hub.origin,
844 http: httpClient,
845 run,
846 platformAvailable: (platform) =>
847 platformAvailability(platform).pipe(Effect.map((availability) => availability.available)),
848 }),
849 nodePath: running.hub.nodePath,
850 run,
851 helpers: running.helpers,
852 ...(running.agentDevice
853 ? { releaseAgentSession: releaseAgentDeviceSession(httpClient, running.agentDevice) }
854 : {}),
855 });
856​
857 const current: DeviceHost.DeviceHost["Service"]["current"] = Ref.get(runningRef).pipe(
858 Effect.map((running) => (running ? toReady(running) : null)),
859 );
860​
861 const stopAgent: DeviceHost.DeviceHost["Service"]["stopAgent"] = startLock.withPermits(1)(
862 Effect.gen(function* () {
863 yield* stopAgentDeviceDaemon;
864 yield* Ref.update(runningRef, (running) =>
865 running ? { ...running, agentDevice: null } : running,
866 );
867 }),
868 );
869​
870 const stop: DeviceHost.DeviceHost["Service"]["stop"] = startLock.withPermits(1)(
871 Effect.gen(function* () {
872 const running = yield* Ref.getAndSet(runningRef, null);
873 yield* stopHub(running?.hub);
874 yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore);
875 yield* stopAgentDeviceDaemon;
876 }),
877 );
878​
879 yield* Effect.addFinalizer(() => stop);
880​
881 const host: DeviceHost.DeviceHost["Service"] = {
882 id: hostId,
883 label: LOCAL_DEVICE_HOST_LABEL,
884 summary,
885 platformAvailability,
886 ensureReady,
887 ensureAgentReady,
888 current,
889 stopAgent,
890 stop,
891 };
892 return host;
893});
894​
895export const layer = Layer.effect(DeviceHost.DeviceHost, make());
896​
897export const __testing = {
898 AgentDeviceDaemonFile,
899 androidSdk,
900 platformReason,
901 deviceHostEnvironment,
902 hubEnvironment,
903};