diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index e133b4f9db36c..5f7cef54f5277 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodePath from "@effect/platform-node/NodePath"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -45,7 +47,8 @@ const agentFixture = Effect.fn("agentFixture")(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-agent-start-" }); - const hub = { running: true, spawnFails: false }; + const hub = { running: true, spawnFails: false, cleanup: Effect.void }; + const daemonStopRequested = yield* Deferred.make>(); const stateDir = agentDeviceStateDir(path, path.join(baseDir, "userdata")); const daemonHealthy = yield* Ref.make(true); const runs: Array = []; @@ -80,20 +83,22 @@ const agentFixture = Effect.fn("agentFixture")(function* ( description: "Hub entry missing", }), ) - : Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(123), - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - exitCode: Effect.never, - isRunning: Effect.sync(() => hub.running), - kill: () => Effect.void, - stdin: Sink.drain, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - unref: Effect.succeed(Effect.void), - }), + : Effect.addFinalizer(() => hub.cleanup).pipe( + Effect.as( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.never, + isRunning: Effect.sync(() => hub.running), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ), ), ), ), @@ -102,7 +107,9 @@ const agentFixture = Effect.fn("agentFixture")(function* ( runs.push(input); return input.args.includes("devices") ? startup(stateDir) - : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)); + : input.args.includes("daemon") + ? Deferred.succeed(daemonStopRequested, input.args).pipe(Effect.as(processOutput(0))) + : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)); }, }), Effect.provideService( @@ -122,7 +129,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ); yield* host.ensureReady(() => Effect.void); - return { host, fs, baseDir, stateDir, hub, daemonHealthy, runs }; + return { host, fs, baseDir, stateDir, hub, daemonHealthy, daemonStopRequested, runs }; }); describe("AgentDevice startup", () => { @@ -353,6 +360,33 @@ describe("AgentDevice startup", () => { expect(yield* fs.exists(path.join(stateDir, "daemon.json"))).toBe(false); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("stops the agent daemon while the hub is still shutting down", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { host, hub, stateDir, daemonStopRequested } = yield* agentFixture((stateDir) => + fs + .writeFileString( + `${stateDir}/daemon.json`, + JSON.stringify({ httpPort: 12345, token: "test-token" }), + ) + .pipe(Effect.orDie, Effect.as(processOutput(0))), + ); + yield* host.ensureAgentReady(() => Effect.void); + const hubExited = yield* Deferred.make(); + hub.cleanup = Deferred.await(hubExited); + const stopping = yield* Effect.forkChild(host.stop); + expect((yield* Deferred.await(daemonStopRequested)).slice(1)).toEqual([ + "daemon", + "stop", + "--state-dir", + stateDir, + ]); + yield* Deferred.succeed(hubExited, undefined); + yield* Fiber.join(stopping); + expect(yield* fs.exists(`${stateDir}/hub.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 c5097509dcade..e657d0dcdfc13 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -870,9 +870,16 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const stop: DeviceHost.DeviceHost["Service"]["stop"] = startLock.withPermits(1)( Effect.gen(function* () { const running = yield* Ref.getAndSet(runningRef, null); - yield* stopHub(running?.hub); - yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); - yield* stopAgentDeviceDaemon; + // Hub cleanup waits for its exit unbounded; it must not strand the detached agent daemon. + yield* Effect.all( + [ + stopHub(running?.hub).pipe( + Effect.andThen(fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore)), + ), + stopAgentDeviceDaemon, + ], + { concurrency: "unbounded", discard: true }, + ); }), );