diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index 250e6cf14f3be..eb299f9e29cdd 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -209,6 +209,19 @@ export const isDeviceHubInstalled = (baseDir: string) => export const isAgentDeviceInstalled = (baseDir: string) => isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice); +/** Entry of the first installed agent-device among `versions`; never installs. */ +export const installedAgentDeviceEntry = Effect.fn("DeviceToolchain.installedAgentDeviceEntry")( + function* (baseDir: string, versions: ReadonlyArray) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const version of versions) { + const paths = toolPaths(path, baseDir, { ...AGENT_DEVICE_SPEC, version }); + if (yield* isInstalled(fs, paths, version)) return paths.entryPath; + } + return null; + }, +); + export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function* ( baseDir: string, running: { hub?: string; agent?: string } = {}, diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 6b86a4d032610..e133b4f9db36c 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -40,6 +40,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( stateDir: string, ) => Effect.Effect, avdOutput: ProcessRunner.ProcessRunOutput = processOutput(0), + previousDaemon?: typeof LocalDeviceHost.__testing.AgentDeviceDaemonFile.Type, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -47,6 +48,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( const hub = { running: true, spawnFails: false }; const stateDir = agentDeviceStateDir(path, path.join(baseDir, "userdata")); const daemonHealthy = yield* Ref.make(true); + const runs: Array = []; for (const [name, version, entry] of [ ["expo-device-hub", DEVICE_HUB_VERSION, "dist/server/cli.mjs"], ["agent-device", AGENT_DEVICE_VERSION, "bin/agent-device.mjs"], @@ -57,6 +59,10 @@ const agentFixture = Effect.fn("agentFixture")(function* ( yield* fs.writeFileString(entryPath, ""); yield* fs.writeFileString(path.join(installDir, ".install-complete"), version!); } + if (previousDaemon) { + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.writeFileString(path.join(stateDir, "daemon.json"), JSON.stringify(previousDaemon)); + } const host = yield* LocalDeviceHost.make().pipe( Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)), Effect.provideService(HostProcess.Environment, { HOME: baseDir, PATH: "" }), @@ -92,10 +98,12 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ), Effect.provideService(ProcessRunner.ProcessRunner, { - run: (input) => - input.args.includes("devices") + run: (input) => { + runs.push(input); + return input.args.includes("devices") ? startup(stateDir) - : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)), + : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)); + }, }), Effect.provideService( HttpClient.HttpClient, @@ -114,7 +122,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ); yield* host.ensureReady(() => Effect.void); - return { host, fs, stateDir, hub, daemonHealthy }; + return { host, fs, baseDir, stateDir, hub, daemonHealthy, runs }; }); describe("AgentDevice startup", () => { @@ -276,6 +284,75 @@ describe("AgentDevice startup", () => { expect((yield* host.current)?.releaseAgentSession).toBeTypeOf("function"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("stops a previous server's daemon with the agent-device version it recorded", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { host, fs, baseDir, runs } = yield* agentFixture( + () => Effect.die("A session that never readies agent tools must not start the daemon"), + processOutput(0), + { httpPort: 12345, token: "previous-token", pid: process.pid, version: "0.0.1" }, + ); + const olderInstall = path.join(baseDir, "tools", "agent-device", "0.0.1"); + const olderEntry = path.join( + olderInstall, + "node_modules", + "agent-device", + "bin", + "agent-device.mjs", + ); + yield* fs.makeDirectory(path.dirname(olderEntry), { recursive: true }); + yield* fs.writeFileString(olderEntry, ""); + yield* fs.writeFileString(path.join(olderInstall, ".install-complete"), "0.0.1"); + yield* host.stop; + expect(runs.find((run) => run.args.includes("daemon"))?.args[0]).toBe(olderEntry); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + // regression: #33552 a daemon left by a previous server survived every later graceful quit + it.effect("stops a previous server's daemon with the current pin when its version is gone", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { host, baseDir, stateDir, runs } = yield* agentFixture( + () => Effect.die("A session that never readies agent tools must not start the daemon"), + processOutput(0), + { httpPort: 12345, token: "previous-token", pid: process.pid, version: "0.0.1" }, + ); + yield* host.stop; + expect(runs.find((run) => run.args.includes("daemon"))?.args).toEqual([ + path.join( + baseDir, + "tools", + "agent-device", + AGENT_DEVICE_VERSION, + "node_modules", + "agent-device", + "bin", + "agent-device.mjs", + ), + "daemon", + "stop", + "--state-dir", + stateDir, + ]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("drops a dead daemon's record without running agent-device when the host stops", () => + Effect.gen(function* () { + // Above the Linux and macOS PID limits, so never a live process. + const deadPid = 4_194_305; + const path = yield* Path.Path; + const { host, fs, stateDir, runs } = yield* agentFixture( + () => Effect.die("A session that never readies agent tools must not start the daemon"), + processOutput(0), + { httpPort: 12345, token: "previous-token", pid: deadPid, version: AGENT_DEVICE_VERSION }, + ); + yield* host.stop; + expect(runs.filter((run) => run.args.includes("daemon"))).toEqual([]); + expect(yield* fs.exists(path.join(stateDir, "daemon.json"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); const diagnose = ( diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index afb408f5cc0fb..c5097509dcade 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -47,7 +47,9 @@ import { isAgentDeviceInstalled, isDeviceHubInstalled, deviceToolVersions, + AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION, + installedAgentDeviceEntry, } from "./DeviceToolchain.ts"; const HUB_READY_TIMEOUT_MS = 30_000; @@ -624,28 +626,44 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { } }); - const stopAgentDeviceDaemon = ( - agentTool: { readonly entryPath: string; readonly nodePath: string } | null, - ) => - agentTool - ? runner - .run({ - command: agentTool.nodePath, - args: [ - agentTool.entryPath, - "daemon", - "stop", - "--state-dir", - agentDeviceStateDir(path, config.stateDir), - ], - env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" }, - timeout: Duration.seconds(10), - timeoutBehavior: "timedOutResult", - }) - .pipe(Effect.ignore) - : Effect.void; - - let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null; + // The daemon may predate this process (an earlier server was killed, possibly on an + // older pin), so stop it with the CLI of its recorded version when that is installed. + const stopAgentDeviceDaemon = Effect.gen(function* () { + if (!(yield* fs.exists(daemonFilePath()))) return; + const recorded = yield* readDaemonFile().pipe(Effect.option); + if ( + Option.isSome(recorded) && + recorded.value.pid && + !(yield* isProcessAlive(recorded.value.pid)) + ) { + return yield* fs.remove(daemonFilePath(), { force: true }); + } + const entryPath = yield* installedAgentDeviceEntry(config.baseDir, [ + ...(Option.isSome(recorded) && recorded.value.version ? [recorded.value.version] : []), + AGENT_DEVICE_VERSION, + ]); + if (!entryPath) return; + const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe( + Effect.provideService(HostProcess.Platform, hostPlatform), + ); + yield* runner.run({ + command: nodePath, + args: [ + entryPath, + "daemon", + "stop", + "--state-dir", + agentDeviceStateDir(path, config.stateDir), + ], + env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" }, + timeout: Duration.seconds(10), + timeoutBehavior: "timedOutResult", + }); + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.ignore, + ); const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, @@ -760,7 +778,6 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { }), ), ); - agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath }; yield* onPhase("starting"); const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath); yield* pruneLocalDeviceTools(config.baseDir, running.hub.nodePath, "agent").pipe( @@ -843,7 +860,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const stopAgent: DeviceHost.DeviceHost["Service"]["stopAgent"] = startLock.withPermits(1)( Effect.gen(function* () { - yield* stopAgentDeviceDaemon(agentToolRef); + yield* stopAgentDeviceDaemon; yield* Ref.update(runningRef, (running) => running ? { ...running, agentDevice: null } : running, ); @@ -855,7 +872,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const running = yield* Ref.getAndSet(runningRef, null); yield* stopHub(running?.hub); yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); - yield* stopAgentDeviceDaemon(agentToolRef); + yield* stopAgentDeviceDaemon; }), );